TOC 插件增量更新


TOC 插件增量更新

ai生成

1. 项目概览

  • 项目定位:Hawk 是基于 ProseMirror 的新一代文档编辑器,TOC 插件负责从编辑器文档中提取标题、维护目录状态,并通过回调同步给外部目录 UI。
  • 技术栈:TypeScript、Yarn workspace、ProseMirror state/model/transform/view、Jest、Storybook、Richdoc delta adaptor、Hawk 自研 core/plugins/shared 包。
  • 核心价值:让目录 UI 能在用户编辑、命令转换、协同 delta、撤销重做和列表编号变化时保持正确,同时避免大文档中每次输入都全量扫描和触发无效 UI 更新。
  • 我的职责:可按实际经历补充;从代码看,职责可描述为负责 TOC 插件的状态建模、增量更新、协同兼容、列表类标题兼容、性能优化和回归测试建设。
  • 主要入口:
    • packages/core/src/editor/plugins.ts 中通过 createTOCPlugin({ ...safePluginOptions, ...tocOptions }) 注册插件。
    • packages/plugins/src/toc/plugin-toc.ts 暴露 createTOCPlugin,负责 ProseMirror Plugin state、appendTransaction 和 view.update 回调。
    • packages/plugins/src/toc/build-toc-incremental.ts 负责按事务影响范围局部重建 TOC。
    • packages/plugins/src/toc/defs.ts 定义 TOCItemTOCPluginStatetocPluginKey
    • packages/plugins/src/toc/plugin-toc.test.tsbuild-toc-incremental.test.ts 覆盖主要行为。

2. 核心流程

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
flowchart TD
A["入口:Editor 初始化注册 createTOCPlugin"] --> B["state.init:读取 new EditorState.doc"]
B --> C["遍历顶层文档块 buildTOCFromDoc"]
C --> D{"节点是否为有效 heading-like?"}
D -- 否 --> E["跳过普通段落、subtitle 或无效节点"]
D -- 是 --> F["构建 TOCItem:id、level、text、pos、nodeType、leadingSymbol"]
F --> G["触发 onList 并保存初始 TOC state"]
G --> H["用户编辑、命令转换、协同 delta 或 undo/redo 产生 transaction"]
H --> I{"tr.docChanged 是否为 true?"}
I -- 否 --> J["仅复用或映射现有 TOC state"]
I -- 是 --> K["使用 tr.mapping 映射旧 TOC pos 到新文档坐标"]
K --> L["getChangedRanges 计算变更范围"]
L --> M["getAffectedTopLevelOffsets 收集受影响顶层 block"]
M --> N{"影响范围是否过大或无法可靠增量?"}
N -- 是 --> O["兜底全量重建 TOC"]
N -- 否 --> P{"是否影响 heading 或已有 TOC 条目?"}
P -- 否 --> Q["快路径:返回映射后的旧 TOC,避免扫描全量标题"]
P -- 是 --> R["仅重建受影响 heading-like 节点,复用未受影响 TOCItem"]
R --> S{"是否涉及 ordered-list 编号失效?"}
S -- 是 --> T["重算 leadingSymbol,保证列表标题编号正确"]
S -- 否 --> U["保留可复用条目的 leadingSymbol"]
O --> V["得到下一版 TOC state"]
Q --> V
T --> V
U --> V
V --> W["view.update 比较视觉差异:忽略纯 pos 偏移"]
W --> X{"是否存在 add/update/remove 视觉变化?"}
X -- 否 --> Y["不触发外部回调,避免 React 无效渲染"]
X -- 是 --> Z["触发 onAdd/onUpdate/onRemove/onList,同一事务按 id 去重"]
Z --> AA["外部目录 UI 渲染并可通过 pos 定位正文标题"]

流程说明

  1. 入口阶段:编辑器在 packages/core/src/editor/plugins.ts 的 P1 插件组中注册 TOC 插件,外部通过 tocOptions 注入 onListonAddonUpdateonRemove 回调。
  2. 数据准备阶段:TOC 的输入是 ProseMirror EditorState.doc 和每次编辑产生的 Transaction;每个目录项以 line-id 作为稳定标识,保留标题级别、展示文本、文档位置、节点类型和列表前导符。
  3. 核心处理阶段:初始化时全量构建;更新时先用 tr.mapping 迁移旧位置,再通过 getChangedRangesgetAffectedTopLevelOffsets 限定受影响顶层块,优先局部重建并复用未受影响条目。
  4. 分支处理阶段:普通段落编辑走快路径;受影响范围过大或无法可靠定位时全量重建;有序列表变化通过 changesInvolveOrderedList 判断是否需要重算编号;subtitle 不进入目录;缺失 line-id 的条目不适合参与稳定差分。
  5. 输出阶段:插件 state 通过 tocPluginKey 存储,外部 UI 通过回调获得目录列表。view.update 只对影响展示的字段触发增删改回调,纯 pos 偏移不会造成无效 onUpdate

3. 技术难点

难点一:从 Step 驱动迁移到文档差异驱动

  • 背景:旧实现容易依赖 AttrStepReplaceStepReplaceAroundStep 等具体 Step 类型来判断新增、修改和删除。
  • 挑战:协同层或命令层一旦引入新的 Step 组合,TOC 需要继续补兼容,容易出现漏增、误删、顺序错误或同一事务多次回调。
  • 方案:基于 oldState.docnewState.doctr.mappinggetChangedRanges 建模,把“最终文档是什么”作为事实来源;buildTOCIncremental 只关心受影响顶层 block,不强依赖具体 Step 子类。
  • 风险控制:无法可靠增量或影响范围过大时返回 null,由调用方兜底全量重建;同时用 line-id 作为稳定 id 做差分。
  • 可验证结果:plugin-toc.test.ts 覆盖新增、删除、文本更新、level 更新、命令转换、协同 richdoc delta、undo/redo;build-toc-incremental.test.ts 覆盖非 heading 快路径和局部重建。

难点二:列表块也可能成为目录标题

  • 背景:createToHeading2CommandcreateToHeading3Command 对 ordered-list、bullet-list、task 等列表块执行标题转换时,可能保持原列表结构,只写入 level
  • 挑战:目录不能只识别 node.type.name === 'heading',否则列表标题会丢失;同时 ordered-list 的展示编号来自 list 插件 decoration,不是节点文本的一部分。
  • 方案:通过有效 level 判断 heading-like 节点,将 headingordered-listbullet-listtask 纳入 TOC;TOCItem.nodeType 保留来源节点类型,leadingSymbol 记录 1.、bullet 符号或 task 符号。
  • 风险控制:ordered-list 的 leadingSymbollistPluginKey 的 decorations 读取,并用 changesInvolveOrderedList 判断编号是否可能失效;bullet/task 使用确定性映射。
  • 可验证结果:plugin-toc.test.tsshould_transform_ordered_list_to_heading_using_commandshould_transform_bullet_list_to_heading_using_commandshould_transform_task_to_heading_using_command 和批量转换用例验证了列表类标题。

难点三:避免纯位置变化导致目录 UI 抖动

  • 背景:在标题前插入普通正文会让所有后续 heading 的 pos 后移,但目录展示的 text、level、nodeType、leadingSymbol 没有变化。
  • 挑战:如果把 pos 变化也作为 onUpdate 依据,大文档中一次输入可能触发大量 React setState,造成明显卡顿。
  • 方案:插件 state 内仍更新 pos,保证滚动定位准确;但 view.update 的视觉差异比较忽略纯 pos 变化,只在展示字段变化时触发回调。
  • 风险控制:把“存储状态”和“UI 展示通知”分层,避免为了性能牺牲定位能力。
  • 可验证结果:plugin-toc.test.ts 的 “仅 pos 偏移不应触发 onUpdate 回调” 用例验证了该策略。

难点四:协同 delta 下的空标题继承

  • 背景:richdoc delta 中在标题开头回车,会产生新增空标题;部分流程中新增标题可能临时表现为默认 h1 或 level 写入时序不一致。
  • 挑战:TOC 只修自己的 state 会导致目录和真实 doc 不一致,后续事务或协同同步可能再次把问题放大。
  • 方案:在 appendTransaction 中基于批量 mapping 将新标题位置映射回旧文档,定位邻近旧标题,并把新增空标题的 level 写回文档层。
  • 风险控制:只处理“新增、空文本、默认 h1、且能通过 line-id 稳定识别”的窄场景;修正事务设置 addToHistory=false,避免污染撤销栈。
  • 可验证结果:plugin-toc.test.ts 中 richdoc delta 回车拆分相关用例验证了新增空标题、顺序递增、level 继承和后续补文本更新。

4. 项目亮点

  • 亮点一:TOC 是 doc 的派生状态,而不是另一份业务真相。
    说明:核心逻辑从最终 newState.doc 构建或局部重建 TOC,降低了与事务 Step 类型的耦合,体现了对 ProseMirror 数据模型的理解。
  • 亮点二:增量更新和全量兜底结合。
    说明:buildTOCIncremental 用受影响顶层块缩小扫描范围,又在大范围变更时回退全量重建,兼顾性能、正确性和实现复杂度。
  • 亮点三:对目录 UI 做视觉差异去噪。
    说明:纯 pos 偏移不触发外部更新,减少大文档编辑时无意义渲染;但插件 state 仍保留最新 pos,滚动定位不受影响。
  • 亮点四:列表类标题与前导符建模完整。
    说明:nodeTypeleadingSymbol 让 TOC 能表示 heading、ordered-list、bullet-list、task 多种标题来源,覆盖真实编辑器命令行为。
  • 亮点五:测试不只测 happy path。
    说明:测试覆盖协同 richdoc delta、命令转换、缺失 id、subtitle、空标题、undo/redo、非 heading 快路径、有序列表编号变化等边界。

5. 技术取舍

方案 优点 缺点 最终选择 原因
直接解析 tr.steps 判断增删改 对简单本地事务直观,能精确知道某个 Step 做了什么 强依赖 Step 子类和组合顺序,协同或命令层变更时维护成本高 未作为核心策略 TOC 更适合作为最终文档的派生结果,依赖 Step 会放大兼容成本
每次事务全量扫描 doc 实现简单,正确性容易保证 大文档频繁输入时成本高,容易触发无效 UI 更新 作为兜底路径 大范围变更或无法可靠增量时使用,保证正确性底线
基于受影响顶层块增量重建 性能好,能复用未变化 TOCItem 需要处理 mapping、删除、列表编号和影响范围判断 核心选择 符合编辑器常见编辑粒度,能把每次输入成本限制在局部
onUpdate 比较包含 pos 外部永远能收到位置变化通知 标题前插入正文会导致大量虚假更新 未选择 位置变化不影响目录展示,应由插件 state 承载,不应驱动 UI 重渲染
onUpdate 忽略纯 pos,但 state 更新 pos 减少无效渲染,定位仍准确 外部若只监听回调且不读 state,拿不到纯位置变化通知 最终选择 TOC UI 展示与定位职责分离,性能收益更明确
只支持标准 heading 节点 规则简单 列表转换标题后目录缺项 未选择 编辑器命令实际会产生列表类 heading-like 节点,必须兼容
支持 heading-like 节点和 leadingSymbol 表达能力完整,目录展示更贴近正文 需要依赖 list 插件 decoration 和符号映射 最终选择 覆盖 ordered-list、bullet-list、task 的真实业务场景

6. 面试官可能追问

Q1: 这个 TOC 插件解决的核心问题是什么?

回答思路:先讲它服务的是富文本编辑器右侧目录或文档导航;核心输入是 ProseMirror doc 和 transaction;输出是按文档顺序排列的 TOCItem。再强调难点不是“遍历标题”本身,而是在编辑、命令转换、协同 delta、撤销重做和列表编号变化下保持目录正确且性能可控。

Q2: 为什么不直接每次全量遍历文档?

回答思路:全量遍历最简单,但大文档频繁输入时成本会被放大。当前设计用 getChangedRanges 收集变更范围,再映射到顶层 block,只重建受影响 heading;当影响范围过大或增量不可靠时才全量兜底。这是性能和正确性的平衡。

Q3: 你们如何判断一个节点应该进入目录?

回答思路:普通 heading 通过有效 level 判断,subtitle 排除。为了兼容编辑器命令,ordered-list、bullet-list、task 在带有有效 level 时也被视为 heading-like 节点。TOCItem 会记录 nodeTypeleadingSymbol,方便目录 UI 展示列表编号或符号。

Q4: 为什么 line-id 这么重要?

回答思路:ProseMirror 的位置 pos 会随着编辑不断变化,不能作为稳定身份;line-id 来自标题内部 paragraph,是跨事务识别同一个标题的稳定 id。增删改差分、回调去重、复用旧 TOCItem 都依赖这个 id。缺失 id 的节点可以出现在列表中,但不适合参与稳定差分。

Q5: 怎么避免只改普通段落时目录反复刷新?

回答思路:先通过 changed ranges 判断受影响顶层块,如果没有 heading,也没有触碰已有 TOCItem,就直接返回映射后的旧 state。即使标题位置因为前文插入而变化,view.update 也会忽略纯 pos 差异,不触发 onUpdateonList

Q6: ordered-list 的编号为什么需要特殊处理?

回答思路:有序列表编号不是简单存在节点文本里,而是由 list 插件根据 listId、indent、listStart 等计算,并通过 decoration 暴露。当前 TOC 从 decoration 读取 leadingSymbol,并在变更涉及 ordered-list 时重算受影响条目,否则可能出现正文编号已经变了但目录编号还是旧值。

Q7: 协同 delta 场景里最容易出什么问题?

回答思路:协同变更可能不是本地命令常见的 Step 组合,甚至 level 写入时序与 line-id 生成时序不同。应避免把 TOC 逻辑绑死到 Step 子类上,而是尽量从 old/new doc 和 mapping 推导最终结果。对空标题拆分这种窄场景,用 appendTransaction 写回 doc,保证 TOC 是文档的派生结果。

Q8: 这个模块还有哪些可改进空间?

回答思路:可以补充真实性能指标,例如大文档 1000 个标题、连续输入时的平均耗时和回调次数;可以进一步统一当前工作区 plugin-toc.tsTOCItem 类型字段;也可以把 heading-like 判断、leadingSymbol 计算抽成可单测的纯函数,减少插件文件复杂度。

7. 一分钟讲解版本

我负责的 TOC 插件是 Hawk 富文本编辑器里的目录能力,核心是把 ProseMirror 文档中的 heading 和带标题级别的列表块抽取成稳定的目录状态,并同步给外部 UI。这个模块的难点在于编辑器事务来源很多,包括本地输入、命令转换、协同 delta、撤销重做和列表编号变化,不能简单依赖某一种 Step 类型。我把 TOC 设计成文档的派生状态:初始化全量构建,更新时用 transaction mapping 和 changed ranges 定位受影响顶层块,优先局部重建,异常或大范围变更时全量兜底。同时区分插件 state 和 UI 回调,纯位置变化只更新 state,不触发无效渲染。测试覆盖了标题增删改、列表标题、协同回车拆分、undo/redo 和性能快路径,保证正确性和可维护性。


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