一篇探讨如何使用物化路径与 DFS 排序高效实现树状评论系统的技术文章。文章首先分析了传统平铺式与有限层级评论在上下文追溯时的痛点,随后详细展示了如何通过 Prisma 结合邻接表、物化路径(path)和 DFS 排序键(sortKey)来优化数据库结构。该方案通过后端预先编码树信息并返回扁平数组,配合前端基于标识字段直接进行缩进和折叠渲染,完美解决了无限层级评论在递归查询、分页加载及展示上的性能难题。
前言
树状评论看起来只是简单的无限层级的评论回复评论,但在实际场景中很少见。能说的上来的,恐怕只有 Reddit 了。
其余的一般可以分为两种:
- 没有分层的:例如各类论坛系统(Discoures、Discuz、百度贴吧),一般只使用“@xxx: 回复内容”这样的标识来表示回复关系。
- 分少数几层的:例如哔哩哔哩、知乎、微信公众号,一层主要评论(评论正文的)+一层该评论的回复串,其中该评论的回复串一般是不继续分层的,也使用“@xxx: 回复内容”这样的标识来表示回复关系。
这两种方式都有比较明显的问题,就是找上下文比较的繁琐。部分 App 会考虑内置上下文查找的功能,例如 Discoures 的查看上文/下文、哔哩哔哩的“查看回复”,但无论怎么后天通过功能来弥补,其还是不能直观的看出“那个帖子回复了哪个帖子”,而是需要依赖上文的功能。考虑以下场景:
- A:123
- B:456
- A:回复B:789
- C:回复A:000
问:此时 C 回复的是 A 的 123 呢,还是 789 呢?
我反正是在哔哩哔哩里面碰到过很多次这个问题了,而且哔哩哔哩只能查看评论(下文),上文是找不到的。
当然,上面的问题也有其他解决方法,非常简单粗暴,直接引用整个上文,例如 Github Issue。但这样导致随着评论层级增加,每个评论的长度也增加了,不仅对数据库不友好,对鼠标滚轮也不太友好。
树状评论则能完美的解决这个问题。我去年写了一个论坛系统,就是使用的树状评论,你可以轻松看到一整个评论串:左上是父评论,右下是子评论,同一缩进的上下评论均为同级。
image.png
当然其也是存在缺点的,最明显的是由于有缩进,导致其在深度较大的时候,内容被挤到右侧,左侧大量留空;此外评论看不出所有评论的时间关系,只能看到同级评论的时间顺序。前者可以靠自适应间距来解决,后者只能当作特色了。
此外,为了方便阅读,一般来说你也可以加一些花活来作为辅助功能。例如我上面的论坛系统:德国森林精灵与数字路径 | XEO OS ,支持高亮评论、聚焦评论、hover高亮整个层级、折叠/隐藏,这样阅读体验会更好一些。
image.png
image.png
这个博客系统也使用了类似的方案,不过考虑到评论不会太多,我把聚焦功能去掉了。本文接下来主要介绍博客系统的这款。
image.png
如果真的想把它写得比较舒服,通常还要额外解决几个问题:
- 评论需要有明确的层级关系
- 父评论后面应该紧跟自己的子评论,而不是简单按时间打散
- 顶级评论和子评论最好都支持分页或局部展开
- 前端需要方便判断一条评论是谁的父评论、谁的祖先评论
在这种情况下,如果只给评论表加一个 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 = 当前评论 iddepth = 父层级 + 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 (
<div>
{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` }}
>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<strong>{comment.author.displayName}</strong>
{hasChildren ? (
<button onClick={() => toggleCollapse(comment.id)}>
{isCollapsed ? "展开回复" : "折叠回复"}
</button>
) : null}
</div>
<p>{comment.content}</p>
</article>
);
})}
</div>
);
}这个思路和项目里的真实实现是一致的,只是把登录、点赞、分页加载、评论预览这些和文章主题无关的东西都砍掉了。你可以在 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,同级顺序不是严格时间顺序 - 深度特别大时,移动端缩进空间还是会被吃掉,但这个属于树状评论的通病。
不过对于博客评论这种读多写少、完全不移动树的场景这套方案是足够了。实际上,项目的媒体管理系统也是用的这套类似的方案来实现虚拟文件夹,这里就用到了移动树,有时间我再写一写。
—— 完。
评论