ProseMirror实现目录增量更新
ai生成
1. 文档范围
本文分析对象是当前工作区中的:
packages/plugins/src/toc/plugin-toc.tspackages/plugins/src/toc/defs.tspackages/plugins/src/toc/build-toc-incremental.tspackages/plugins/src/toc/plugin-toc.test.tspackages/plugins/src/toc/build-toc-incremental.test.ts
当前工作区的 plugin-toc.ts 是一版以 AttrStep、ReplaceStep、ReplaceAroundStep 为核心的 Step 驱动增量实现;仓库已提交版本则已经演进为“变化范围 + 最终文档派生 + 全量兜底”的方案。本文先忠实解释当前文件,再结合已提交版本说明它的演进方向。
注意:分析时
plugin-toc.ts存在未提交修改。本文不会假设这版代码已经通过当前仓库的全部测试,而会明确区分“设计意图”“实现亮点”和“已验证风险”。
2. 一句话定位
plugin-toc.ts 是一个 ProseMirror 状态插件:它把编辑器文档中的标题节点派生成 TOCPluginState,并在标题新增、修改、删除或位置变化时,向外部目录 UI 提供最新状态和增量回调。
真正困难的部分不是“找到 heading”,而是同时处理以下四件事:
- 标题身份在多次编辑中保持稳定。
- 标题位置在新旧文档坐标系之间正确迁移。
- 一个用户操作产生多个 Step 时,只输出最终一致状态。
- 在大文档高频输入下,避免每次都做昂贵的全量计算和 UI 更新。
3. 核心数据模型与不变量
当前 TOCItem 的完整定义是:
1 | |
各字段职责不同:
| 字段 | 职责 | 是否稳定 | 典型用途 |
|---|---|---|---|
id |
标题的逻辑身份,来自子 paragraph 的 line-id |
相对稳定 | 增删改 diff、回调去重 |
pos |
标题在当前文档中的绝对位置 | 不稳定 | 排序、点击目录后跳转 |
level |
标题层级 | 可变 | 目录缩进、层级展示 |
text |
标题展示文本 | 可变 | 目录内容 |
nodeType |
标题载体类型 | 可变 | 区分 heading、列表、任务项 |
leadingSymbol |
列表型标题的前导符 | 可变且可能受邻居影响 | 展示 1.、项目符号、任务符号 |
理想情况下,插件应始终满足以下不变量:
- TOC 项顺序与文档中的标题顺序一致。
- 每个可识别标题最多对应一个 TOC 项。
item.pos必须属于当前EditorState.doc的坐标系。- 同一
line-id在一次事务结束后最多产生一次最终语义回调。 - TOC state 应由最终文档状态决定,不应长期保留事务中间态。
其中最关键的一条是:**id 表示身份,pos 表示位置,二者不能混用。**
4. 插件生命周期总流程
下面这张图只负责展示插件初始化和 state.apply 的主控制流。真正的增删改判定分别在第 5 节的三张子流程图中展开,避免把 40 多个节点挤进一张无法阅读的图。
1 | |
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-id、level等属性变化。ReplaceStep:处理文本插入、删除、替换、节点新增和拆分。ReplaceAroundStep:处理包裹、提升、列表转换等结构变化。- 每处理完一个 Step,重新映射全部 TOC 项的位置。
5. 当前 Step 驱动增量流程
5.1 AttrStep:补充新增 ID 与更新 level
1 | |
AttrStep 有两个实际用途:
line-id分支补偿“节点已经插入,但稳定 ID 稍后才写入”的创建流程。level分支把标题层级变化暂存在pendingLevelById,并更新已有 TOC 项。
当前代码还把 step.attr === 'pos' 放入 level 更新分支,这是第 8 节指出的潜在错误:它会把 step.value 写入 TOCItem.level。
5.2 ReplaceStep:判定新增与更新
1 | |
新增标题
新增标题有两条处理路径。
第一条是 ReplaceStep 路径:
- 扫描新文档受影响区间。
- 找到 heading-like 节点。
- 将新位置通过
tr.mapping.invert()映射回旧文档。 - 如果旧位置没有 heading,则判定为新增。
- 如果已经能读取
line-id,立即按pos插入 TOC。
第二条是 AttrStep(line-id) 路径:
- 某些创建流程先插入节点结构,再单独写入
line-id。 ReplaceStep阶段因为没有稳定 ID,暂不新增。- 后续
AttrStep写入line-id时,再构造并插入 TOC 项。
这个延迟处理体现了一个重要原则:宁可稍后建立目录项,也不要建立一个没有稳定身份、无法可靠 diff 的目录项。
更新标题
更新来源主要有两类:
AttrStep(level):标题层级改变。ReplaceStep:标题文本或节点内容改变。
当一个事务同时修改 level 和文本时,pendingLevelById 会记录已经看到的 level:
1 | |
后续处理文本变化时优先读取 pending level,避免用最终文档节点中的旧值覆盖前面已经捕获的新值。
这本质上是在解决“一个用户意图由多个底层 Step 表达”的问题。
5.3 旧文档扫描:判定删除并更新位置
1 | |
删除逻辑从旧文档反向确认:
- 扫描
oldState.doc的受影响区间。 - 找到旧标题。
- 用
tr.mapping.map(pos, -1)映射到新文档。 - 如果新位置没有节点、已不是标题,或稳定 ID 已改变,则认为旧标题被删除。
- 按
line-id从 TOC 中过滤,并触发onRemove。
删除比新增更容易误判,因为删除后的边界位置可能映射到相邻节点。单点映射只能提供候选位置,不能天然证明节点身份仍然存在。
6. 增量更新最难的地方
6.1 同一事务中存在多套坐标系
这是整个实现最核心的难点。
一个包含多个 Step 的事务至少涉及:
oldState.doc坐标系。- 第一个 Step 执行前后的坐标系。
- 第两个 Step 执行前后的坐标系。
tr.doc/newState.doc最终坐标系。
step.from、step.to、step.pos 通常属于该 Step 执行时的局部坐标系;tr.mapping 则描述整个事务从初始文档到最终文档的映射。如果直接拿某个 Step 的位置访问最终文档,就可能命中错误节点。
1 | |
面试时可以强调: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 可能观察到:
- 先新增一个旧 level 的标题。
- 再收到 level 更新。
- 再收到文本更新。
而用户只做了一次操作。理想输出应基于事务最终状态合并为一次语义变化。
6.5 删除、拆分和替换的分类存在歧义
标题拆分可能同时表现为:
- 原标题文本缩短。
- 新标题出现。
- 后续标题位置整体移动。
标题替换则可能表现为旧 ID 消失、新 ID 在同一位置出现。仅根据位置,很容易把新增误判为更新,或把替换误判为位置移动。
6.6 目录顺序必须与文档顺序保持一致
批量把文档前半部分的段落转换为标题时,新 TOC 项不能简单追加到数组末尾。insertTOCItem 按 pos 找插入点,保证目录顺序与文档顺序一致。
这个问题在单标题用例里不明显,批量转换和中间插入时才会暴露。
6.7 性能优化不能只看扫描范围
“只扫描局部区间”不代表整体一定快。还要计算:
- 每个 Step 是否都遍历全部 TOC 项。
- 每次
mapping.map内部要经过多少个 StepMap。 findIndex、filter是否被重复执行。- 回调是否引发 React 同步渲染。
- 是否因为对象全部重建而破坏引用复用。
目录插件运行在每次输入的热路径上,算法常数和 UI 副作用都很重要。
6.8 协同编辑会放大对 Step 类型的依赖
本地命令、历史撤销、协同数据和自定义变换可能产生不同 Step 组合。对 instanceof AttrStep/ReplaceStep/ReplaceAroundStep 的强依赖意味着:
- 新 Step 类型可能完全绕过 TOC 更新。
- 跨包或不同运行时中的类身份判断需要额外注意。
- 同一用户语义可能有多种底层表示。
因此更稳健的设计通常是:优先从 oldState.doc、newState.doc、tr.mapping 和统一变化范围工具推导结果,并为未知情况保留全量重建兜底。
7. 当前实现值得肯定的亮点
7.1 将稳定身份与文档位置分离
用 line-id 做身份、用 pos 做定位,是整个方案能够成立的基础。这个设计可推广到评论锚点、协同光标、批注、侧边栏索引等场景。
7.2 覆盖属性、内容和结构三类变化
代码没有只处理文本替换,而是同时识别:
AttrStepReplaceStepReplaceAroundStep
说明实现考虑了普通输入、节点属性修改、列表转换、结构包裹等不同编辑路径。
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 项只有 id、level、text、pos,但 defs.ts 还要求:
nodeTypeleadingSymbol
这会导致列表型标题能力缺失,也说明工作区文件与当前接口、测试基线并不同步。
8.2 Step 局部坐标被直接用于最终文档
例如代码使用:
1 | |
以及:
1 | |
对于多 Step 事务,step.pos/from/to 不一定属于最终 tr.doc 坐标系。更稳妥的做法是使用该 Step 的 StepMap,或把局部范围通过后续 mapping 映射到最终文档。
8.3 全事务 mapping 在 Step 循环中被重复应用
当前代码在每个 Step 结束后执行:
1 | |
这里有两个风险:
- 旧 TOC 位置可能被整个事务的 mapping 重复映射多次。
- 从
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 | |
如果确实存在 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:变化区间内扫描到的节点数。M:tr.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、大量标题的事务里,位置映射和数组查找仍可能成为热点。
更合理的优化方向包括:
- 旧 TOC 位置只映射一次。
- 用
Map<id, item>降低身份查找成本。 - 未变化项保持对象引用。
- 只对受影响标题重新提取文本。
- 变化范围过大或无法证明安全时全量重建。
- UI 回调忽略纯
pos偏移,避免无意义渲染。
10. 仓库已提交版本的演进方案
当前仓库的已提交版本通过 buildTOCIncremental 把更新策略调整为:
- 先把旧 TOC 的位置一次性映射到新文档。
- 通过统一的
getChangedRanges(tr)收集变化范围。 - 把变化范围归一到受影响的顶层 block offset。
- 非标题区域变化时直接复用映射后的 TOC。
- 只重新构建受影响标题,未变化项复用原对象。
- 有序列表编号可能变化时,主动重建相关列表标题。
- 大文档中受影响 block 比例超过阈值时回退全量构建。
- 在
view.update中基于最终状态统一计算增删改回调。 - UI 视觉差分忽略纯
pos变化,减少 React 无效更新。
1 | |
这个演进体现了一条很重要的工程原则:增量算法不是永远拒绝全量,而是在安全、收益明确时增量,在不确定或收益不足时回退。
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 | |
结果:
- 37 个测试中 13 个通过、24 个失败。
- 主要失败原因包括当前文件缺失
nodeType、leadingSymbol,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:ReplaceStep 与 ReplaceAroundStep 有什么区别?
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-id 与 pos 分别定义为身份和位置;用 mapping 迁移旧位置;按变化范围定位受影响顶层 block;只重建受影响标题,复用其他 TOC 项;对 ordered-list 编号做额外失效处理;对大范围或异常变化全量兜底;最终在 view.update 中统一 diff 和发布回调。
Result: TOC 状态与最终文档保持一致,普通段落输入不再重建标题数据,纯位置偏移不再触发目录 UI 的无效更新,同时保留复杂场景的正确性兜底。
14. 总结
plugin-toc 是一个典型的“看起来简单、实际非常考验状态建模”的编辑器插件。它最有价值的经验可以归纳为四点:
- 用稳定 ID 表示身份,用 mapping 维护位置。
- 始终标明位置属于哪一版文档坐标系。
- 以事务最终状态做语义 diff,不向 UI 泄漏 Step 中间态。
- 增量只用于能够证明安全且有收益的路径,其余情况主动全量兜底。
如果面试官继续深入,讨论重点应从“我处理了哪些 Step”上升到“如何定义状态不变量、如何证明增量结果正确、如何衡量整体性能,以及如何让方案适应协同和未来 Step 演进”。