本文记录我在博客项目中实现评论功能时,涉及的一些细节。
一、后端实现
评论表的核心在于自引用关系:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| @Entity public class Comment { @Id private Long id; private String content; @ManyToOne(fetch = FetchType.EAGER) private Article article; @ManyToOne(fetch = FetchType.EAGER) private User author; @ManyToOne(fetch = FetchType.LAZY) private Comment parentComment; @CreationTimestamp private LocalDateTime createdAt; }
|
设计要点
parentComment 指向自身,实现评论嵌套
- 父评论使用
LAZY,避免循环加载
- 作者、文章使用
EAGER,避免访问时频繁触发延迟加载
- 不在实体中直接维护 children,避免 ORM 复杂度失控
高并发场景应使用join fetch 或DTO投影来精确控制查询行为。
2. 数据访问层(Repository)
1 2 3 4 5 6 7 8 9 10
| public interface CommentRepository extends JpaRepository<Comment, Long> { Page<Comment> findByArticleIdOrderByCreatedAtAsc(Long articleId, Pageable pageable); Page<Comment> findByAuthorId(Long authorId, Pageable pageable); List<Comment> findByParentCommentId(Long parentId); }
|
👉 递归删除时依赖 findByParentCommentId 查找子节点
评论属于高频读写表,后续应在数据库层添加必要索引。
3. 评论创建逻辑(Service)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| public CommentDTO createComment(Long articleId, Long userId, String content, Long parentId) { Article article = articleRepository.findById(articleId)... User author = userRepository.findById(userId)... if (content == null || content.trim().isEmpty()) { throw new RuntimeException("评论内容不能为空"); } if (parentId != null) { Comment parent = commentRepository.findById(parentId)... if (!parent.getArticle().getId().equals(articleId)) { throw new RuntimeException("父评论与文章不匹配"); } comment.setParentComment(parent); } comment = commentRepository.save(comment); return convertToDTO(comment, userId); }
|
核心约束点
- 回复的父评论必须属于同一篇文章
- 所有评论统一通过 DTO 返回,避免实体泄露
- 创建逻辑不做层级限制,展示逻辑交给前端
4. 递归级联删除
删除一条评论时,必须同时删除:
- 所有子评论(递归)
- 所有关联的点赞记录
- 保证外键约束不报错
- 保证操作原子性
实现方案
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| @Transactional public void deleteComment(Long commentId, Long userId) { Comment comment = commentRepository.findById(commentId)... if (!comment.getAuthor().getId().equals(userId)) { throw new RuntimeException("只能删除自己的评论"); } deleteCommentWithChildren(comment); }
private void deleteCommentWithChildren(Comment comment) { List<Comment> children = commentRepository.findByParentCommentId(comment.getId()); for (Comment child : children) { deleteCommentWithChildren(child); } commentLikeRepository.deleteByCommentId(comment.getId()); commentRepository.deleteById(comment.getId()); }
|
设计要点
- 深度优先遍历(DFS)
- 时间复杂度 O(n)
- 使用事务保证一致性
- 显式控制删除顺序,避免外键(FK)约束问题
为避免复杂递归以及保留上下文,可考虑采用逻辑删除private boolean deleted;。
二、前端实现
1. 数据结构设计
前端接收的是平铺数组,自行构建树结构:
1 2 3 4 5 6 7 8 9
| data() { return { comments: [], commentTree: [], newCommentContent: '', replyingTo: null, replyContent: '' } }
|
选择前端构建树结构而不是后端直接递归组装,是为了保持接口简单、避免后端递归查询带来的复杂度,同时让前端掌控展示策略。
2. 评论树构建与展平
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| buildCommentTree(comments) { const map = new Map(); const roots = []; comments.forEach(comment => { map.set(comment.id, { ...comment, children: [] }); }); comments.forEach(comment => { const node = map.get(comment.id); if (!comment.parentId) { roots.push(node); return; } let parent = map.get(comment.parentId); while (parent && parent.parentId) { parent = map.get(parent.parentId); } if (parent) { parent.children.push(node); } else { roots.push(node); } }); return roots; }
|
设计要点
- 支持任意层级回复
- UI 只展示两层
- 不需要后端额外接口
- 构建复杂度 O(n)
前端只展示两层结构,避免前端无限递归渲染