如何基于React,NodeJs,MySQL,开发一个类似于掘金的评论模块

895 阅读5分钟

如何基于React,NodeJs,MySQL,开发一个类似于掘金的评论模块

最近一段时间为了学习React Hook的用法以及服务端渲染等技术,索性就直接将之前使用VUE全家桶弄好的博客给改了。评论这一块之前也是有这个功能的只是做的比较简单,访客之间是不能互动的,只能对他人的留言就行引用然后进行评论。只有管理员在后台对访客进行回复。

参考了很多博客的评论功能,发现很多博客都是采用独立的第三方模块集成,如Valine, Disqus,Gitment,友言,网易云跟帖,畅言,多说等等。有好处:配置简单,开箱即用,清爽又省事。不好的地方:评论是存在第三方服务器,保不齐哪天人家运营不下去,那评论也就没了。如果很在乎读者的评论的话,最好还是自己动手开发,将数据存到自己服务器。

交互方式

类似于掘金如下图

用户可以对文章进行评论,用户也可以对用户的评论进行评论。

结构:左边头像,右边评论。如果存在嵌套,评论里面的结构和上一级一样,嵌套最多为两层。

前端根据以上结构,理想的渲染数据结构如下:

[
  {
    commentId: 1,
    commentLikes: 0,
    commentNickname: 'Fishbone',
    commentTime: '2021-02-05T03:37:27.000Z',
    commentContent: 'this is comment'
    childrend: [
      {
        replyContent: "this is reply content"
        replyFromUid: "daf65129ef6f234ce42f811e068bb389"
        replyId: 33
        replyLikes: 0
        replyNickname: "Kimi"
        replyTime: "2021-02-05T03:38:01.000Z"
        replyToUid: "cdba6f2fe4719a4915e407baf72c496a"
        replyType: 4
      }
    ]
  }
]

后端数据表设计

这里使用 MySql,分别创建评论表 COMMENTS 和回复表 REPLIES,用一张表也能实现。

COMMENTS:

REPLIES:

业务逻辑实现

后端:

后端部分主要是提供查询数据接口,通过文章 id 查询对应的回复和评论。后端采用的是阿里的 Egg.js 。

查询逻辑:

const querySql = `select
        a.id as commentId,
        a.nickname as commentNickname,
        a.content as commentContent,
        a.fromUid as commentFromUid,
        a.articleId,
        a.likes as commentLikes,
        a.createTime as commentTime,
        b.id as replyId,
        b.replyType,
        b.nickname as replyNickname,
        b.content as replyContent,
        b.fromUid as replyFromUid,
        b.toUid as replyToUid,
        b.likes as replyLikes,
        b.createTime as replyTime 
        from 
            COMMENTS a LEFT JOIN REPLIES b 
        on 
            a.id = b.commentId ${conditions}
        order by 
          a.createTime desc,
          b.createTime asc
        ${limitCondition}`
      const countSql = `select 
        count(a.id) as total
        from 
            COMMENTS a left join REPLIES b 
        on 
            a.id = b.commentId where a.isPass = 1 and a.articleId = ${escapeArticleId}`
const result = await this.app.mysql.query(querySql)

这里需要注意 Egg.js 并不推荐这种直接使用 SQL 语句进行查询,容易被 SQL 注入,传入参数的部分都需要进行转义操作比如:${conditions}${escapeArticleId} 都需要做响应的转义如下: 调用 mysql.escape() 方法

const escapeArticleId = mysql.escape(articleId)

这里涉及多表查询,egg.js mysql 模块提供的基本增删改查操作貌似不能满足我的需求。 以上查询的结果大致如下:

result: [
  {
    articleId: 62
    commentContent: "luvfishbones"
    commentFromUid: "58c785abcc2b24a4159c7d9d791369bd"
    commentId: 38
    commentLikes: 0
    commentNickname: "32423423"
    commentTime: "2021-02-08T13:49:45.000Z"
    replyContent: null
    replyFromUid: null
    replyId: null
    replyLikes: null
    replyNickname: null
    replyTime: null
    replyToUid: null
    replyType: null
  }
  ...
]

以上示例数据 articleId = 62, commentId = 38 reply 相关字段都为 null 说明这条评论并没有人进行回复。

如果有回复的情况那么 reply 相关字段就不会为 null。 replyType === 2 表示对评论的回复。 replyType === 3 表示对回复的回复。 replyType === 4 表示作者在后台对评论或者回复进行的回复。

如下示例:

result: [
  ...
  {
    articleId: 62
    commentContent: "msg1"
    commentFromUid: "98c55583934b418028f38b67decc3766"
    commentId: 34
    commentLikes: 0
    commentNickname: "msg1"
    commentTime: "2021-02-08T03:16:07.000Z"
    replyContent: "msg1-2"
    replyFromUid: "2bb93a31cfcc639d641d5ee8b1005b10"
    replyId: 64
    replyLikes: 0
    replyNickname: "msg1-2"
    replyTime: "2021-02-08T03:18:51.000Z"
    replyToUid: "98c55583934b418028f38b67decc3766"
    replyType: 2
  }
  ...
]

由于多表查询产生了一些冗余数据,这种结果并非适合这次前端显示的理想数据结构,这边在拿到数据之后需要在前端进行一个调整。

前端:

前端评论组件通过接口拿到以上分析的数据,通过格式化数据函数:

const formatList = useCallback(list => {
    let res = []
    list.map((item, index) => {
        let previous = ''
        if (index > 0) previous = list[index - 1] 
        const {
            commentContent,
            commentFromUid,
            commentId,
            commentLikes,
            commentNickname,
            commentTime,
            replyId,
            replyContent,
            replyFromUid,
            replyLikes,
            replyNickname,
            replyToUid,
            replyType,
            replyTime,
        } = item
        const li = {
            commentId,
            commentContent,
            commentFromUid,
            commentLikes,
            commentNickname,
            commentTime,
            childrend: []
        }
        if (!replyNickname) {
            res.push(li)
        } else {
            const childItem = {
                replyId,
                replyContent,
                replyFromUid,
                replyLikes,
                replyNickname,
                replyToUid,
                replyType,
                replyTime,
            }
            if (commentId !== previous.commentId) {
                res.push(li)
                li.childrend.push(childItem)
            } else {
                res[res.length - 1].childrend.push(childItem)
            }
        }
    })
    return res
}, [])

来消除接口查询的冗余数据,并构造前端渲染需要的结构:

[
  {
    commentId: 1,
    commentLikes: 0,
    commentNickname: 'Fishbone',
    commentTime: '2021-02-05T03:37:27.000Z',
    commentContent: 'this is comment'
    childrend: [
      {
        replyContent: "this is reply content"
        replyFromUid: "daf65129ef6f234ce42f811e068bb389"
        replyId: 33
        replyLikes: 0
        replyNickname: "Kimi"
        replyTime: "2021-02-05T03:38:01.000Z"
        replyToUid: "cdba6f2fe4719a4915e407baf72c496a"
        replyType: 4
      }
    ]
  }
]

拿到了以上数据结构渲染出以下效果:

点击评论或 Reply 按钮都会在按钮下方创建一个评论框组件如下图: 在不同的地方传入不同的 replyType 插入数据库。

总结

  1. 目前这种结构,分页数据会有点小问题。分页会将原本是在同一条评论里面的内容分成 2 条记录,虽然评论的内容是一样的。这个目前还没有解决,各位看官如果点击更多的话,可能就看出来了。忘大佬指教一波。
  2. 点击评论或 Reply 按钮是通过 API ReactDOM.Render 动态渲染评论框组件到指定id的容器内的,这个有待改进。按理说评论框组件是一个单例模式,只需要加载,渲染一次就行了。使用 ReactDOM.Render 像在使用 Jquery 操作 DOM 来动态显示内容。
  3. 加入表情包功能。