ProseMirror实现目录增量更新


ProseMirror实现目录增量更新

ai生成

1. 文档范围

本文分析对象是当前工作区中的:

  • packages/plugins/src/toc/plugin-toc.ts
  • packages/plugins/src/toc/defs.ts
  • packages/plugins/src/toc/build-toc-incremental.ts
  • packages/plugins/src/toc/plugin-toc.test.ts
  • packages/plugins/src/toc/build-toc-incremental.test.ts

当前工作区的 plugin-toc.ts 是一版以 AttrStepReplaceStepReplaceAroundStep 为核心的 Step 驱动增量实现;仓库已提交版本则已经演进为“变化范围 + 最终文档派生 + 全量兜底”的方案。本文先忠实解释当前文件,再结合已提交版本说明它的演进方向。

注意:分析时 plugin-toc.ts 存在未提交修改。本文不会假设这版代码已经通过当前仓库的全部测试,而会明确区分“设计意图”“实现亮点”和“已验证风险”。

2. 一句话定位

plugin-toc.ts 是一个 ProseMirror 状态插件:它把编辑器文档中的标题节点派生成 TOCPluginState,并在标题新增、修改、删除或位置变化时,向外部目录 UI 提供最新状态和增量回调。

真正困难的部分不是“找到 heading”,而是同时处理以下四件事:

  1. 标题身份在多次编辑中保持稳定。
  2. 标题位置在新旧文档坐标系之间正确迁移。
  3. 一个用户操作产生多个 Step 时,只输出最终一致状态。
  4. 在大文档高频输入下,避免每次都做昂贵的全量计算和 UI 更新。

3. 核心数据模型与不变量

当前 TOCItem 的完整定义是:

1
2
3
4
5
6
7
8
interface TOCItem {
id: string;
level: string;
text: string;
pos: number;
nodeType: string;
leadingSymbol: string | null;
}

各字段职责不同:

字段 职责 是否稳定 典型用途
id 标题的逻辑身份,来自子 paragraph 的 line-id 相对稳定 增删改 diff、回调去重
pos 标题在当前文档中的绝对位置 不稳定 排序、点击目录后跳转
level 标题层级 可变 目录缩进、层级展示
text 标题展示文本 可变 目录内容
nodeType 标题载体类型 可变 区分 heading、列表、任务项
leadingSymbol 列表型标题的前导符 可变且可能受邻居影响 展示 1.、项目符号、任务符号

理想情况下,插件应始终满足以下不变量:

  1. TOC 项顺序与文档中的标题顺序一致。
  2. 每个可识别标题最多对应一个 TOC 项。
  3. item.pos 必须属于当前 EditorState.doc 的坐标系。
  4. 同一 line-id 在一次事务结束后最多产生一次最终语义回调。
  5. TOC state 应由最终文档状态决定,不应长期保留事务中间态。

其中最关键的一条是:**id 表示身份,pos 表示位置,二者不能混用。**

4. 插件生命周期总流程

下面这张图只负责展示插件初始化和 state.apply 的主控制流。真正的增删改判定分别在第 5 节的三张子流程图中展开,避免把 40 多个节点挤进一张无法阅读的图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
flowchart TD
create(["createTOCPlugin"])

subgraph initPhase ["初始化阶段"]
init["state.init"]
createState["创建空 TOC 数组"]
scanDoc["nodesBetween 遍历文档"]
isHeadingNode{"节点是标题?"}
extractItem["读取 line-id、level、text、pos"]
appendItem["追加 TOCItem"]
scanDone{"文档遍历完成?"}
emitInitial["触发 onList"]
returnInitial["返回初始 TOC state"]
end

subgraph applyPhase ["事务更新阶段"]
apply["state.apply"]
cloneState["复制旧 TOC state"]
createPending["创建 pendingLevelById"]
docChanged{"tr.docChanged?"}
readStep["读取下一个 tr.steps 项"]
stepType{"Step 类型"}
attrFlow[["执行 AttrStep 子流程"]]
replaceFlow[["执行 Replace 类子流程"]]
unknownStep["不做语义增删改"]
mapPositions["用完整 tr.mapping 映射全部 pos"]
hasMoreSteps{"还有 Step?"}
returnUpdated["返回 updated"]
end

create --> init
init --> createState --> scanDoc --> isHeadingNode
isHeadingNode -->|"是"| extractItem --> appendItem --> scanDone
isHeadingNode -->|"否"| scanDone
scanDone -->|"否"| scanDoc
scanDone -->|"是"| emitInitial --> returnInitial --> apply

apply --> cloneState --> createPending --> docChanged
docChanged -->|"否"| returnUpdated
docChanged -->|"是"| readStep --> stepType
stepType -->|"AttrStep"| attrFlow --> mapPositions
stepType -->|"ReplaceStep 或 ReplaceAroundStep"| replaceFlow --> mapPositions
stepType -->|"其他"| unknownStep --> mapPositions
mapPositions --> hasMoreSteps
hasMoreSteps -->|"是"| readStep
hasMoreSteps -->|"否"| returnUpdated

style initPhase fill:#C2E5FF,stroke:#3DADFF
style applyPhase fill:#FFECBD,stroke:#FFC943

4.1 初始化阶段

state.init 使用 doc.nodesBetween 遍历文档,识别 level 存在且不为 subtitle 的节点,提取:

  • 子节点上的 line-id
  • 节点的 level
  • getSelectionText 返回的展示文本
  • 节点当前位置 pos

初始化是全量过程,时间复杂度约为 O(N)N 是被遍历的文档节点总数。

4.2 事务更新阶段

state.apply 复制上一份 TOC state,然后遍历 tr.steps

  • AttrStep:处理 line-idlevel 等属性变化。
  • ReplaceStep:处理文本插入、删除、替换、节点新增和拆分。
  • ReplaceAroundStep:处理包裹、提升、列表转换等结构变化。
  • 每处理完一个 Step,重新映射全部 TOC 项的位置。

5. 当前 Step 驱动增量流程

5.1 AttrStep:补充新增 ID 与更新 level

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
flowchart TD
attrStart(["进入 AttrStep 分支"])
valueValid{"step.value 有效?"}
attrType{"step.attr"}

resolveLineId["在 tr.doc.resolve(step.pos)"]
parentHeading{"parent 是标题?"}
extractText["从 newState 提取标题文本"]
buildNewItem["构造 TOCItem"]
insertByPos["按 pos 插入 updated"]
emitAdd["触发 onAdd"]

resolveLevel["在 tr.doc.resolve(step.pos)"]
levelHeading{"parent 是标题?"}
readLineId["读取子 paragraph 的 line-id"]
isLevelAttr{"属性是 level?"}
rememberLevel["写入 pendingLevelById"]
findExisting["按 line-id 查找 TOC 项"]
itemFound{"找到 TOC 项?"}
updateLevel["将 level 写为 step.value"]
emitUpdate["触发 onUpdate"]
attrDone(["结束 AttrStep 子流程"])

attrStart --> valueValid
valueValid -->|"否"| attrDone
valueValid -->|"是"| attrType
attrType -->|"line-id"| resolveLineId --> parentHeading
parentHeading -->|"否"| attrDone
parentHeading -->|"是"| extractText --> buildNewItem --> insertByPos --> emitAdd --> attrDone

attrType -->|"level 或 pos"| resolveLevel --> levelHeading
levelHeading -->|"否"| attrDone
levelHeading -->|"是"| readLineId --> isLevelAttr
isLevelAttr -->|"是且存在 ID"| rememberLevel --> findExisting
isLevelAttr -->|"否"| findExisting
findExisting --> itemFound
itemFound -->|"否"| attrDone
itemFound -->|"是"| updateLevel --> emitUpdate --> attrDone
attrType -->|"其他"| attrDone

style rememberLevel fill:#DCCCFF,stroke:#874FFF
style emitAdd fill:#CDF4D3,stroke:#66D575
style emitUpdate fill:#FFECBD,stroke:#FFC943

AttrStep 有两个实际用途:

  1. line-id 分支补偿“节点已经插入,但稳定 ID 稍后才写入”的创建流程。
  2. level 分支把标题层级变化暂存在 pendingLevelById,并更新已有 TOC 项。

当前代码还把 step.attr === 'pos' 放入 level 更新分支,这是第 8 节指出的潜在错误:它会把 step.value 写入 TOCItem.level

5.2 ReplaceStep:判定新增与更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
flowchart TD
replaceStart(["进入 Replace 类分支"])
calculateRange["start = step.from"]
insertion{"from 等于 to?"}
insertRange["end = from + slice.size"]
replaceRange["end = step.to"]
scanNew["遍历 newState.doc 的 start 到 end"]
headingNode{"当前节点是标题?"}
mapBack["tr.mapping.invert().map(pos, 1)"]
readOldNode["从 oldState.doc 读取 oldNode"]
extractTexts["提取旧文本和新文本"]
oldHeading{"oldNode 是标题?"}

readNewId["读取新节点 line-id"]
hasNewId{"line-id 存在?"}
deferAdd["跳过并等待 AttrStep"]
buildAdded["构造新增 TOCItem"]
insertAdded["按 pos 插入 updated"]
emitAdded["触发 onAdd"]

contentChanged{"文本或 level 变化?"}
findUpdated["按 line-id 查找 TOC 项"]
updateFound{"找到 TOC 项?"}
chooseLevel["优先 pending level,否则 node.level"]
writeUpdated["更新 level 和 text"]
emitUpdated["触发 onUpdate"]
continueNew["继续遍历后续节点"]
hasMoreNew{"还有区间节点?"}
newScanDone(["新文档扫描结束"])

replaceStart --> calculateRange --> insertion
insertion -->|"是"| insertRange --> scanNew
insertion -->|"否"| replaceRange --> scanNew
scanNew --> headingNode
headingNode -->|"否"| continueNew
headingNode -->|"是"| mapBack --> readOldNode --> extractTexts --> oldHeading

oldHeading -->|"否"| readNewId --> hasNewId
hasNewId -->|"否"| deferAdd --> continueNew
hasNewId -->|"是"| buildAdded --> insertAdded --> emitAdded --> continueNew

oldHeading -->|"是"| contentChanged
contentChanged -->|"否"| continueNew
contentChanged -->|"是"| findUpdated --> updateFound
updateFound -->|"否"| continueNew
updateFound -->|"是"| chooseLevel --> writeUpdated --> emitUpdated --> continueNew
continueNew --> hasMoreNew
hasMoreNew -->|"是"| headingNode
hasMoreNew -->|"否"| newScanDone

style deferAdd fill:#D9D9D9,stroke:#B3B3B3
style emitAdded fill:#CDF4D3,stroke:#66D575
style emitUpdated fill:#FFECBD,stroke:#FFC943

新增标题

新增标题有两条处理路径。

第一条是 ReplaceStep 路径:

  1. 扫描新文档受影响区间。
  2. 找到 heading-like 节点。
  3. 将新位置通过 tr.mapping.invert() 映射回旧文档。
  4. 如果旧位置没有 heading,则判定为新增。
  5. 如果已经能读取 line-id,立即按 pos 插入 TOC。

第二条是 AttrStep(line-id) 路径:

  1. 某些创建流程先插入节点结构,再单独写入 line-id
  2. ReplaceStep 阶段因为没有稳定 ID,暂不新增。
  3. 后续 AttrStep 写入 line-id 时,再构造并插入 TOC 项。

这个延迟处理体现了一个重要原则:宁可稍后建立目录项,也不要建立一个没有稳定身份、无法可靠 diff 的目录项。

更新标题

更新来源主要有两类:

  • AttrStep(level):标题层级改变。
  • ReplaceStep:标题文本或节点内容改变。

当一个事务同时修改 level 和文本时,pendingLevelById 会记录已经看到的 level:

1
const pendingLevelById = new Map<string, string>();

后续处理文本变化时优先读取 pending level,避免用最终文档节点中的旧值覆盖前面已经捕获的新值。

这本质上是在解决“一个用户意图由多个底层 Step 表达”的问题。

5.3 旧文档扫描:判定删除并更新位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
flowchart TD
deleteStart(["新文档扫描完成"])
scanOld["遍历 oldState.doc 的 start 到 end"]
oldHeading{"旧节点是标题?"}
mapForward["tr.mapping.map(pos, -1)"]
readMapped["从 newState.doc 读取 mappedNode"]
readIds["读取旧、新 line-id"]
removed{"节点消失、降级或 ID 改变?"}
keepItem["保留原 TOC 项"]
filterItem["按旧 line-id 过滤 updated"]
emitRemove["触发 onRemove"]
continueOld["继续遍历后续旧节点"]
hasMoreOld{"还有区间节点?"}
oldScanDone(["旧文档扫描结束"])
mapAll["映射 updated 中全部 pos"]
moreSteps{"还有 Step?"}
nextStep(["返回主流程处理下一 Step"])
returnState(["返回 updated"])

deleteStart --> scanOld --> oldHeading
oldHeading -->|"否"| continueOld
oldHeading -->|"是"| mapForward --> readMapped --> readIds --> removed
removed -->|"否"| keepItem --> continueOld
removed -->|"是"| filterItem --> emitRemove --> continueOld
continueOld --> hasMoreOld
hasMoreOld -->|"是"| oldHeading
hasMoreOld -->|"否"| oldScanDone
oldScanDone --> mapAll --> moreSteps
moreSteps -->|"是"| nextStep
moreSteps -->|"否"| returnState

style keepItem fill:#C2E5FF,stroke:#3DADFF
style emitRemove fill:#FFCDC2,stroke:#FF7556

删除逻辑从旧文档反向确认:

  1. 扫描 oldState.doc 的受影响区间。
  2. 找到旧标题。
  3. tr.mapping.map(pos, -1) 映射到新文档。
  4. 如果新位置没有节点、已不是标题,或稳定 ID 已改变,则认为旧标题被删除。
  5. line-id 从 TOC 中过滤,并触发 onRemove

删除比新增更容易误判,因为删除后的边界位置可能映射到相邻节点。单点映射只能提供候选位置,不能天然证明节点身份仍然存在。

6. 增量更新最难的地方

6.1 同一事务中存在多套坐标系

这是整个实现最核心的难点。

一个包含多个 Step 的事务至少涉及:

  • oldState.doc 坐标系。
  • 第一个 Step 执行前后的坐标系。
  • 第两个 Step 执行前后的坐标系。
  • tr.doc / newState.doc 最终坐标系。

step.fromstep.tostep.pos 通常属于该 Step 执行时的局部坐标系;tr.mapping 则描述整个事务从初始文档到最终文档的映射。如果直接拿某个 Step 的位置访问最终文档,就可能命中错误节点。

1
2
3
4
5
6
7
8
9
10
flowchart LR
oldDoc["oldState.doc"]
stepOne["Step 1 坐标"]
stepTwo["Step 2 坐标"]
finalDoc["tr.doc 和 newState.doc"]

oldDoc -->|"StepMap 1"| stepOne
stepOne -->|"StepMap 2"| stepTwo
stepTwo -->|"剩余 StepMap"| finalDoc
oldDoc ==>|"tr.mapping"| finalDoc

面试时可以强调:ProseMirror 位置 bug 的根源通常不是 API 不会用,而是没有先说明当前变量属于哪一版文档坐标系

6.2 pos 不能表示身份

在文档开头插入一个字符,后续所有标题的 pos 都会变化,但它们并没有被删除重建。因此:

  • line-id 用于回答“是不是同一个标题”。
  • pos 用于回答“这个标题现在在哪里”。
  • mapping 用于回答“旧位置在新文档中移动到了哪里”。

这是增量目录设计中最值得在面试里讲清楚的抽象边界。

6.3 新增节点的稳定 ID 可能延迟出现

节点结构和业务 ID 不一定在同一个 Step 中写入。如果只看 ReplaceStep

  • 过早新增:可能产生 id === undefined 的目录项。
  • 直接跳过:后续又可能永远漏掉该标题。

当前方案用 AttrStep(line-id) 作为补偿路径,是对真实编辑器命令链和协同数据流程的适配。

6.4 一个用户操作不等于一个 Step

“把段落改成二级标题并修改文本”可能同时包含结构替换、属性写入和文本替换。插件如果逐 Step 立刻发回调,外部 UI 可能观察到:

  1. 先新增一个旧 level 的标题。
  2. 再收到 level 更新。
  3. 再收到文本更新。

而用户只做了一次操作。理想输出应基于事务最终状态合并为一次语义变化。

6.5 删除、拆分和替换的分类存在歧义

标题拆分可能同时表现为:

  • 原标题文本缩短。
  • 新标题出现。
  • 后续标题位置整体移动。

标题替换则可能表现为旧 ID 消失、新 ID 在同一位置出现。仅根据位置,很容易把新增误判为更新,或把替换误判为位置移动。

6.6 目录顺序必须与文档顺序保持一致

批量把文档前半部分的段落转换为标题时,新 TOC 项不能简单追加到数组末尾。insertTOCItempos 找插入点,保证目录顺序与文档顺序一致。

这个问题在单标题用例里不明显,批量转换和中间插入时才会暴露。

6.7 性能优化不能只看扫描范围

“只扫描局部区间”不代表整体一定快。还要计算:

  • 每个 Step 是否都遍历全部 TOC 项。
  • 每次 mapping.map 内部要经过多少个 StepMap。
  • findIndexfilter 是否被重复执行。
  • 回调是否引发 React 同步渲染。
  • 是否因为对象全部重建而破坏引用复用。

目录插件运行在每次输入的热路径上,算法常数和 UI 副作用都很重要。

6.8 协同编辑会放大对 Step 类型的依赖

本地命令、历史撤销、协同数据和自定义变换可能产生不同 Step 组合。对 instanceof AttrStep/ReplaceStep/ReplaceAroundStep 的强依赖意味着:

  • 新 Step 类型可能完全绕过 TOC 更新。
  • 跨包或不同运行时中的类身份判断需要额外注意。
  • 同一用户语义可能有多种底层表示。

因此更稳健的设计通常是:优先从 oldState.docnewState.doctr.mapping 和统一变化范围工具推导结果,并为未知情况保留全量重建兜底。

7. 当前实现值得肯定的亮点

7.1 将稳定身份与文档位置分离

line-id 做身份、用 pos 做定位,是整个方案能够成立的基础。这个设计可推广到评论锚点、协同光标、批注、侧边栏索引等场景。

7.2 覆盖属性、内容和结构三类变化

代码没有只处理文本替换,而是同时识别:

  • AttrStep
  • ReplaceStep
  • ReplaceAroundStep

说明实现考虑了普通输入、节点属性修改、列表转换、结构包裹等不同编辑路径。

7.3 对 line-id 延迟写入做补偿

ReplaceStep 读取不到 line-id 时先跳过,再由 AttrStep(line-id) 完成新增,避免生成不可追踪的临时 TOC 项。

7.4 使用 pendingLevelById 合并跨 Step 信息

这个 Map 是一个小而有效的事务级缓存,解决了“同一事务中 level 和文本更新顺序交错”的问题,体现了对最终一致性的关注。

7.5 新增项按文档位置插入

insertTOCItem 不修改原数组,并按 pos 插入,兼顾了不可变状态和目录顺序。

7.6 新旧文档双向检查

新增和更新从新文档扫描,删除从旧文档扫描。这个对称思路比只观察新文档更完整,因为已经消失的节点只能在旧文档中找到。

7.7 复用编辑器统一文本提取逻辑

通过 getSelectionText(..., { showSequence: true }) 提取标题文本,能与编辑器已有文本语义保持一致,而不是重新实现一套 inline 节点序列化规则。

8. 当前工作区版本的风险与改进点

以下内容不是对设计思路的否定,而是基于当前代码和测试结果得到的工程结论。

8.1 TOCItem 与当前类型定义不一致

当前文件构造的 TOC 项只有 idleveltextpos,但 defs.ts 还要求:

  • nodeType
  • leadingSymbol

这会导致列表型标题能力缺失,也说明工作区文件与当前接口、测试基线并不同步。

8.2 Step 局部坐标被直接用于最终文档

例如代码使用:

1
const $pos = tr.doc.resolve(step.pos);

以及:

1
newState.doc.nodesBetween(step.from, end, ...)

对于多 Step 事务,step.pos/from/to 不一定属于最终 tr.doc 坐标系。更稳妥的做法是使用该 Step 的 StepMap,或把局部范围通过后续 mapping 映射到最终文档。

8.3 全事务 mapping 在 Step 循环中被重复应用

当前代码在每个 Step 结束后执行:

1
2
3
4
updated = updated.map((item) => ({
...item,
pos: tr.mapping.map(item.pos, 1),
}));

这里有两个风险:

  1. 旧 TOC 位置可能被整个事务的 mapping 重复映射多次。
  2. newState.doc 读取的新增项位置本来已经是新坐标,再映射会产生二次偏移。

正确的边界通常是:旧 state 的位置只从 old 映射到 new 一次;直接从 newDoc 构造的项不再映射。

8.4 onAdd 中也可能二次映射新增位置

insertTOCItem 接收到的 tocItem.pos 注释声明已经属于当前事务文档,但回调参数又执行 tr.mapping.map(tocItem.pos, 1)。这会让 state 中的位置和回调中的位置不一致。

8.5 AttrStep(attr === 'pos') 被写入 level

当前分支同时处理 step.attr === 'level' || step.attr === 'pos',但更新对象时统一执行:

1
level: step.value;

如果确实存在 pos 属性 Step,这会把位置属性值错误写入标题级别。应明确两个属性各自的业务含义,不能共享同一赋值逻辑。

8.6 isHeading 判断过宽

当前判断只检查 node.attrs.level,没有限制节点类型和层级。任何带 level 属性且不是 subtitle 的节点都可能被识别为标题;配合 nodesBetween 深度遍历时,还可能扫描到不应独立进入 TOC 的嵌套节点。

8.7 对 children[0] 的直接访问缺少防御

多处直接读取 node.children[0].attrs['line-id']。空节点、异常协同数据或结构未完成时可能触发运行时异常。当前部分新增逻辑已经使用 childCount 和可选链,其他路径应保持一致。

8.8 回调在 state.apply 内产生副作用

ProseMirror plugin state 的 apply 最好保持纯计算。直接调用外部 onAdd/onUpdate/onRemove 会带来:

  • 同一事务多 Step 导致重复回调。
  • 外部 React 状态更新进入编辑器事务热路径。
  • 中间态可能先于最终 TOC state 暴露。
  • 调试、回放和测试更难保证确定性。

仓库已提交版本把最终差分发布放在 view.update,以最终 TOC 快照统一计算和去重,是更清晰的职责分层。

8.9 缺少未知 Step 和复杂变化的全量兜底

当前版本无法识别的 Step 仍会执行位置映射,但不会重新检查标题内容,可能让 TOC 静默过期。生产级增量算法通常需要“能证明安全才增量,否则全量重建”的保守策略。

8.10 非文档事务仍会破坏引用复用

apply 一开始执行 let updated = [...value],所以即使 tr.docChanged === false,也返回新数组。这样会丢失 state 的引用相等性,可能让下游做无意义更新。

9. 性能复杂度分析

设:

  • N:文档总节点数。
  • B:顶层 block 数。
  • H:目录标题数。
  • S:事务中的 Step 数。
  • C:变化区间内扫描到的节点数。
  • Mtr.mapping 中的 StepMap 数,通常与 S 同阶。
环节 当前工作区版本复杂度 说明
初始化 O(N) nodesBetween 深度遍历全文
变化区间扫描 O(C) 但 Step 区间和最终文档坐标可能不一致
每 Step 位置更新 O(H × M) 每个 TOC 项都经过完整 transaction mapping
整个事务位置更新 O(S × H × M) M ≈ S 时接近 O(H × S²)
单次新增/更新查找 O(H) 使用 findIndex
单次删除 O(H) 使用 filter

这说明当前方案虽然减少了文档扫描范围,但在多 Step、大量标题的事务里,位置映射和数组查找仍可能成为热点。

更合理的优化方向包括:

  1. 旧 TOC 位置只映射一次。
  2. Map<id, item> 降低身份查找成本。
  3. 未变化项保持对象引用。
  4. 只对受影响标题重新提取文本。
  5. 变化范围过大或无法证明安全时全量重建。
  6. UI 回调忽略纯 pos 偏移,避免无意义渲染。

10. 仓库已提交版本的演进方案

当前仓库的已提交版本通过 buildTOCIncremental 把更新策略调整为:

  1. 先把旧 TOC 的位置一次性映射到新文档。
  2. 通过统一的 getChangedRanges(tr) 收集变化范围。
  3. 把变化范围归一到受影响的顶层 block offset。
  4. 非标题区域变化时直接复用映射后的 TOC。
  5. 只重新构建受影响标题,未变化项复用原对象。
  6. 有序列表编号可能变化时,主动重建相关列表标题。
  7. 大文档中受影响 block 比例超过阈值时回退全量构建。
  8. view.update 中基于最终状态统一计算增删改回调。
  9. UI 视觉差分忽略纯 pos 变化,减少 React 无效更新。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
flowchart TD
tx(["Transaction"])
mapOnce["旧 TOC 位置映射一次"]
changed{"docChanged?"}
ranges["计算 changed ranges"]
affected["归一为受影响顶层 block"]
safe{"可安全增量且影响比例可控?"}
fast{"涉及标题或旧 TOC?"}
reuse["复用 mapped TOC"]
rebuildAffected["重建受影响标题"]
fullRebuild["全量构建 TOC"]
finalState["得到最终 TOC state"]
visualDelta["view.update 计算视觉差分"]
emit["批量触发回调和 onList"]

tx --> mapOnce --> changed
changed -->|"否"| finalState
changed -->|"是"| ranges --> affected --> safe
safe -->|"否"| fullRebuild --> finalState
safe -->|"是"| fast
fast -->|"否"| reuse --> finalState
fast -->|"是"| rebuildAffected --> finalState
finalState --> visualDelta
visualDelta -->|"无视觉变化"| tx
visualDelta -->|"有增删改"| emit --> tx

这个演进体现了一条很重要的工程原则:增量算法不是永远拒绝全量,而是在安全、收益明确时增量,在不确定或收益不足时回退。

11. 测试与验证策略

11.1 必测场景

初始化

  • 空文档。
  • 多级标题。
  • 空标题。
  • 缺失 line-id
  • subtitle 和非法 level。

新增

  • 文档开头、中间、末尾新增。
  • 一步创建带 line-id 的标题。
  • 先 Replace、后 Attr 写入 line-id
  • 批量把多个段落或列表转换为标题。
  • 在已有标题前新增,验证目录顺序。

更新

  • 只改文本。
  • 只改 level。
  • 同一事务同时改文本和 level。
  • 标题载体在 heading、ordered-list、bullet-list、task 之间变化。
  • 有序列表前插入列表项后,leadingSymbol 变化。

删除与结构变化

  • 删除单个标题。
  • 批量删除。
  • 标题降级为普通段落。
  • 标题拆分、合并。
  • ReplaceAroundStep 产生的包裹或提升。
  • undo/redo。

性能与引用复用

  • 普通段落输入不重建 TOC。
  • 标题前方输入只更新受影响的 pos
  • 未变化 TOC 项保持对象引用。
  • 纯位置偏移不触发 UI onUpdate
  • 大变化正确进入全量兜底。

11.2 本次本地验证结果

执行命令:

1
yarn workspace @shimo/hawk-tests test:unit packages/plugins/src/toc/plugin-toc.test.ts --runInBand

结果:

  • 37 个测试中 13 个通过、24 个失败。
  • 主要失败原因包括当前文件缺失 nodeTypeleadingSymbol,level 更新未生效、批量转换后目录项丢失,以及非文档变化未保持引用复用。

这个结果符合前文所述:当前工作区文件是一版与仓库最新接口、测试基线不完全同步的 Step 驱动实现,适合用于分析增量更新难点,但不能直接视为当前生产基线已经验证通过。

12. 面试官可能会问的问题

Q1:这个插件的核心职责是什么?

它把 ProseMirror 文档中的标题派生成目录 state,并在事务后维护标题的身份、文本、层级、顺序和跳转位置,同时把最终变化同步给外部目录 UI。

Q2:为什么不在每次 transaction 后直接遍历全文?

全量遍历最简单、正确性也容易保证,但它会进入每次输入的热路径。大文档下还可能触发大量文本提取和 UI 更新。更好的方案是局部重建、引用复用,并在无法安全增量时回退全量。

Q3:为什么 pos 不能作为标题唯一标识?

pos 是随文档编辑变化的绝对坐标。在标题前插入内容,标题身份没变但 pos 会改变。稳定身份应该由 line-id 表示,pos 只负责定位。

Q4:tr.mapping.map(pos, 1) 中的 1 是什么?

它是 assoc,表示位置落在插入边界时偏向右侧。-1 则偏向左侧。边界选择会影响位置最终关联到插入内容前还是后,需要根据节点起点、删除和新增语义决定。

Q5:为什么多 Step 事务特别容易出错?

因为每个 Step 的位置属于它执行时的文档版本,而 tr.doc 是全部 Step 执行后的最终文档。若混用坐标系,就会 resolve 到错误节点或重复映射位置。

Q6:为什么新增标题有时要等 AttrStep(line-id)

一些命令先插入节点,再写入业务 ID。没有 line-id 时无法可靠追踪身份,所以先跳过,等 ID 写入后再创建目录项。

Q7:pendingLevelById 解决了什么问题?

它暂存同一事务中已经捕获的 level 变化。后续文本 ReplaceStep 更新同一标题时,优先使用 pending level,避免用旧属性覆盖新 level。

Q8:如何判断标题是新增、更新还是替换?

优先比较稳定 ID:新 ID 不在旧集合中是新增;同 ID 的展示字段变化是更新;旧 ID 不在新集合中是删除。如果同一位置 ID 改变,应视为删除旧项并新增新项,而不是普通更新。

Q9:为什么删除不能只检查映射后的单个位置?

删除后的边界可能映射到相邻节点,尤其是拆分、合并和批量替换。映射位置只是候选,还应结合节点类型、line-id 和局部范围确认。

Q10:ReplaceStepReplaceAroundStep 有什么区别?

ReplaceStep 直接替换一个文档区间;ReplaceAroundStep 在替换时保留 gap 中的内容并重新包裹,常见于列表包裹、提升和结构转换。目录插件必须考虑结构变化不只来自普通文本替换。

Q11:为什么直接依赖具体 Step 类型不够稳健?

本地命令、协同层和自定义插件可能产生不同 Step 类型或组合。只识别少数 instanceof 分支,未知 Step 会让 TOC 静默失效。最终文档差分和全量兜底更抗演进。

Q12:为什么 plugin state 的 apply 最好保持纯函数?

apply 更容易回放、测试和推理。外部 UI 回调属于副作用,放在 view.update 中基于最终 state 批量发布,可以避免事务中间态和重复回调。

Q13:如何避免同一事务多次触发 onUpdate

不要逐 Step 直接发布。先得到最终 TOC state,再按 line-id 对事务前后的 TOC 快照做一次 diff,最后统一触发回调。

Q14:为什么纯 pos 变化不一定应该触发 UI 更新?

目录展示通常只依赖 text、level、nodeType 和 leadingSymbol。前方普通文本输入会让所有后续标题 pos 偏移,但目录视觉内容没变。如果逐项触发 React 更新,会造成严重无效渲染。

Q15:如何保证目录顺序?

最稳妥的是按最终文档顺序构建结果;局部插入时也必须依据新文档 pos 找正确插入点,不能依赖回调到达顺序或简单 push。

Q16:增量方案什么时候应该回退全量?

变化范围无法可靠计算、出现未知 Step、映射结果异常、稳定 ID 缺失,或受影响 block 比例过大时。回退不是失败,而是正确性优先的安全策略。

Q17:如何评估这类插件的性能?

同时观察事务耗时、文档扫描节点数、标题文本提取次数、对象复用率、回调数量和 React commit 次数。只测 apply 的 CPU 时间可能漏掉回调导致的 UI 阻塞。

Q18:为什么使用 getSelectionText 而不是 node.textContent

它能复用编辑器已有的文本序列化语义,处理自定义 inline 节点和序号展示。但列表型标题要明确前导符是混入 text,还是单独放到 leadingSymbol,避免重复展示。

Q19:如果 line-id 缺失怎么办?

短期可以让该项参与完整列表但不参与基于 ID 的增量回调;更严格的方案是延迟加入、生成稳定 ID,或直接进入全量重建。不能用 pos 冒充长期 ID。

Q20:如何处理 ordered-list 标题的编号变化?

编号可能受前面列表项影响,即使该标题自身内容没有变化也要重算 leadingSymbol。因此需要额外的列表变化失效信号,不能只看标题所在 block 是否被直接编辑。

Q21:这个实现中你会优先修哪三个问题?

第一,统一 TOCItem 数据结构;第二,旧位置只映射一次并严格区分 Step 局部坐标和最终坐标;第三,把回调从 apply 移到最终 state 差分发布层,并增加未知变化的全量兜底。

Q22:增量更新与全量 diff,应该怎么选?

小文档或低频更新优先选全量,代码更简单可靠;大文档高频输入时采用混合方案:位置映射和非标题 fast path 做增量,复杂或大范围变化回退全量。

Q23:如何测试坐标映射是否正确?

不要只测最终数组长度。应覆盖文档开头插入、标题前插入、批量 Step、拆分、合并、undo/redo,并断言每个 pos 都能在最终 doc 中定位到对应 line-id

Q24:这个项目最能体现你能力的点是什么?

不是写了几个 Step 分支,而是把编辑器底层事务、稳定身份、位置映射、最终一致性和 UI 性能串成一套可验证的增量派生方案,并知道何时回退以保护正确性。

13. 面试表达模板

13.1 60 秒版本

我负责过 ProseMirror 编辑器的目录同步插件。它初始化时从文档标题生成 TOC state,编辑过程中根据 transaction 维护标题的新增、删除、文本、层级和位置。这个问题最难的地方是 ProseMirror 的位置不是稳定身份,而且一次用户操作可能产生多个 Step,每个 Step 还属于不同文档坐标系。

我的处理思路是用 paragraph 上的 line-id 识别同一个标题,用 mapping 维护 pos,把属性变化、内容替换和结构变化统一到最终 TOC 状态。对于 line-id 延迟写入、同一事务同时修改 level 和文本、批量转换后顺序保持等问题,都有专门的合并逻辑。后续性能优化又把方案演进为受影响 block 增量重建、未变化项引用复用、复杂变化全量兜底,并把 UI 回调移到最终 state 的统一 diff 阶段,避免每次输入产生大量无效 React 更新。

13.2 STAR 版本

Situation: 大文档中用户每次输入都会触发目录插件更新,旧方案既容易受协同 Step 变化影响,也可能造成全量扫描和大量 UI 回调。

Task: 在不牺牲目录正确性的前提下,支持标题增删改、拆分合并、列表型标题、undo/redo 和协同变更,并降低输入热路径开销。

Action:line-idpos 分别定义为身份和位置;用 mapping 迁移旧位置;按变化范围定位受影响顶层 block;只重建受影响标题,复用其他 TOC 项;对 ordered-list 编号做额外失效处理;对大范围或异常变化全量兜底;最终在 view.update 中统一 diff 和发布回调。

Result: TOC 状态与最终文档保持一致,普通段落输入不再重建标题数据,纯位置偏移不再触发目录 UI 的无效更新,同时保留复杂场景的正确性兜底。

14. 总结

plugin-toc 是一个典型的“看起来简单、实际非常考验状态建模”的编辑器插件。它最有价值的经验可以归纳为四点:

  1. 用稳定 ID 表示身份,用 mapping 维护位置。
  2. 始终标明位置属于哪一版文档坐标系。
  3. 以事务最终状态做语义 diff,不向 UI 泄漏 Step 中间态。
  4. 增量只用于能够证明安全且有收益的路径,其余情况主动全量兜底。

如果面试官继续深入,讨论重点应从“我处理了哪些 Step”上升到“如何定义状态不变量、如何证明增量结果正确、如何衡量整体性能,以及如何让方案适应协同和未来 Step 演进”。


文章作者: 赤蓝紫
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 赤蓝紫 !
评论
  目录