前言

评论系统相信大家并不陌生,在社交网络相关的软件中是一种常见的功能。然而对于初学者来说,实现一个完整的评论系统并不容易。本文笔者以 Vue+SpringBoot 前后端分离的架构细说博客评论功能的实现思路。

难点

在这里插入图片描述
对于一个评论系统主要包含评论人,评论时间,评论内容,评论回复等内容。此外可能还存在回复的回复以及回复的回复的回复,每条评论可能存在多条回复,每条回复又可能存在多条回复,即是一个多叉树的关系。因此,难点如下:

  1. 确定并存储评论与回复的层级关系以及与博客本章的从属关系
  2. 多层级评论与回复的前端递归显示
  3. 多层级评论与回复的递归删除

实现思路

数据表设计

首先我们需要考虑的是数据表中如何存储评论与回复的层级关系以及与博客文章的从属关系。

  • 很直观能够想到对于每一条评论,拥有一个表示所属博客文章ID的字段blogId
  • 每一条评论维护一个parentId字段,表示父评论的id,由此确定评论之间的层级关系
  • 此外我们还会维护一个rootParentId字段,表示当前评论所属根评论的id,该字段将在前端递归显示时有大用

于是,添加上其他相关信息后最终的数据表schema如下:

字段名称中文注释数据类型是否为null备注
id评论idbigintnot nullprimary key,auto increment
content评论内容textnot null
user_id评论人idbigintnot null
user_name评论人姓名varchar(80)
create_time创建时间datetime
is_delete是否已删除tinyintdefault 00:未删除;1:已删除
blog_id所属博客idbigint
parent_id父评论idbigint
root_parent_id根评论idbigint

数据传输格式设计

基于数据表schema,我们需要设计前后端数据传输的格式,以方便前后端对于层级关系的解析。

  • 很自然地想到将评论的基本信息封装为 bean,并将其子评论对象封装为其一个属性。
  • 由于每条评论可能存在多条回复,因此属性的数据类型应当为 List

于是得到的评论 bean 为:

/**
 * 评论信息
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Comment implements Serializable {

    private Long id;       // 评论ID
    private String content;       // 评论内容
    private Long userId;          // 评论作者ID
    private String userName;      // 评论作者姓名
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date createTime;      // 创建时间
    private Integer isDelete;     // 是否删除(0:未删除;1:已删除)

    private Long blogId;      // 博客ID
    private Long parentId;    // 父评论ID(被回复的评论)
    private Long rootParentId;      // 根评论ID(最顶级的评论)

    private List<Comment> child;    // 本评论下的子评论
}

那么接下来的问题是如何将数据表中的层级关系转化为 Comment 类中的 father-child 的关系
我这里写了一个 util 的方法完成这个转化过程

/**
* 构建评论树
* @param list
* @return
*/
public static List<Comment> processComments(List<Comment> list) {
    Map<Long, Comment> map = new HashMap<>();   // (id, Comment)
    List<Comment> result = new ArrayList<>();
    // 将所有根评论加入 map
    for(Comment comment : list) {
        if(comment.getParentId() == null)
            result.add(comment);
        map.put(comment.getId(), comment);
    }
    // 子评论加入到父评论的 child 中
    for(Comment comment : list) {
        Long id = comment.getParentId();
        if(id != null) {   // 当前评论为子评论
            Comment p = map.get(id);
            if(p.getChild() == null)    // child 为空,则创建
                p.setChild(new ArrayList<>());
            p.getChild().add(comment);
        }
    }
    return result;
}

这样父子关系就表示清楚了,前端通过接口请求到的数据就会是如下的样子

{
    "success": true,
    "code": 200,
    "message": "执行成功",
    "data": {
        "commentList": [
            {
                "id": 13,
                "content": "r34r43r4r54t54t54",
                "userId": 1,
                "userName": "admin",
                "createTime": "2022-10-26 04:53:21",
                "isDelete": null,
                "blogId": 1,
                "parentId": null,
                "rootParentId": null,
                "child": [
                    {
                        "id": 19,
                        "content": "评论回复测试2",
                        "userId": 1,
                        "userName": "admin",
                        "createTime": "2022-10-27 03:10:41",
                        "isDelete": null,
                        "blogId": 1,
                        "parentId": 13,
                        "rootParentId": 13,
                        "child": null
                    }
                ]
            },
            {
                "id": 12,
                "content": "fdfgdfgfg",
                "userId": 1,
                "userName": "admin",
                "createTime": "2022-10-26 04:51:46",
                "isDelete": null,
                "blogId": 1,
                "parentId": null,
                "rootParentId": null,
                "child": [
                    {
                        "id": 20,
                        "content": "评论回复测试3",
                        "userId": 1,
                        "userName": "admin",
                        "createTime": "2022-10-27 03:16:09",
                        "isDelete": null,
                        "blogId": 1,
                        "parentId": 12,
                        "rootParentId": 12,
                        "child": null
                    }
                ]
            },
            {
                "id": 11,
                "content": "demo",
                "userId": 1,
                "userName": "admin",
                "createTime": "2022-10-26 04:12:43",
                "isDelete": null,
                "blogId": 1,
                "parentId": null,
                "rootParentId": null,
                "child": [
                    {
                        "id": 21,
                        "content": "评论回复测试4",
                        "userId": 1,
                        "userName": "admin",
                        "createTime": "2022-10-27 03:19:42",
                        "isDelete": null,
                        "blogId": 1,
                        "parentId": 11,
                        "rootParentId": 11,
                        "child": null
                    }
                ]
            },
            {
                "id": 9,
                "content": "评论3",
                "userId": 3,
                "userName": "zhangsan",
                "createTime": "2022-10-05 06:20:54",
                "isDelete": null,
                "blogId": 1,
                "parentId": null,
                "rootParentId": null,
                "child": [
                    {
                        "id": 24,
                        "content": "评论回复测试n3",
                        "userId": 1,
                        "userName": "admin",
                        "createTime": "2022-10-27 03:23:54",
                        "isDelete": null,
                        "blogId": 1,
                        "parentId": 9,
                        "rootParentId": 9,
                        "child": null
                    }
                ]
            },
            {
                "id": 7,
                "content": "评论2",
                "userId": 2,
                "userName": "liming",
                "createTime": "2022-10-05 06:19:40",
                "isDelete": null,
                "blogId": 1,
                "parentId": null,
                "rootParentId": null,
                "child": [
                    {
                        "id": 8,
                        "content": "回复2-1",
                        "userId": 1,
                        "userName": "admin",
                        "createTime": "2022-10-14 06:20:07",
                        "isDelete": null,
                        "blogId": 1,
                        "parentId": 7,
                        "rootParentId": 7,
                        "child": null
                    }
                ]
            },
            {
                "id": 1,
                "content": "评论1",
                "userId": 1,
                "userName": "admin",
                "createTime": "2022-10-05 06:14:32",
                "isDelete": null,
                "blogId": 1,
                "parentId": null,
                "rootParentId": null,
                "child": [
                    {
                        "id": 3,
                        "content": "回复1-2",
                        "userId": 2,
                        "userName": "liming",
                        "createTime": "2022-10-07 06:16:25",
                        "isDelete": null,
                        "blogId": 1,
                        "parentId": 1,
                        "rootParentId": 1,
                        "child": [
                            {
                                "id": 6,
                                "content": "回复1-2-1",
                                "userId": 3,
                                "userName": "zhangsan",
                                "createTime": "2022-10-13 06:18:51",
                                "isDelete": null,
                                "blogId": 1,
                                "parentId": 3,
                                "rootParentId": 1,
                                "child": null
                            }
                        ]
                    }
                ]
            }
        ],
        "total": 13
    }
}

对于处于叶子节点的评论,其 child 就为 null

前端递归显示

接下来的一个难题是从后端获取到的这个多叉树结构的数据如何显示出来。

  • 我们首先能想到的是 Vue 里的 v-for 来循环输出所有 comment,再取其 child 进行嵌套 v-for 输出
  • 但是这样就会产生一个问题,v-for 的嵌套次数这么写就是固定的,然而对于这棵多叉树我们并不知道其深度为多少。举个例子,例如我的前端结构是外层一个 v-for 输出所有的 comment,内层一个 v-for 输出这些 comment 的 child。但是这样的结构无法输出 child 的 child,如果再加一层 v-for,又无法输出 child 的 child 的 child。因为我们无法知道这棵树的深度为多少,所以并不能确定 v-for 的嵌套层树。而且这样的一种写法也实在是冗余,缺乏优雅。
  • 因此,我们很自然地想到算法中的递归
  • Vue 中的递归可以利用其独特的父子组件机制实现。简单来说,Vue 允许父组件调用子组件,并可进行数据的传递,那么只要我们让组件自己调用自己并调整传递的数据,那么这不就形成了一个递归结构了吗?

我们接下来来看我的具体实现
blogDetails.vue(父组件)

<!-- 显示评论 -->
<div class="comment-list-container">
	<div class="comment-list-box comment-operate-item">
		<ul class="comment-list" v-for="comment in commentList">
			<!-- 评论根目录 -->
			<root :comment="comment" :blog="blog" :getCommentList="getCommentList"></root>
			<!-- 评论子目录 -->
			<li class="replay-box" style="display: block;">
				<ul class="comment-list">
					<!-- 子组件递归实现 -->
					<child :childComments="comment.child" :parentComment="comment" :blog="blog" :rootParentId="comment.id" :getCommentList="getCommentList" v-if="comment.child != null"></child>
				</ul>
			</li>
		</ul>
	</div>
</div>

在父组件中我们调用了子组件 child 去实现评论的输出,child 来自于 childComment.vue
childComment.vue

<div class="comment-line-box" v-for="childComment in childComments">
	<div class="comment-list-item">
		<el-avatar icon="el-icon-user-solid" :size="35" style="width: 38px;"></el-avatar>
		<div class="right-box">
			<div class="new-info-box clearfix">
				<div class="comment-top">
					<div class="user-box">
						<span class="comment-name">{{ childComment.userName }}</span>
						<el-tag size="mini" type="danger" v-show="childComment.userName === blog.authorName" style="margin-left: 5px;">作者</el-tag>
						<span class="text">回复</span>
						<span class="nick-name">{{ parentComment.userName }}</span>
						<span class="date">{{ childComment.createTime }}</span>
						<div class="opt-comment">
							<i class="el-icon-delete"></i>
							<span style="margin-left: 3px;" @click="deleteComment(childComment)">删除</span>
							<i class="el-icon-chat-round" style="margin-left: 10px;"></i>
							<span style="margin-left: 3px;" @click="showReplay = !showReplay">回复</span>
						</div>
					</div>
				</div>
				<div class="comment-center">
					<div class="new-comment">{{ childComment.content }}</div>
				</div>
			</div>
		</div>
	</div>
	<!-- 回复框 -->
	<replay :rootParentId="rootParentId" :comment="childComment" :showReplay="showReplay" :blogId="blogId" :getCommentList="getCommentList" style="margin-top: 5px;"></replay>
	<!-- 嵌套递归 -->
	<child :childComments="childComment.child" :parentComment="childComment" :blog="blog" :rootParentId="rootParentId" :getCommentList="getCommentList"></child>
</div>

在子组件中,我们递归调用了自身,并设置了子评论和父评论等数据加入下一轮递归,由此完成该递归过程。

删除评论

关于评论的操作无非是添加评论(回复)和删除评论。添加评论比较好理解,只要获取了相关的层级关系数据,如 parentId 等,往数据表里插入一条记录就可以了。然而删除评论则较为复杂,删除评论不仅要删除当前的这条评论(回复),也要删除其子评论(回复),即以该条评论为根结点的子树
为了能完整地删除这棵子树,我们需要遍历这棵子树的每一个结点,比较简单的方式就是层序遍历。这里我采用了非递归的方法,即借助队列实现。

/**
 * 删除评论
 * @param comment
 * @return
 */
@Override
public boolean removeComment(Comment comment) {
    Queue<Comment> queue = new LinkedList<>();
    queue.offer(comment);
    while(!queue.isEmpty()) {
        Comment cur = queue.poll();
        int resultNum = commentMapper.removeById(cur.getId());
        if(resultNum <= 0) return false;
        if(cur.getChild() != null) {
            List<Comment> child = cur.getChild();
            for(Comment tmp: child)
                queue.offer(tmp);
        }
    }
    return true;
}

讲到这里差不多就把评论系统的所有难点讲完了,欢迎指正批评!

Logo

基于 Vue 的企业级 UI 组件库和中后台系统解决方案,为数万开发者服务。

更多推荐