读一篇技术文章时,疑问通常落在某个具体地方:一段代码的参数为什么这样写,表格里的条件有没有例外,某句结论能不能展开说明。这次博客改版,我保留并整理了正文旁的评论入口,让讨论可以直接发生在对应内容块边上。
这个交互的核心是给正文块一个稳定身份,再把评论关联到“哪篇文章的哪一块”。按钮和弹窗负责展示,真正决定评论会不会串位置的,是 ID、存储和编辑流程。
下面以 仓库提交 b67d64f为例,说明当前的块级评论与回复实现。划词范围、原句快照和正文改写后的自动重定位,是后续可以扩展的能力。
先看一条评论经过哪些地方
正文渲染时,每个顶层 block 旁边都有一个 Commentable。点击后打开当前块的讨论,提交时发送文章 ID、块 ID、评论文字和可选的父评论 ID。后端校验通过后存储,前端再把返回的评论放入该文章的缓存和当前页面状态。
flowchart TD
A["正文块:blockId"] --> B["Commentable 弹窗"]
B --> C["POST /api/comments/:postId"]
C --> D["身份、文章、块与父评论校验"]
D --> E["comments:postId + body.blockId + parentId"]
E --> F["文章评论查询"]
F --> G["归一化新旧文章 ID"]
G --> H["Query 缓存与当前文章状态"]
H --> B正在加载图表…
这里有两个引用关系:body.blockId 指向正文块,parentId 指向被回复的评论。读者看到的“回复引用”来自父评论内容,而不是一份自动截取的正文原句。
三种 ID,各自解决一个问题
左右滑动查看完整表格
字段 | 含义 | 用在哪里 |
|---|---|---|
| 文章的内部身份 | 查文章、读取评论、划分缓存 |
| 文章内某个正文块的身份 | 关联段落、代码块、图片或表格的讨论 |
| 评论本身,以及它回复的评论 | 回复关系、列表去重、滚动定位 |
文章 URL 的 slug 面向读者,数据库里的文章 ID 面向关联关系。正文的排列顺序则由 sortIndex 保存。块排到第几位和它是谁,是两个应分开保存的信息。
块级评论的实际定位键是 (postId, blockId)。两篇文章碰巧出现相同的块 ID,并不表示它们共用一组评论。数据库为 post_blocks 建立了这两个字段的联合唯一索引,前端过滤时也同时检查它们。
一个简化的请求如下:
{
"body": {
"blockId": "paragraph-installation",
"text": "这里的配置是否也适用于远程环境?"
},
"parentId": null
}它发送到 POST /api/comments/<postId>。用户 ID、昵称和头像由服务端当前会话提供,前端不需要提交“这是谁写的”。
正文从一个大字段变成可以寻址的块
文章保留完整的 Slate JSON,同时把顶层块整理到 post_blocks。每条记录包含 post_id、block_id、排序、类型、结构化内容和提取出的纯文本。这样既能恢复编辑器内容,也能在发表评论时查询某个块是否属于这篇文章。
普通段落、代码块、表格和图片都能成为讨论单位。当前入口挂在顶层块旁边:一个表格有自己的讨论,不会因此自动细分到每个单元格;代码块也不会自动产生每行的独立评论。
渲染层的关系可以缩略为:
blocks.map((block) => (
<Fragment key={block.blockId}>
<div className="group relative article-block">
<PostBlockView block={block} />
<Commentable postId={post.id} blockId={block.blockId} />
</div>
</Fragment>
));内容节点带有 data-block-id,用于识别其对应块。标题还会获得 HTML id,供目录跳转使用。这里要区分两件事:data-block-id 只是 DOM 数据属性,不会自动让普通段落拥有 #blockId 深链接能力;如果要直接分享任意段落,还需要另外实现 URL 与滚动定位逻辑。
数据模型可查看 content.schema.ts,页面入口在 文章路由。
稳定 ID 的重点是保留已有身份
从旧内容转换为编辑器节点时,项目会兼容 blockId、blockID 和 id 三种字段,优先使用已有值,再把它们统一。只有没有身份的块,才根据序号和内容生成兜底 ID。
因此,“稳定”有一个前提:后续编辑和保存必须带回原来的 ID。如果把整篇文章导出为纯 Markdown,再重新解析、丢掉所有块身份,内容或顺序一变,兜底生成的 ID 就可能不同。哈希算法并不能凭空识别“这还是原来的段落”。
更容易忽略的是按 Enter 拆段。编辑器可能为新节点生成新的 id,却复制了旧的 blockId 或 blockID。直接优先读取复制来的字段,就会让两个段落共享一个讨论入口。
withStableBlockIds 会在一轮规范化中记录已经用过的 ID。第一次出现的块保留身份;遇到重复值时,优先采用新节点自己的可用 id,再做兜底去重。可以用下面这个例子理解输入和输出:
const input = [
{ id: 'original', blockId: 'original', children: [{ text: '上半段' }] },
{ id: 'new-slate-id', blockId: 'original', children: [{ text: '下半段' }] },
];
const normalized = withStableBlockIds(input);
// normalized[0].blockId === 'original'
// normalized[1].blockId === 'new-slate-id'这保证了本轮输入里块 ID 不重复,并保留第一次出现的原始身份。它还没有判断旧评论讨论的那句话落在拆分后的哪半段;这类语义迁移需要更细的引用信息。
实现和对应测试在 block-id.ts与 block-id.test.ts。规范化同时出现在编辑器加载、保存和服务端写入路径中。
服务端不能只相信浏览器传来的 blockId
浏览器页面可能过期,用户也能自行构造请求。创建评论时,服务端依次确认:有登录用户,文章存在且已经发布,文字经过长度与空白校验,提供的块确实属于当前文章。
块归属检查的关键条件是两个字段一起查,下面省略了查询构造器:
SELECT id
FROM post_blocks
WHERE post_id = ? AND block_id = ?
LIMIT 1;若块已不存在,接口返回需要刷新文章的错误,不把评论挂到文章中的另一个同名或相邻段落上。回复还会校验父评论属于当前文章,且父评论的 body.blockId 与本次请求一致。
例如,读者在段落 A 的弹窗里回复一条属于段落 B 的评论,即使两个评论 ID 都有效,也会被拒绝。这个约束保护的是讨论上下文,不只是数据库记录是否存在。
接口还要求登录,并使用服务端会话覆盖作者信息;公开返回的个人资料只整理为展示名和头像,不直接暴露历史 userInfo 中可能存在的其他字段。正文长度当前上限为 999,服务端也会拒绝空白评论。对应实现见 评论服务与 接口路由。
改版迁移:旧文章 ID 与新文章 ID 同时读取
博客从旧内容系统迁移后,一篇文章同时保留新的内部 ID 和原 Sanity ID。旧评论仍然保存旧 post_id,新评论则写入新的文章 ID。如果只用新 ID 查评论,页面看起来就像把历史讨论全部丢了。
当前做法是先解析文章身份,再用去重后的两个 ID 查询:
const ids = [...new Set([post.id, post.sanityId])];
// 查询 comments.postId 属于 ids 的记录。
// 返回给前端时,把每条评论的 postId 归一为 post.id。这样不需要为了展示而批量改写旧评论,也保留了原来的评论 ID 和 parentId。新用户回复历史评论时,后端能够验证旧父评论,再将新回复写到标准文章 ID 下。
归一化发生在 API 出口,让前端只处理一种文章身份。旧用户资料中的 firstName、lastName 和头像字段,也在这里转换成当前展示格式。
comment_anchors 表目前承担什么角色
迁移脚本还生成了 comment_anchors,用于记录旧评论定位到哪个文章和块,以及当时能否找到目标。字段中有 quote、range_json 和 anchor_status,很容易仅凭命名就认为划词引用已经完成。
但这版代码的实际情况是:
部分 | 当前行为 |
|---|---|
新评论写入 | 写 |
评论页面读取 | 从 |
| 由迁移脚本生成,记录历史定位结果 |
| 迁移时写入 |
| 现有迁移脚本写入的是评论文字,不是正文选句快照 |
| 模型中预留,但没有自动判定正文语义变化的完整流程 |
因此这张表目前不能作为“完整引用系统已经上线”的证据。要增加划词评论,需要先明确快照存什么、范围基于哪份文本、何时更新状态,再把创建、查询和编辑三条链路接起来。迁移代码可查看 build-d1-import-sql.ts。
前端为什么既有 Query 缓存,又有当前文章状态
TanStack Query 按 ['public', 'comments', postId] 缓存文章评论。页面交互状态则用 Valtio 保存当前文章、打开的块和正在回复的评论,供多个 Commentable 使用。
每个弹窗筛选自己的讨论时,同时检查文章与块:
comments.filter(
(comment) => comment.postId === postId
&& comment.body?.blockId === blockId,
);切换文章时,要清空旧评论、打开状态和回复目标。异步请求返回后也要检查它是否仍属于当前文章,避免慢请求把上一页的评论重新塞回来。
提交成功后,组件先取消该文章仍在进行的评论查询,再更新 Query 缓存并按评论 ID 去重。只有页面仍停留在这篇文章时,才同步更新当前状态、滚动到新评论和清空输入框。这里是收到服务端成功结果后插入,并没有先显示一条尚未成功的乐观评论。
只更新弹窗状态会导致返回文章时看到旧缓存;只更新缓存又可能让当前弹窗没有及时反馈。两处状态的职责需要明确。实现分别位于 commentable.tsx与 blog-post-state.ts。
回复引用是怎样显示和跳转的
每条评论可以带一个 parentId。弹窗按这个 ID 找到父评论,在消息上方展示父作者和一行内容;点击引用后,通过 data-commentid 找到弹窗中的目标元素,再调用 scrollIntoView。
这保留了回复关系,但展示仍是同一个块下按时间排列的列表,没有递归铺开无限层级的评论树。长讨论因此不会不断挤窄阅读宽度。
段落边的头像也做了聚合:按用户 ID 去重,最多显示三个参与者。它用于提示“这里有讨论”,并不是每条评论都对应一个头像。
编辑正文后,哪些情况能保留引用
只调整段落顺序,且原 blockId 被保留下来,评论仍能找到这个块。修改块里的文字并保留 ID,也会让评论继续挂在此处,但这仅说明技术关联仍存在,不保证讨论内容仍然适用。
删掉整个块后,旧评论记录不会因为找不到入口就自动变成其他块的评论。当前页面是跟随现存块渲染入口,因此这类评论可能仍能从文章评论 API 读到,却没有对应的正文入口。现有保存流程也没有自动提供失效评论面板或重新定位向导。
这次更新 Cline 旧文时,就遇到了一段被历史讨论关联的旧配置说明。我把原句放到明确标注的历史内容区域,保留原块 ID,再在正文提供当前配置。这是一次人工保护引用的编辑策略,不能代表系统已经能自动处理任何改写。
普通保存路径会规范化块 ID、更新文章,并重新写入该文章的块记录。批量导入或整篇重写尤其要检查原身份有没有丢失,不能只看新页面是否渲染正常。
如果以后增加划词评论,需要补哪些数据
我会在块级定位之上保存正文版本、被引用文本,以及块内的起止范围;再用少量前后文辅助重定位。下面是未来设计示意,不是当前 API 已接受的请求结构:
{
"blockId": "paragraph-installation",
"revision": "article-revision-42",
"textQuote": {
"exact": "需要重新启动终端",
"prefix": "安装完成后,",
"suffix": "再检查命令是否可用。"
},
"range": {
"start": 6,
"end": 14,
"unit": "utf16"
}
}这个结构还需要定义“把富文本变成可计数文本”的规则。链接、加粗、换行、emoji 都可能改变 DOM 文本节点与字符偏移之间的关系,不能直接保存一次浏览器 Selection,就假设未来的渲染还能恢复它。
正文变化后,可以先尝试同一块内的范围与原句匹配,再结合前后文寻找候选。匹配不到或出现多个候选时,标记待确认并保留原始快照,比悄悄挂到另一句上更可靠。
我用哪些测试确认现有行为
本次写作时运行了三组已有测试:
bun test packages/editor/src/block-id.test.ts \
apps/server/src/modules/comments/index.test.ts \
apps/web/tests/comment-state.test.ts结果为 13 项通过、0 项失败,51 次断言。它们覆盖旧块 ID 保留、拆段去重、新旧文章身份兼容、历史评论回复、未登录与伪造作者拒绝、跨文章/跨块回复拒绝,以及切换文章时的状态隔离。
这些测试验证的是当前代码路径,不包含划词范围迁移、所有浏览器交互,也不代表所有正文编辑都能自动恢复评论。今后如果增加引用快照与失效处理,应围绕“改写后是否仍引用正确内容”补充独立用例。
对这个博客来说,块级评论已经能让一个具体问题留在对应内容旁边。后续继续演进时,我更在意讨论对象的身份能否被可靠保存:从旧系统迁入、在编辑器中修改,再到读者重新打开文章,每一步都应知道这条评论究竟在谈哪一块内容。