评论系统相信大家并不陌生,在社交网络相关的软件中是一种常见的功能。然而对于初学者来说,实现一个完整的评论系统并不容易。本文笔者以 Vue+SpringBoot 前后端分离的架构细说博客评论功能的实现思路。
对于一个评论系统主要包含评论人,评论时间,评论内容,评论回复等内容。此外可能还存在回复的回复以及回复的回复的回复,每条评论可能存在多条回复,每条回复又可能存在多条回复,即是一个多叉树的关系。因此,难点如下:
首先我们需要考虑的是数据表中如何存储评论与回复的层级关系以及与博客文章的从属关系。
于是,添加上其他相关信息后最终的数据表schema如下:
字段名称 | 中文注释 | 数据类型 | 是否为null | 备注 |
---|---|---|---|---|
id | 评论id | bigint | not null | primary key,auto increment |
content | 评论内容 | text | not null | |
user_id | 评论人id | bigint | not null | |
user_name | 评论人姓名 | varchar(80) | ||
create_time | 创建时间 | datetime | ||
is_delete | 是否已删除 | tinyint | default 0 | 0:未删除;1:已删除 |
blog_id | 所属博客id | bigint | ||
parent_id | 父评论id | bigint | ||
root_parent_id | 根评论id | bigint |
基于数据表schema,我们需要设计前后端数据传输的格式,以方便前后端对于层级关系的解析。
于是得到的评论 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
接下来的一个难题是从后端获取到的这个多叉树结构的数据如何显示出来。
我们接下来来看我的具体实现
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;
}
讲到这里差不多就把评论系统的所有难点讲完了,欢迎指正批评!