评论与讨论
规则
- 线程评论:
comments 表中的 parent_id 外键——顶级评论为 null,回复引用其父评论
- 乐观 UI:将评论立即添加到界面,用服务器响应确认,出错时回滚
- @提及:解析评论正文中的
@username——在输入 @ 时触发用户查找下拉列表,通知被提及的用户
- 表情反应:独立的
reactions 表(commentId, userId, emoji)——对 (commentId, userId, emoji) 组合有唯一约束
- 审核:
status 字段(published, flagged, removed)——对标记内容有审核队列,通过敏感词列表自动标记
- 软删除:设置
deletedAt 时间戳——对有回复的评论显示“已删除”占位符,完全隐藏没有回复的被删除评论
- 游标分页:
cursor(commentId)+ limit——按 createdAt 排序,滚动时加载更早的评论
- 编辑历史:将编辑记录存储在单独的表或 JSON 数组中——显示“已编辑”指示器和最后编辑时间戳
模式
const comments = await db.query.comments.findMany({
where: and(eq(comments.postId, postId), isNull(comments.parentId)),
with: {
author: { columns: { id: true, name: true, avatar: true } },
replies: {
with: { author: { columns: { id: true, name: true, avatar: true } } },
orderBy: [asc(comments.createdAt)],
},
reactions: true,
},
orderBy: [desc(comments.createdAt)],
limit: 20,
});
const addComment = useMutation({
mutationFn: (body: string) => api.post(`/posts/${postId}/comments`, { body }),
onMutate: async (body) => {
await queryClient.cancelQueries({ queryKey: ["comments", postId] });
const previous = queryClient.getQueryData(["comments", postId]);
queryClient.setQueryData(["comments", postId], (old: Comment[]) => [
{ id: `temp-${Date.now()}`, body, author: currentUser, createdAt: new Date() },
...old,
]);
return { previous };
},
onError: (_err, _body, context) => {
queryClient.setQueryData(["comments", postId], context?.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["comments", postId] }),
});
避免
- 无限嵌套深度——限制在 2-3 层,更深的回复则扁平化处理
- 一次加载所有评论——使用游标分页,尤其是在活跃的讨论中
- 仅客户端解析提及——应在服务端验证和提取提及以发送通知
- 硬删除有回复的评论——这会导致线程孤立;应使用软删除并显示占位符
- 没有唯一约束的反应——允许同一用户重复反应