Vue+SpringBoot实现评论功能

前言

评论系统相信大家并不陌生,在社交网络相关的软件中是一种常见的功能。然而对于初学者来说,实现一个完整的评论系统并不容易。本文笔者以 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;
}

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

  • 38
    点赞
  • 268
    收藏
    觉得还不错? 一键收藏
  • 20
    评论
实现回复评论功能的基本思路如下: 1. 在前端页面中,为每个评论添加一个“回复”按钮或者图标,点击该按钮可以弹出一个输入框,用于输入回复内容。 2. 在后端 Spring Boot 中,需要创建一个 RESTful API 接口,用于接收前端传来的回复内容,并将该回复内容存储到数据库中。 3. 在数据库中,需要为每个评论添加一个字段,用于存储其回复内容。可以考虑使用一个 JSON 格式的字符串来存储回复内容和回复者信息等相关信息。 4. 在前端页面中,需要使用 Axios 或者其他类似的 HTTP 请求库,向后端发送 POST 请求,将回复内容提交到后端保存。 5. 在前端页面中,需要动态渲染出已有的评论及其回复内容,并显示在页面上。 6. 在后端 Spring Boot 中,需要编写代码来查询数据库中的评论及其回复内容,并将其返回给前端。 下面是一个简单的实现示例: 1. 前端页面代码: ```html <!-- 评论列表 --> <div class="comment-list"> <div class="comment-item" v-for="(comment, index) in comments" :key="index"> <div class="comment-info"> <div class="comment-user">{{ comment.user }}</div> <div class="comment-content">{{ comment.content }}</div> <div class="comment-time">{{ comment.time }}</div> <button class="reply-btn" @click="showReplyDialog(index)">回复</button> </div> <div class="comment-reply" v-if="comment.reply"> <div class="comment-user">{{ comment.reply.user }}</div> <div class="comment-content">{{ comment.reply.content }}</div> <div class="comment-time">{{ comment.reply.time }}</div> </div> </div> </div> <!-- 回复对话框 --> <div class="reply-dialog" v-if="showDialog"> <textarea v-model="replyContent"></textarea> <button @click="submitReply">提交</button> </div> ``` 2. 前端页面 JS 代码: ```javascript import axios from 'axios' export default { data() { return { comments: [], // 评论列表 showDialog: false, // 是否显示回复对话框 replyIndex: -1, // 当前回复的评论索引 replyContent: '' // 回复内容 } }, created() { this.getComments() }, methods: { // 获取评论列表 getComments() { axios.get('/api/comments') .then(response => { this.comments = response.data }) .catch(error => { console.log(error) }) }, // 显示回复对话框 showReplyDialog(index) { this.showDialog = true this.replyIndex = index }, // 提交回复内容 submitReply() { axios.post('/api/comments/' + this.replyIndex + '/reply', { content: this.replyContent, user: '匿名用户', time: new Date().toLocaleString() }) .then(response => { this.showDialog = false this.replyContent = '' this.getComments() }) .catch(error => { console.log(error) }) } } } ``` 3. 后端 Spring Boot 代码: ```java @RestController @RequestMapping("/api/comments") public class CommentController { @Autowired private CommentRepository commentRepository; // 获取评论列表 @GetMapping("") public List<Comment> getComments() { return commentRepository.findAll(); } // 回复评论 @PostMapping("/{index}/reply") public void replyComment(@PathVariable int index, @RequestBody Reply reply) { Comment comment = commentRepository.findById(index).get(); comment.setReply(reply); commentRepository.save(comment); } } ``` 4. 数据库表结构: ```sql CREATE TABLE `comment` ( `id` int(11) NOT NULL AUTO_INCREMENT, `content` varchar(255) NOT NULL, `user` varchar(50) NOT NULL, `time` varchar(50) NOT NULL, `reply` json DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` 5. Reply 类定义: ```java public class Reply { private String content; private String user; private String time; // getter 和 setter 略 } ``` 以上是一个简单的回复评论功能实现示例,您可以根据实际需求进行修改和扩展。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 20
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值