成为全栈·Next.js 网站前台篇·评论系统:叠楼、回复、删除与内容审核如何落到前台
后端返回的是带 parentId 的扁平分页,用户看到的却是主楼、回复对象和上下文。前端重建树时,必须同时面对跨页父评论、审核隐藏、删除缺口与异常环形引用。

前言
评论列表最初只要循环数组就能显示。加入回复以后,数组变成树;加入分页以后,父评论和回复可能不在同一页;再加入审核以后,回复可能公开,而它的父评论暂不可见。
当前接口不会按主楼分页,也没有"加载某一楼全部回复"的端点。它按创建时间返回已经审核通过的扁平评论,每项带 parentId。前端可以重建当前已加载范围内的楼层,但不能假装掌握尚未加载的数据。
契约决定前端能承诺什么
| 接口事实 | 前端可以做到 | 前端不能宣称 |
|---|---|---|
| 扁平分页含 parentId | 重建已加载父子关系 | 每页都是完整楼层 |
| 只返回 approved | 不展示审核中与拒绝内容 | 父评论一定存在 |
| POST 返回评论 status | 给出审核结果反馈 | 提交成功必然公开 |
| 删除接口由后端处理级联 | 删除后重新获取 | 自行推断所有后代删除范围 |
因此按钮文案写"加载更多评论与回复",楼内数量写"已加载回复",不把局部数据冒充总数。

从扁平数组建立节点索引
ts
const nodes = new Map<number, CommentNode>()
for (const comment of comments) {
if (comment.status === 'approved') {
nodes.set(comment.id, {
comment,
missingParent: false,
children: [],
})
}
}
先建完整索引,再连接关系,避免子评论出现在父评论前面时找不到节点。即使接口当前按时间升序,算法也不应依赖这一偶然顺序。
连接父子时要防止环形引用
ts
for (const node of nodes.values()) {
const parent = node.comment.parentId
? nodes.get(node.comment.parentId)
: undefined
const seen = new Set([node.comment.id])
let ancestor = parent
while (ancestor && !seen.has(ancestor.comment.id)) {
seen.add(ancestor.comment.id)
ancestor = ancestor.comment.parentId
? nodes.get(ancestor.comment.parentId)
: undefined
}
if (parent && !ancestor && parent.comment.articleId === node.comment.articleId) {
node.parent = parent.comment
parent.children.push(node)
} else {
node.missingParent = Boolean(node.comment.parentId)
roots.push(node)
}
}
seen 防止异常数据形成 A 回复 B、B 又回复 A 的循环。同文章检查则避免错误 parentId 把两篇文章的评论连在一起。无法安全归属的节点作为根显示,并标记父评论不可见。
缺失父评论不能泄露隐藏内容
tsx
{node.parent && (
<a className="comment-context" href={`#comment-${node.parent.id}`}>
<span>回复 {node.parent.userName || '读者'}</span>
<q>{node.parent.content.slice(0, 90)}</q>
</a>
)}
{node.missingParent && (
<p className="comment-unavailable">回复的原评论暂不可见</p>
)}
父评论可能尚未翻页加载、被删除或未通过审核。前端只知道"当前结果里没有",不能猜作者、内容或审核原因,也不应显示一个裸 parentId。
后续分页加载到父评论后,重新对全部已加载数组建树,回复就能回到正确楼层。
深层回复保留关系,但视觉缩进只增加一档
递归渲染 1000 层既可能压窄手机内容,也可能触发调用栈问题。项目使用迭代遍历生成扁平显示序列:
ts
export function threadReplies(root: CommentNode) {
const result: { node: CommentNode; depth: number }[] = []
const stack = root.children
.slice()
.reverse()
.map((node) => ({ node, depth: 1 }))
while (stack.length) {
const entry = stack.pop()
if (!entry) break
result.push(entry)
for (const child of entry.node.children.slice().reverse()) {
stack.push({ node: child, depth: entry.depth + 1 })
}
}
return result
}
真实 parentId 和回复对象仍然保留,CSS 只对 depth > 1 使用同一档浅缩进。信息关系没有丢,阅读宽度也不会随着楼层无限缩小。
评论分页要累计并按 id 去重
tsx
const query = useInfiniteQuery({
queryKey: ['comments', articleId],
initialPageParam: 1,
getNextPageParam: (last) =>
last.pagination.page < last.pagination.totalPages
? last.pagination.page + 1
: undefined,
queryFn: ({ pageParam }) => request(`/articles/${articleId}/comments`, {
skipAuth: true,
skipRefresh: true,
query: { page: pageParam, pageSize: 20 },
}),
})
动态审核或新评论可能造成页码漂移,组合页面时应按 id 去重。追加失败要保留已经加载的楼层:
tsx
{query.isFetchNextPageError && (
<p role="alert">
后续评论加载失败,已加载内容仍保留,请重试。
</p>
)}
刚发表的评论需要临时保留,但不能绕过审核
tsx
const post = useMutation({
mutationFn: () => createComment(articleId, {
content: content.trim(),
parentId: reply?.id,
}),
onSuccess: (comment) => {
if (comment.status === 'approved') {
setPosted((current) => [...current, comment])
}
setContent('')
setReply(null)
setNotice(
comment.status === 'approved'
? '评论已发布'
: comment.status === 'reviewing'
? '评论已提交,等待审核'
: `评论未通过审核${comment.rejectedReason ? `:${comment.rejectedReason}` : ''}`,
)
},
})
只有 approved 评论进入临时展示。reviewing 和 rejected 只显示反馈,不能为了"即时体验"先插入公开列表。
当服务端重新查询已经包含这条评论时,要从临时数组删除,避免重复:
ts
const confirmed = new Set(
pages.flatMap((page) => page.list.map((comment) => comment.id)),
)
setPosted((current) => current.filter((comment) => !confirmed.has(comment.id)))
审核拒绝必须保留用户输入吗
网络或服务错误时,当前输入不会被清空,用户可以修改后重试;服务端成功返回 rejected,则前端展示明确原因,并按当前实现清空已提交内容。
如果产品要求"敏感词拒绝后继续编辑原文",应只在 approved 或用户主动放弃时清空 textarea。这个行为要由产品规则明确,不能把 HTTP 成功等同于内容发布成功。
删除后重新读取服务端结果
tsx
const remove = useMutation({
mutationFn: deleteComment,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['comments', articleId] })
setPosted([])
setReply(null)
setNotice('评论已删除')
},
})
界面会提示"其下回复也可能一并删除",但不在前端自行递归删除。后端可能采用级联、软删除或重新挂载策略,重新获取才是最终事实。
评论系统测试矩阵
| 场景 | 预期 |
|---|---|
| 子评论早于父评论加载 | 暂列根部并提示原评论不可见 |
| 后续页出现父评论 | 重新建树后归入正确主楼 |
| 三层以上回复 | 直接回复对象正确,视觉只浅缩进 |
| 父评论未审核 | 不泄露父内容和作者猜测 |
| 重叠分页 | 按评论 id 去重 |
| 环形 parentId | 不无限循环 |
| approved 新评论 | 立即加入已加载视图 |
| reviewing/rejected | 只显示准确审核反馈 |
| 删除本人评论 | 重新获取,不猜级联结果 |

实际项目还用 1000 层回复验证迭代遍历,并在 1440px 与 375px 下检查溢出。评论树的正确性既需要纯数据测试,也需要真实浏览器确认焦点、展开和锚点。
适用边界
当前方案适合中小规模评论。热门文章评论很多时,按时间扁平分页会持续拆散楼层,后端应升级为按主楼分页并提供楼内回复端点。
实时聊天室、弹幕或协同讨论需要 WebSocket、增量事件和冲突顺序,不能直接套用本文的分页评论模型。
小结
叠楼评论不是把数组递归渲染出来。真正的难点是承认分页和审核造成的信息不完整,并在父评论缺失、后续到达、删除和异常关系中保持诚实。
前端重建的是"当前已加载的可见关系",不是数据库完整真相。这个边界说清以后,界面文案、算法和测试都会更可靠。
延伸阅读
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
