使用物化路径与 DFS 排序来实现树状评论

前往 https://ravelloh.com/posts/implement-tree-comments-materialized-path-dfs 查看以获得最佳体验。

前言

树状评论看起来只是简单的无限层级的评论回复评论，但在实际场景中很少见。能说的上来的，恐怕只有 Reddit 了。

其余的一般可以分为两种：

没有分层的：例如各类论坛系统（Discoures、Discuz、百度贴吧），一般只使用“@xxx: 回复内容”这样的标识来表示回复关系。

分少数几层的：例如哔哩哔哩、知乎、微信公众号，一层主要评论（评论正文的）+一层该评论的回复串，其中该评论的回复串一般是不继续分层的，也使用“@xxx: 回复内容”这样的标识来表示回复关系。

这两种方式都有比较明显的问题，就是找上下文比较的繁琐。部分 App 会考虑内置上下文查找的功能，例如 Discoures 的查看上文/下文、哔哩哔哩的“查看回复”，但无论怎么后天通过功能来弥补，其还是不能直观的看出“那个帖子回复了哪个帖子”，而是需要依赖上文的功能。考虑以下场景：

A：123

B：456

A：回复B：789

C：回复A：000

问：此时 C 回复的是 A 的 123 呢，还是 789 呢？

我反正是在哔哩哔哩里面碰到过很多次这个问题了，而且哔哩哔哩只能查看评论（下文），上文是找不到的。

当然，上面的问题也有其他解决方法，非常简单粗暴，直接引用整个上文，例如 Github Issue。但这样导致随着评论层级增加，每个评论的长度也增加了，不仅对数据库不友好，对鼠标滚轮也不太友好。

树状评论则能完美的解决这个问题。我去年写了一个论坛系统，就是使用的树状评论，你可以轻松看到一整个评论串：左上是父评论，右下是子评论，同一缩进的上下评论均为同级。

当然其也是存在缺点的，最明显的是由于有缩进，导致其在深度较大的时候，内容被挤到右侧，左侧大量留空；此外评论看不出所有评论的时间关系，只能看到同级评论的时间顺序。前者可以靠自适应间距来解决，后者只能当作特色了。

此外，为了方便阅读，一般来说你也可以加一些花活来作为辅助功能。例如我上面的论坛系统：德国森林精灵与数字路径 | XEO OS ，支持高亮评论、聚焦评论、hover高亮整个层级、折叠/隐藏，这样阅读体验会更好一些。

这个博客系统也使用了类似的方案，不过考虑到评论不会太多，我把聚焦功能去掉了。本文接下来主要介绍博客系统的这款。

如果真的想把它写得比较舒服，通常还要额外解决几个问题：

评论需要有明确的层级关系

父评论后面应该紧跟自己的子评论，而不是简单按时间打散

顶级评论和子评论最好都支持分页或局部展开

前端需要方便判断一条评论是谁的父评论、谁的祖先评论

在这种情况下，如果只给评论表加一个 parentId，虽然也能勉强做出来，但后面在查询、排序和渲染时会比较麻烦。因此，使用深度优先搜索 + 物化路径来优化性能是个不错的方案。

实现

在 NeutralPress 这个项目里，我用的是邻接表 + 物化路径 + DFS 排序键的组合，在评论创建时就把 DFS 顺序编码进数据库字段，后面查询和渲染的性能就很高了。（实际上，prisma 对无限递归查询的支持不太好，大概只能这样才比较实用。）

也就是：

parentId 表示这条评论直接回复谁

depth 表示当前层级，前端拿来算缩进

path 表示从根评论到当前评论的完整祖先链

sortKey 表示这条评论在整棵树中的 DFS 顺序

replyCount 记录直接子评论数量，方便前端判断是否需要展开回复

评论表结构

项目里评论表的核心字段大概是这样的：

model Comment {
id String @id @default(uuid())
content String

parentId String?
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
replies Comment[] @relation("CommentReplies")

depth Int @default(0)
path String @default("") @db.VarChar(2000)
sortKey String @default("") @db.VarChar(500)
replyCount Int @default(0)

@@index([parentId])
@@index([postId, status, sortKey])
@@index([pageId, status, sortKey])
@@index([path])
}

这里最关键的其实不是 parentId，而是后面这些辅助字段。只靠 parentId，你只能知道这个评论的上一级评论，却没法高效查询：

这条评论的所有后代是谁（用于展开）

这棵子树应该排在整篇评论流的什么位置（前端排序）

某条评论折叠后，哪些后代应该一起隐藏

某条评论下方是否还有更多没展开的后代

path、depth 和 sortKey 用于标识这些附加字段。

创建评论时补全树信息

在创建评论时，先查询父评论，再计算自己的 depth，最后回填 path 和 sortKey：

let depth = 0;
let path = "";
let parentSortKey = "";

if (parentId) {
const parentComment = await prisma.comment.findUnique({
where: { id: parentId },
select: { depth: true, path: true, sortKey: true },
});

depth = parentComment.depth + 1;
parentSortKey = parentComment.sortKey;
}

const record = await prisma.comment.create({
data: {
content,
parentId: parentId || null,
depth,
path: "",
sortKey: "pending",
replyCount: 0,
},
select: { id: true },
});

path = parentId
? `${parentComment.path}/${record.id}`
: record.id;

const finalSortKey = parentId
? `${parentSortKey}.${record.id}`
: record.id;

await prisma.comment.update({
where: { id: record.id },
data: { path, sortKey: finalSortKey },
});

顶级评论的 path 就是自己的 id，例如：

a

如果 a 下面有个子评论 b，那么它的 path 就会变成：

a/b

再往下一层 c：

a/b/c

于是 depth 也就分别是 0、1、2。如果你想找 a 的所有评论串，只需要：

const comment = { path: "a", postId: "test" };

const descendantCount = await prisma.comment.findMany({
where: {
postId: comment.postId,
path: { startsWith: comment.path + "/" },
deletedAt: null,
},
});

此外，项目里还会在子评论创建成功后给父评论的 replyCount + 1，用空间换时间，防止还需要额外判断是否存在子评论。

不过，这里的sortKey 段值直接用了评论 id，能保证的是“树顺序稳定”，不是同级严格按发布时间排序。如果你希望同级也严格按时间升序，可以把每一段换成零填充时间戳、Snowflake，或者每个父评论下的自增序号。

后端返回

如果一篇文章真的有几千条评论，一次性查整棵树、递归组装后再返回，会导致数据库传输的数据太多。我这里加了两层优化（现在的评论系统就是这种）：

首屏只分页拿顶级评论

每个顶级评论先预加载少量子评论，更深的按需展开

顶级评论的查询大概是这样的：

const rootWhere: Prisma.CommentWhereInput = {
postId: target.id,
deletedAt: null,
parentId: null,
...(cursor ? { sortKey: { gt: cursor } } : {}),
};

const rootComments = await prisma.comment.findMany({
where: rootWhere,
orderBy: { sortKey: "asc" },
take: pageSize + 1,
select: commentSelect,
});

也就是说，首屏实际上只看主评论，子评论不加入。但如果完全不带子评论，也就看不出树状效果了。所以需要额外做一层预加载：

每个主评论最多先带 5 条一级子评论，每个一级子评论再最多带 5 条二级子评论。

这样首屏一打开就已经有树状结构的感觉，也不会导致每一页加载的评论数太多。

而当用户点击“展开回复”时，则调用 getDirectChildren，只加载某个父评论的直接子评论：

const where: Prisma.CommentWhereInput = {
postId: target.id,
deletedAt: null,
parentId,
depth: parentDepth + 1,
...(cursor ? { sortKey: { gt: cursor } } : {}),
};

const children = await prisma.comment.findMany({
where,
orderBy: { sortKey: "asc" },
take: pageSize + 1,
select: commentSelect,
});

这里用了两个条件：

parentId = 当前评论 id

depth = 父层级 + 1

parentId 用来锁定直属评论，depth 用来再保险，避免脏数据把别的层级混进来。

至于“这条评论下面到底还有多少后代没展开”，可以直接利用 path 的前缀匹配来统计：

const descendantCount = await prisma.comment.count({
where: {
postId: comment.postId,
path: { startsWith: comment.path + "/" },
deletedAt: null,
},
});

这就是物化路径的好处，想查整棵子树，不用递归或 WITH RECURSIVE，直接前缀匹配即可。

前端渲染

前端这边我没有真的把评论重新组装成嵌套 children 树，而是直接用后端返回的扁平数组，然后按 sortKey 排序、按 depth 缩进、按 path 判断可见性。

一个简化后的写法如下：

"use client";

import { useMemo, useState } from "react";

type CommentItem = {
id: string;
parentId: string | null;
content: string;
depth: number;
path: string;
sortKey: string;
replyCount: number;
author: {
displayName: string;
};
};

function isHiddenByCollapse(
comment: CommentItem,
collapsedIds: Set<string>,
commentsMap: Map<string, CommentItem>,
) {
for (const collapsedId of collapsedIds) {
const collapsed = commentsMap.get(collapsedId);
if (collapsed && comment.path.startsWith(`${collapsed.path}/`)) {
return true;
}
}
return false;
}

export default function TreeComments({
initialComments,
}: {
initialComments: CommentItem[];
}) {
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());

const comments = useMemo(
() =>
[...initialComments].sort((a, b) =>
a.sortKey.localeCompare(b.sortKey),
),
[initialComments],
);

const commentsMap = useMemo(
() => new Map(comments.map((comment) => [comment.id, comment])),
[comments],
);

const visibleComments = useMemo(() => {
return comments.filter((comment) => {
return !isHiddenByCollapse(comment, collapsedIds, commentsMap);
});
}, [comments, collapsedIds, commentsMap]);

const toggleCollapse = (commentId: string) => {
setCollapsedIds((prev) => {
const next = new Set(prev);
if (next.has(commentId)) {
next.delete(commentId);
} else {
next.add(commentId);
}
return next;
});
};

return (

{visibleComments.map((comment) => {
const hasChildren = comment.replyCount > 0;
const isCollapsed = collapsedIds.has(comment.id);

return (
<article
key={comment.id}
style={{ paddingLeft: `${comment.depth * 24}px` }}
>

{comment.author.displayName}
{hasChildren ? (
toggleCollapse(comment.id)}>
{isCollapsed ? "展开回复" : "折叠回复"}

) : null}

{comment.content}

);
})}

);
}

这个思路和项目里的真实实现是一致的，只是把登录、点赞、分页加载、评论预览这些和文章主题无关的东西都砍掉了。你可以在 NeutralPress/apps/web/src/components/client/features/posts/CommentsSection.tsx at main · RavelloH/NeutralPress 看完整的实现。

核心逻辑是：

后端返回扁平列表，但已经按 sortKey 排好了 DFS 顺序

前端直接 map 渲染，不需要再递归建树

缩进靠 depth，折叠靠 path 前缀判断

在实际项目里，我还额外维护了一个 childPaginationMap，用来区分“这条评论是折叠了”还是“这条评论的子评论其实还没加载出来”，这样局部展开和分页加载都更自然。

原理

树状评论排序

假设现在有这样一棵评论树：

A
| B
| | C
| D
E

如果把它们的 sortKey 设计成：

A
A.B
A.B.C
A.D
E

那么只要按字符串升序排序，结果天然就是一次深度优先遍历：

A
A.B
A.B.C
A.D
E

也就是说，数据库的压力非常小，只是在按一个普通字符串字段排序而已；真正让这个顺序看起来像树的，是前端根据 depth 做的缩进。

整套方案的重点并不是 DFS 算法本身，而是把 DFS 的结果提前编码进了 sortKey 里，也就是物化路径。

path 和 sortKey 的区别

这两个字段看起来有点像，但职责其实完全不同：

sortKey 负责排序和游标分页

path 负责祖先链判断和子树查询

depth 负责缩进和直接层级判断

只用 path 也不是完全不行，但会有两个问题：

不好做稳定分页，因为“某条评论之后是谁”这件事本质上还是排序问题

前端做折叠时，容易把“祖先关系判断”和“显示顺序”搅在一起

分成三个字段之后就很清晰了：

一条评论是不是另一条评论的后代：看 path.startsWith(parent.path + "/")

整篇评论流应该怎么排：看 sortKey

该往右缩进多少：看 depth

前端不使用递归来建树而使用扁平数组

做评论树，一般都是把数据组装成：

type TreeComment = Comment & {
children: TreeComment[];
};

这样当然能 render，我在 XEOOS 里面就是这样做的，但我发现，一旦要做局部展开、只加载直接子评论、根评论无限滚动，递归就会重复太多次，且随着同屏评论的增加而性能下降。

这个项目里前端保留的是扁平数组，

首屏加载 10 条顶级评论

某个父评论展开后，把新拿到的子评论按 sortKey 合并进原数组

渲染时直接遍历一遍

哪些该显示，靠 path 和展开状态判断

这样做有几个非常实际的好处：

增量合并简单，mergeBySortKey 一次搞定

分页简单，根评论和子评论都可以走同一套 cursor 思路

折叠简单，只要判断当前评论是不是某个折叠节点的后代

高亮祖先路径也简单，可以用comment.path.split("/") 直接拿祖先链

所以从渲染树的角度看它像树；但从状态管理的角度看，它其实一直是个有顺序的扁平列表。

总结

优点：

父评论后面天然跟着自己的子评论，渲染方便

折叠、展开、高亮祖先、局部加载都很好做

不需要数据库递归查询，查询性能高

前后端都只是在处理排序好的列表，复杂度低

缺点：

path 和 sortKey 都是冗余字段，浪费一点空间

如果要移动一整棵子树，后代所有节点的 path / sortKey 需要一起改

当前实现若直接用 UUID 片段做 sortKey，同级顺序不是严格时间顺序

深度特别大时，移动端缩进空间还是会被吃掉，但这个属于树状评论的通病。

不过对于博客评论这种读多写少、完全不移动树的场景这套方案是足够了。实际上，项目的媒体管理系统也是用的这套类似的方案来实现虚拟文件夹，这里就用到了移动树，有时间我再写一写。

—— 完。