【项目实战】如何设计一个可以无限叠楼并且支持一键跳转目标的游标评论树?

这一篇是本人开源博客社区项目CCBlog的系列教程,我尽量把所有相关的细节和难点全部用通俗的方式讲清楚,全程干货满满,如果有疑问欢迎提问我会及时解答。

实现目标

无限层级评论树指的是一个可以无限展开的树形评论区结构,每一层的回复被挂载在父评论上,可以无限展开 。最终要实现是下面的效果:每一条评论都有一个展开回复/收起回复,点击之后就会有更多评论被渲染或者被收起 ,当然要有回复才能展开,对于同级评论最后一条评论之后又展开更多表示渲染更多评论

下面是一个三层的无限层级评论树演示,实际上可以有更多层

而一键跳转指在很多平台的评论区都有,指的是在别人回复自己后点击回复评论就可以直接跳转评论区查看,而且往往会把这一条评论置顶,操作直观且友好。

一般来说这个功能是比较简单的,但是由于项目使用无限层级评论树,渲染是递归的过程,我又想要渲染出所有评论前的子评论,那么就变得相对复杂。下面是一个gif的示意:

可以看到,别人给我的评论回复了之后我点击查看,然后立马跳转到对应文章的评论下,并且一直渲染到目标评论出现,然后高亮显示

下面就展开介绍如何实现这样的一个评论区。

请求数据协议

数据表定义

评论表结构如下:

字段名 数据类型 说明 备注
id int(10) unsigned 主键ID 自增
article_id int(10) unsigned 文章ID
user_id int(10) unsigned 用户ID
content varchar(300) 评论内容
parent_comment_id int(10) unsigned 父评论ID
create_time timestamp 创建时间 默认当前时间
update_time timestamp 最后更新时间 默认当前时间并更新

除去内容相关,最重要的字段是评论ID(id)、所属文章ID(article_id)、父评论ID(parent_comment_id,不回复评论的为0)。

前后端通信协议

对于评论区的渲染,最简单的就是直接把全部评论数据直接查出来全部传给前端,但是这样的缺点是如果评论区的数据规模很大,并且当并发量提高之后,服务器的数据负载压力非常大,因此这样的设计显然不利于后期的扩展,并且用户翻评论区的评论数量往往是有限的,并且白白浪费了大量流量。并且游标对于并发场景可控,也方便后续扩大规模。

借鉴目前主流平台如抖音的做法,采用游标 的思路来优化,简单来说就是每次请求一批,如果要渲染更多的时候,继续发请求请求更多数据

游标在数据库用的比较多,一般记为cursor。简单理解就像是一个标记,用这个标记来告诉数据库我要取什么位置的数据,比如以评论区为例,游标是一个日期数据,前端把这个数据发给后端,就代表请求比这个日期更早的评论 (一般评论区从晚到早渲染评论),后端根据游标查询也很简单,举个例子,要查询某文章articleId的日期小于游标cursor的,回复某条评论parentIdlimit条回复:

sql 复制代码
SELECT * FROM comment WHERE
c.article_id = #{articleId} AND
c.parent_comment_id=#{parentId} AND (c.create_time < #{cursor}
LIMIT #{limit}

这样的简单几行查询就查询到了时间小于cursor(更早)的评论数据。

前面提到还需要告诉前端这一层是否还有更多数据,这样前端决定是否渲染同层级的 展开更多,做法其实很简单,就是每次查询都多查询一条,如果查询到的实际数量小于等于请求数量,那么就是没有更多 。举个简单的例子,比如前端请求20条,我查21条,假如我查到21条那么就是还有更多(至少还有一条),否则就是没有更多了。当然还需要告诉前端每一条评论是否有回复,这样才能决定是否渲染子评论的展开回复

一般会设定一个专门的数据结构来存放这样查询得到的结果:

java 复制代码
public class CommentCursorVO {
    /**
     * 是否还有更多(本条评论是是否有更多展开)
     */
    private Boolean hasMore;
​
    List<CommentItemDTO> comments;
}
​
public class CommentItemDTO extends BaseCommentDTO{
    // 评论id
    private Long commentId;
​
    // 父评论id
    private Long parentCommentId;
​
    // 顶级评论id
    private Long topCommentId;
​
    // 是否有回复(本条评论是否有回复)
    private Boolean hasReply;
}

其中CommentItemDTO为游标结构,里面的CommentItemDTO定义了每一条评论的基本信息,这个就完全取决于渲染的需求。

顶级评论:直接回复文章的评论,所有评论都对应一个顶级评论,为楼的地基

子评论:回复评论的评论

到了这里,拿数据的过程就非常直观了:一开始前端渲染出文章,需要渲染评论,此时没有渲染任何评论,使用一个空游标(或置为其他等效无条件的游标)请求到第一批顶级评论,用户点击展开,请求对应的子评论,如果点击查看更多,那么请求更多渲染同层级评论

由于仅仅用时间作为游标可能出现重复的情况,所以一般还会加上评论的id作为第二游标,在时间相同时用id作为排序依据,这样避免了重复的情况

评论树渲染

拿数据的过程明白了,问题就在于前端拿到数据如何渲染。

渲染的这个过程是一个递归的过程,每一条评论都可以看作是一个递归的单元,每个单元的结构都是一样的,除去评论自身信息,都有三个操作:展开更多(同级),展开/收起回复(子评论)。因此每个递归组件的构成就是自身信息+子评论列表,子评论列表专门用于挂载子评论,

直接说很抽象,举个例子整理一下整体的流程。前端应该首先请求一批顶级评论数据,比如五条,此时会渲染出这些评论的评论内容信息和回复状态。假如我现在要展开第一条的评论的子评论,那么直接发送请求,把这一条的信息发到服务求拿到五条回复,这时候把这五条挂载到评论的子评论列表里面,因为子评论列表也是一样的结构(同样的递归单元),那么这五条渲染好了自身内容和回复信息,如果想继续展开,那么只需要做相同的事情即可。

一个简单的评论树示意图如下:

假设每次请求五条数据,黄色为已经渲染的数据,蓝色为存在但未请求的,绿色展开更多指示了蓝色框如果还有更多评论那么需要点击展开更多请求 。可以看到顶级评论请求了五条数据,显然顶级评论还有更多(hasMore=True),此时可以点击展开更多 请求更多同级数据,然后继续挂载到递归单元的链上。点击顶级评论第三条评论的展开回复 ,子评论只有三条,因此只能渲染三条,此时不能继续展开更多hasMore=False),第一层的第一条子评论有回复,可以继续点击展开回复渲染更多子评论,渲染第二层...就这样可以无限递归叠加下去。

具体到代码核心就是渲染递归单元:

html 复制代码
<!-- 先渲染自己的内容,然后挂载子评论列表 -->
<CursorCommentItem :item="item">
<div v-if="showSub" class="sub-wrap">
  <RecursiveCommentItem
    v-for="c in children"
    :key="c.commentId"
    :item="c"
    :comment-parents="props.commentParents"
    :target-index="props.targetIndex !== undefined ? props.targetIndex + 1 : undefined"
    :if-ok="props.ifOk"
    :authorId="props.authorId"
  />
  <div class="expand-container" v-if="hasMore">
    <div class="expand" @click="loadMore()">展开更多</div>
  </div>
</div>

这个Vue文件本身就是RecursiveCommentItem,在自己的模板定义中又把自己定义在模板中 ,这就是递归 在渲染中的体现。里面v-for="c in childrenchildren是一个列表,存储了所有挂载的子评论信息,每一个元素都是评论递归节点。这些元素在每次请求到数据后加入到子评论列表中,获取的代码如下:

js 复制代码
async function fetchChildren() {
  // 固定参数
  const params: Record<string, any> = {
    articleId: articleId,
    parentId: Number(props.item.commentId),
    limit: 5 // 获取数量
  }
​
  // 设置游标参数
  if (cursor.value) {
    params.cursor = dayjs(cursor.value).format('YYYY-MM-DDTHH:mm:ss') // 去时区
    params.cursorId = cursorId.value
  }
  
  try {
    const res = await doGet<CommonResponse<CommentCursorVO>>(COMMENT_CURSOR_URL, params)
    const vo = res.data.result!
    children.value.push(...vo.comments) // 加入到子评论列表中
    
    hasMore.value = vo.hasMore          // 更新是否有更多
    if (vo.comments.length) {
      const last = vo.comments[vo.comments.length - 1]
      cursor.value = last.commentTime
      cursorId.value = Number(last.commentId)
    }
  } catch (error) {
    console.error('获取评论数据失败:', error)
    hasMore.value = false // 出错时停止加载
  }
}

一键跳转

目标和核心思路

一键跳转就是在通知中点击查看回复可以直接跳转到相应的评论,需要实现的目标主要有下面两点:

  1. 顶级评论直接置顶,相当于把整棵树放在最前面
  2. 子评论一直展开展开到目标评论出现,比如目标评论在第2层的第13条,回复的是第2层的第2条,那么第2层的评论要展开到2出现(1,2,3,4,5),第三层要展开到13出现(1,2,3...,12,13,14,15),不进行多余展开。

小知识点 :如果在url里面加上#目标id信息别人http://localhost:8080/article/detail/10#comment-8,那么在渲染的时候就会自动滚动到idcomment-8的评论,因此跳转到目标评论的目标就是渲染出对应评论

我采取的思路是从数据库先查出目标评论的父子链,也就是说给出评论回复关系一直查到顶级评论,把所有id串起来形成父子链,这样前端可以判断每一层需要渲染到谁。并且为了能够置顶渲染顶级评论,需要直接查出顶级评论的全部信息(内容等信息),方便直接置顶

核心需要告诉前端的回复结构如下(父子链+顶级评论信息):

java 复制代码
/**
* 评论的祖先节点序列->父子链
*/
List<Long> commentParents;
​
/**
* 顶级评论信息
*/
private CommentItemDTO topCommentInfo;

具体实现

首先后端需要查询出父子链和评论信息,评论信息比较简单不展开,查询父子链的语句如下:

sql 复制代码
<select id="getAllParentIdByCommentId">
    <!-- 预处理 得到chain -->
    WITH RECURSIVE chain AS (
    SELECT id, parent_comment_id    -- 第一个SELECT定义初始条件
    FROM comment
    <where>
        id = #{commentId}               -- 要查评论 id
    </where>
    UNION ALL
    SELECT c.id, c.parent_comment_id  -- 第二个SELECT定义递归查找条件
    FROM   comment c
<!-- 能拼就一直拼,顶级评论的parent_id为0是找不到comment_id匹配的 -->
    JOIN chain p ON c.id = p.parent_comment_id)
    
<!-- 实际数据 -->    
    SELECT id
    FROM   chain
    ORDER BY id ASC;              -- 顶级评论节点-目标节点
</select>

解释一下,这是一个使用递归CTE 查找的SQL语句。对于第一段with...,,每次查出一条评论就会把<id,parent_comment_id>拼起来,然后继续用父评论的id,也就是parent_comment_id继续匹配找其父评论id,直到无法匹配,无法匹配就是评论id为0,没有对应的评论。然后下面的SELECT id...就会把id列取出来,也就是需要的父子链。

前端得到了父子链和顶级评论信息,先渲染顶级评论并置顶,接下来是渲染子评论直到目标评论出现,下面举一个例子,以父子链为100->7->14为例:

每一个框表示一条评论,框内有全局唯一的评论id。假设现在需要跳转到评论14,那么现在需要渲染出路径上的所有评论,直到目标出现

  1. 首先渲染顶级评论100,展开回复。
  2. 第一层目标要找到7,第一次展开回复得到1,2,3,4,5,没有出现7,展开更多得到6,7,8,9,10,这时候出现7了,那么第一层展开完成,展开7的回复。
  3. 第二层目标要查找14,发现已经找到,结束查找,此时直接跳转到14并将14高亮显示。

具体到代码的实现,在控制渲染的入口也就是onMount渲染评论的时候如果发现有目标评论信息,那么特殊处理。用一个全局标记ifOk用于标记是否已经找到了,否则会出现已经展开高亮显示过了,但是再次展开又高亮显示的清理。(相当于控制是否查找目标)

js 复制代码
onMounted(async () => {
  // 根据传入的targetIndex开始处理评论链展开逻辑
  // 正常评论不用管/顶级评论不用管(length为1)/target超出不用管(已经找到了)
  if(props.targetIndex === undefined||props.commentParents&&props.commentParents.length==1||
      props.commentParents && props.targetIndex>=props.commentParents.length) return
  if(props.ifOk) return
  await findCommentInChain(props.targetIndex);
});

展开的逻辑就是不断请求直到该层目标找到,展开并进入下一层,核心代码如下:

js 复制代码
async function findCommentInChain(targetIndex = -1) {
  if(!props.commentParents||targetIndex>0&&props.commentParents[targetIndex-1]!==props.item.commentId) return false // 直接返回
  try {
    // 当前需要查找的目标评论ID
    const targetCommentId = props.commentParents[targetIndex];
    await ensureLoad()
​
    let found = children.value.some(child => {
      return Number(child.commentId) === Number(targetCommentId)
    })
    let cnt = 0
    showSub.value = true 
    while (!found && hasMore.value && cnt < 20) {
      cnt++
      // 再拉下一页
      await fetchChildren() // 展开评论:继续拉取下一页子评论
      found = children.value.some(child => {
        return Number(child.commentId) === Number(targetCommentId)
      })
      if (found) {
        break
      }
    }
​
    // 如果这是最后一个目标评论,滚动到该位置
    if (targetCommentId === props.commentParents[props.commentParents.length - 1]) {
      // 直接通过事件通知顶级评论更新状态(已找到)
      emitOk()
​
      // 跳转到目标评论
      setTimeout(() => {
        const element = document.getElementById(`comment-${targetCommentId}`);
        if (element) {
            element.scrollIntoView({ behavior: 'smooth', block: 'center' });
            // 高亮提示更明显:黄色背景 + 红色边框
            element.classList.add('bg-yellow-200', 'border-2', 'border-blue-300');
            setTimeout(() => {
              element.classList.remove('bg-yellow-200', 'border-2', 'border-blue-300');
            }, 2000);
          }
        }, 300);
        return true;
      } 
  } catch (error) {
    console.error('查找评论链时出错:', error);
    return false;
  }
}

总结

评论树是项目中思维难度比较大的一个地方,因为涉及到递归,有些地方并不好理解,但这是我觉得最有意思的地方,无论是无限叠楼还是一键跳转的交互都是比较有意思的体验,因此放在了最前面来写,。

这部分主要的难点在于前端渲染逻辑的理解和一键跳转的递归条件终止条件,如果这两部分能理解了,那么整体的思路就非常明确了。

问题和改进

纵观主流平台,真正用无限层级评论树于生产的并不多,大多是只有两层或者三层,个人猜测原因可能是楼层过高不利于界面渲染,并且深递归可能对查询性能造成损害。改进的方案有很多,比如在三层之后不再叠加楼层,转为A回复B这样的简单平铺评论区。

一键跳转我本来想做为直接传回目标评论的父-子评论链,并且全部在对应层级置顶渲染,但是考虑到这样失去了子评论的上下文,因此选择渲染评论直到目标出现。但是如果评论区的规模变大,那么就必须要采用这样的方案了。

还有一个问题就是假设有两个用户一直互相回复,那么就会一直叠楼,导致前端界面渲染困难。这种情况其实直接提示楼层到顶就可以了,博客平台实际上是有私聊功能的,这种或许直接私聊更合适!

总的来看,当前的评论区存在的问题在小规模时没什么问题,但是在规模变大后会出现一些性能和渲染问题,那么如何既能保留无限层级评论区的有趣的交互,又可以尽量避免规模过大导致的问题,可以采用折中的方案:根据文章的总评论数量来判断,如果小于一个可接受的阈值那么用评论树来渲染,否则把多余某个楼层的折叠为平铺评论区

如果有想要加入这样的功能的,可以告诉我或者一起来开发!

完整代码

本文章所有代码已开源,完全独立自主开发,支持Docker一键部署测试,地址:

GithubCCBlog-Github

GiteeCCBlog-Gitee

如果这篇文章帮到了你,欢迎在 GitHub/Gitee 点个⭐,也欢迎在评论区写想要看的其他教程! 您的 Star + 反馈,就是我持续输出干货的最大动力!

相关推荐
Mintopia8 小时前
🚀 2025全栈架构方案汇总与未来分析
人工智能·架构·全栈
我叫黑大帅1 天前
存储管理在开发中有哪些应用?
前端·后端·全栈
汤姆Tom1 天前
前端转战后端:JavaScript 与 Java 对照学习指南(第三篇 —— Map 对象)
java·javascript·全栈
HX4361 天前
Swift - Sendable (not just Sendable)
人工智能·ios·全栈
Mintopia1 天前
🚀 Supabase:强力的服务端助手
数据库·架构·全栈
陈佬昔没带相机2 天前
Trae SOLO 挑战全栈开发记账 App,Token狂烧
ai编程·全栈·vibecoding
汤姆Tom3 天前
前端转战后端:JavaScript 与 Java 对照学习指南 (第二篇 - 基本数据类型对比)
java·javascript·全栈
Mintopia6 天前
无界微前端:父子应用通信、路由与状态管理最佳实践
架构·前端框架·全栈
Mintopia6 天前
🎭 小众语言 AIGC:当 Web 端的低资源语言遇上“穷得只剩文化”的生成挑战
人工智能·aigc·全栈