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定义TOCItem、TOCPluginState和tocPluginKey。packages/plugins/src/toc/plugin-toc.test.ts与build-toc-incremental.test.ts覆盖主要行为。
2. 核心流程
1 | |
流程说明
- 入口阶段:编辑器在
packages/core/src/editor/plugins.ts的 P1 插件组中注册 TOC 插件,外部通过tocOptions注入onList、onAdd、onUpdate、onRemove回调。 - 数据准备阶段:TOC 的输入是 ProseMirror
EditorState.doc和每次编辑产生的Transaction;每个目录项以line-id作为稳定标识,保留标题级别、展示文本、文档位置、节点类型和列表前导符。 - 核心处理阶段:初始化时全量构建;更新时先用
tr.mapping迁移旧位置,再通过getChangedRanges和getAffectedTopLevelOffsets限定受影响顶层块,优先局部重建并复用未受影响条目。 - 分支处理阶段:普通段落编辑走快路径;受影响范围过大或无法可靠定位时全量重建;有序列表变化通过
changesInvolveOrderedList判断是否需要重算编号;subtitle不进入目录;缺失line-id的条目不适合参与稳定差分。 - 输出阶段:插件 state 通过
tocPluginKey存储,外部 UI 通过回调获得目录列表。view.update只对影响展示的字段触发增删改回调,纯pos偏移不会造成无效onUpdate。
3. 技术难点
难点一:从 Step 驱动迁移到文档差异驱动
- 背景:旧实现容易依赖
AttrStep、ReplaceStep、ReplaceAroundStep等具体 Step 类型来判断新增、修改和删除。 - 挑战:协同层或命令层一旦引入新的 Step 组合,TOC 需要继续补兼容,容易出现漏增、误删、顺序错误或同一事务多次回调。
- 方案:基于
oldState.doc、newState.doc、tr.mapping和getChangedRanges建模,把“最终文档是什么”作为事实来源;buildTOCIncremental只关心受影响顶层 block,不强依赖具体 Step 子类。 - 风险控制:无法可靠增量或影响范围过大时返回
null,由调用方兜底全量重建;同时用line-id作为稳定 id 做差分。 - 可验证结果:
plugin-toc.test.ts覆盖新增、删除、文本更新、level 更新、命令转换、协同 richdoc delta、undo/redo;build-toc-incremental.test.ts覆盖非 heading 快路径和局部重建。
难点二:列表块也可能成为目录标题
- 背景:
createToHeading2Command、createToHeading3Command对 ordered-list、bullet-list、task 等列表块执行标题转换时,可能保持原列表结构,只写入level。 - 挑战:目录不能只识别
node.type.name === 'heading',否则列表标题会丢失;同时 ordered-list 的展示编号来自 list 插件 decoration,不是节点文本的一部分。 - 方案:通过有效 level 判断 heading-like 节点,将
heading、ordered-list、bullet-list、task纳入 TOC;TOCItem.nodeType保留来源节点类型,leadingSymbol记录1.、bullet 符号或 task 符号。 - 风险控制:ordered-list 的
leadingSymbol从listPluginKey的 decorations 读取,并用changesInvolveOrderedList判断编号是否可能失效;bullet/task 使用确定性映射。 - 可验证结果:
plugin-toc.test.ts中should_transform_ordered_list_to_heading_using_command、should_transform_bullet_list_to_heading_using_command、should_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,滚动定位不受影响。 - 亮点四:列表类标题与前导符建模完整。
说明:nodeType和leadingSymbol让 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 会记录 nodeType 和 leadingSymbol,方便目录 UI 展示列表编号或符号。
Q4: 为什么 line-id 这么重要?
回答思路:ProseMirror 的位置 pos 会随着编辑不断变化,不能作为稳定身份;line-id 来自标题内部 paragraph,是跨事务识别同一个标题的稳定 id。增删改差分、回调去重、复用旧 TOCItem 都依赖这个 id。缺失 id 的节点可以出现在列表中,但不适合参与稳定差分。
Q5: 怎么避免只改普通段落时目录反复刷新?
回答思路:先通过 changed ranges 判断受影响顶层块,如果没有 heading,也没有触碰已有 TOCItem,就直接返回映射后的旧 state。即使标题位置因为前文插入而变化,view.update 也会忽略纯 pos 差异,不触发 onUpdate 和 onList。
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.ts 与 TOCItem 类型字段;也可以把 heading-like 判断、leadingSymbol 计算抽成可单测的纯函数,减少插件文件复杂度。
7. 一分钟讲解版本
我负责的 TOC 插件是 Hawk 富文本编辑器里的目录能力,核心是把 ProseMirror 文档中的 heading 和带标题级别的列表块抽取成稳定的目录状态,并同步给外部 UI。这个模块的难点在于编辑器事务来源很多,包括本地输入、命令转换、协同 delta、撤销重做和列表编号变化,不能简单依赖某一种 Step 类型。我把 TOC 设计成文档的派生状态:初始化全量构建,更新时用 transaction mapping 和 changed ranges 定位受影响顶层块,优先局部重建,异常或大范围变更时全量兜底。同时区分插件 state 和 UI 回调,纯位置变化只更新 state,不触发无效渲染。测试覆盖了标题增删改、列表标题、协同回车拆分、undo/redo 和性能快路径,保证正确性和可维护性。