成为全栈·Next.js 网站前台篇·评论系统:叠楼、回复、删除与内容审核如何落到前台

成为全栈·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

相关推荐
Alice-YUE2 小时前
React 渲染机制速通:Render 树、Fiber 树与更新调度
前端·react.js·前端框架·react·fiber·前端性能·渲染机制
liangshanbo12151 天前
React Fiber:从“解决卡顿”到完整更新链路
前端·javascript·react.js
红红谈说1 天前
论坛帖子审核链路怎么设计?内容安全与状态机的一次复盘
安全·状态机·内容审核·敏感词·异步审核
ss2732 天前
AI全栈实战 | 2.2-02 React 思维模型:数据不可变 vs Vue 数据可变,两种哲学把复杂度推给谁
前端·vue.js·react.js
cindershade3 天前
一条请求的接力赛:用运行时责任重画 RSC 边界
性能优化·next.js
500843 天前
React Native for OpenHarmony 实战:三方库 react-native-device-name 的鸿蒙化适配指南
深度学习·react native·react.js·机器学习·harmonyos
500843 天前
React Native for OpenHarmony 实战:三方库 react-native-torch 的鸿蒙化适配指南
javascript·react native·react.js·harmonyos