Transform 类:构建修改的 API 层

3 分钟阅读
·

这是 transform 阶段的收官篇。前面五篇看的都是零件:Step 抽象、ReplaceStep 和 Fitter、StepMap、Mapping、structure.ts 的结构判断函数。这些零件不直接暴露给命令层,中间隔着 Transform 类(src/transform.ts)。它做三件事:把 step 逐个应用到文档上,把每一步之前的文档记下来,维护整条位置映射链。对外再提供 replace、insert、addMark 这批便捷方法,让调用方不用手工构造 step。配套的内联格式范围处理在 src/mark.ts。这两个文件读完,transform 包的主线就齐了。参考代码是 prosemirror-transform 的 662b7a9。

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21 ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05 ResolvedPos:一个数字位置怎么变成路径
07-12 Slice 与 replace:切一块文档出来再塞回去
07-19 DOMSerializer:文档怎么变成 DOM 和 HTML
08-02 DOMParser:parseDOM 规则与 HTML 解析
08-09 findDiffStart / findDiffEnd:两份文档怎么求差
08-16 model 收官:Node 上的辅助方法与位置约定总结
09-06 ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20 ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03 StepMap:一步修改怎么映射每个位置
10-11 Mapping:多步映射的链式合并,rebase 的地基
10-18 structure.ts:split/join/lift/wrap 的可达性判断
10-25 Transform 类:构建修改的 API 层(本篇)

addStep:三个数组同步增长

Transform 的实例字段只有四个:

readonly steps: Step[] = []
readonly docs: Node[] = []
readonly mapping: Mapping = new Mapping
public doc: Node

doc 是当前文档,构造时传进来,之后每应用一步就换成新文档。其余三个数组记录过程。所有修改最终都汇入同一个方法 addStep:

addStep(step: Step, doc: Node) {
  this.docs.push(this.doc)
  this.steps.push(step)
  this.mapping.appendMap(step.getMap())
  this.doc = doc
}

四行,顺序固定:先把应用前的文档压进 docs,再记 step,再把这一步的位置映射追加进 mapping,最后换 doc。走完 n 步之后,steps 有 n 个元素,docs 也有 n 个,docs[i] 是 steps[i] 应用之前的文档,mapping 里串着 n 张 StepMap。

Transform 的 docs 与 mapping 双数组

docs 这个数组看着费内存,实际上每格只是一个引用。Node 不可变,新旧文档共享没动的子树,存 n 份文档的代价接近存 n 个指针。它的消费方主要是撤销:Step.invert 的不少实现要拿当时的文档查内容,比如 AddNodeMarkStep.invert 要读节点当前的 marks 才知道逆操作该加回哪个 mark,历史管理逐条 invert 时用的就是 docs[i]。配套的 getter before 返回整个修改开始之前的文档:有 step 时是 docs[0],没有 step 时就是 doc 自己。docChanged 更直接,steps.length > 0 就算改过。这两个判断都只数 step 不看内容:replace 在构造不出有意义的 step 时(空 slice 替换空区间)一步也不会加,docChanged 依然是 false。

用 Transform 写命令时有一个坐标约定要时刻记得:所有便捷方法的 from、to、pos 都按「调用那一刻的当前文档」的坐标解释,和修改开始前的文档无关。链式调用 tr.delete(a, b).insert(c, node) 时,insert 的 c 已经在删除之后的新文档坐标系里了。写复合操作时,要么从后往前安排操作让前面的改动不影响后面的坐标,要么干脆用 mapping 把旧坐标逐步映射过来。这个约定和 mapping 的存在互为表里:mapping 就是给「手上只有旧坐标」的调用方准备的换算工具。

另外注意 addStep 不做任何合并。Step 接口里有 merge 方法(第 13 篇),相邻的同类型 step 在概念上可以并成一个,但 Transform 只管如实记录,steps 数组里是什么粒度就是什么粒度。合并与否留给消费方自己决定,比如历史管理可以按自己的策略归并一组连续输入。Transform 这一层保持记录的原样,撤销和协作传输才能拿到未经加工的步骤序列。

changedRange 把「这次改了哪一段」汇总成一个区间,返回的是修改后的文档坐标。实现靠 mapping 里的 StepMap 逐个 forEach。StepMap 那篇说过,forEach 回调给出新旧两套坐标,changedRange 取所有区间新侧边界的最小 from 和最大 to;从第二张 map 开始,先把之前累计的 from/to 穿过这张 map 再和新区间的边界比较。纯 mark 修改的 getMap 是 StepMap.empty,forEach 一个区间也回调不出来,所以对只改格式的修改,changedRange 返回 null。方法的注释里专门写明了这条,调用方要拿它做「改动的区域重渲染」之类的优化时,得知道格式变化不在覆盖范围内。想拿这个区间回修改前的文档里取内容,也得先反向映射。

addStep 标了 @internal,包外代码的正常入口是下一节的 step 和 maybeStep,addStep 本身只做记录。

step 与 maybeStep:失败时的两种处理

Step.apply 可能失败,返回带 failed 消息的 StepResult(第 13 篇)。Transform 分两种处理:

  • step(step):失败就抛 TransformError。绝大多数便捷方法走这条,因为它们的参数在调用前已经过校验或计算,再失败说明有 bug,抛出来比静默吞掉好排查。
  • maybeStep(step):失败就忽略,把 result 返回给调用方自己判断。协作场景 rebase 别人的 step 时,映射过来的 step 可能已经不适用于当前文档,走这条。

TransformError 本身有个细节:文件里没有用 class 直接 extends Error,写法是先声明一个壳类,再整体替换成 function,手工接 prototype 链。这是给旧的转译目标准备的兼容写法,class 继承内建 Error 在某些编译输出下拿不到正确的实例类型。StepMap 那篇 recover 不用位运算也是同样的考虑,这个包对运行环境的假设一直很保守。

便捷方法的四组实现

Transform 上二十来个方法,按实现方式分四组。

第一组围绕 replace。replace(from, to, slice) 调 replaceStep(src/replace.ts)构造 step,构造出来就应用。replaceStep 可能返回 null:空 slice 替换空区间没有操作价值,Fitter 拟合失败时也返回 null,这时 replace 静默什么都不做,一个 step 也不会产生。构造之前还有一条 fitsTrivially 快速通道:slice 两侧都没打开、from 和 to 落在同一父节点内、父节点的 canReplace 直接通过时,跳过 Fitter 直接生成 ReplaceStep,常见的小范围编辑都走这条。delete(from, to) 是 replace(from, to, Slice.empty)。insert(pos, content) 和 replaceWith(from, to, content) 把内容包成 new Slice(Fragment.from(content), 0, 0) 再调 replace,openStart 和 openEnd 都是 0,能不能放下、要不要补闭合节点,全交给 replaceStep 里的 Fitter。这四个方法覆盖了「改内容」的全部基本形态,替换、删除、插入只是 slice 和区间的不同取值,底层只有一个入口。

第二组是 Range 系:replaceRange、replaceRangeWith、deleteRange。这组把 from/to 当提示而不是精确边界,允许向外扩张或闭合 slice 里打开的节点,换取更符合直觉的结果。粘贴走 replaceRange:从剪贴板解析出的 slice 通常带着 openStart,而 replace 不会移动 from/to 的边界,贴进来的内容只能在原位置闭合。replaceRange 参照 definingAsContext、definingForContent 这些 spec 标记,决定要不要把被完全覆盖的父节点整个换掉、要不要把 slice 里打开的父节点带进来。它的兜底循环逐个候选深度调 tr.replace,靠 tr.steps.length 有没有变长判断这次尝试成没成,因为 replace 失败是静默的。replaceRangeWith 多一步前置处理:插入的是块级节点、from 等于 to 且当前父节点非空时,先用 structure.ts 的 insertPoint 向下找一个能放下该节点的位置,再按 replaceRange 走。deleteRange 处理「删除范围两端正好都落在文本块开头」这类边界:路径上没有 isolating 节点挡路时把范围向外扩,避免删完留下两个空段落壳;然后按 coveredDepths 找能整层覆盖的深度,优先整层删。全选删除这类跨多个父节点的场景,简单 delete 可能构造出必填内容缺失的非法替换,deleteRange 的向外扩展就是用来避开这个的。

第三组结构系:lift、join、wrap、setBlockType、setNodeMarkup、split。全部转调 structure.ts 的同名函数,方法体只有一句调用加 return this。参数怎么算(liftTarget 找目标深度、findWrapping 选包裹链)上一篇已经拆过;这些函数内部再构造 ReplaceStep 或 ReplaceAroundStep 调 tr.step,setNodeMarkup 对非叶节点走整节点的 ReplaceAroundStep 替换,叶节点没有内容要保留,退化成一次 replaceWith(第 13 篇)。

这组方法体现了 transform 包的一个分工习惯:判断和修改分离。Transform.lift 的注释直接写明,target 应该用 liftTarget 先算出来;wrap 假设 wrappers 用 findWrapping 验证过。方法本身不替调用方做可达性检查,硬调进去的后果是 step 应用失败抛 TransformError。命令层的标准写法是先跑 canSplit、canJoin、liftTarget 这批纯函数,返回 null 就把命令置灰,通过了才构造 Transform。检查不产生 step,没有任何副作用,dry-run 一个命令(只判断可不可用,不真的执行)就是靠跑检查函数完成的。

第四组直接 new step。setNodeAttribute、setDocAttribute 构造 AttrStep、DocAttrStep;addNodeMark 构造 AddNodeMarkStep。这组同样不预检:AttrStep.apply 复制现有 attrs 再覆写一个键,只有 pos 上没有节点时才失败。键不在 spec 里声明也不报错,但 NodeType.create 重建 attrs 时只取声明过的键,未声明的键会被静默丢掉,等于这一步白应用。removeNodeMark 稍绕:传 Mark 实例时先查它在不在节点的 marks 里,不在就不产生 step;传 MarkType 时用循环把该类型的 mark 全部找出来(同类型不同 attrs 的 mark 可以并存),逐个生成 RemoveNodeMarkStep,再逆序应用。逆序配合历史管理的倒序撤销:先收集的后应用,撤销时后应用的先 invert,加回 marks 的顺序和原集合里的排列一致。marks 的顺序决定渲染时的嵌套次序,这个顺序不能乱。

四组方法都返回 this,可以链式调用。一次「把选区改成标题」在命令层是 setBlockType 一次调用,落到 Transform 上可能产生好几个 step:每个文本块一个替换 step,外加清理非法内容的后续 step。调用方不需要关心这个展开过程。

addMark / removeMark:按范围增删 mark

src/mark.ts 的三个函数负责按范围增删 mark,是 Transform 上 addMark、removeMark、clearIncompatible 的实现。

addMark(tr, from, to, mark) 用 nodesBetween 遍历范围内所有节点,只处理 inline 节点,满足两个条件才动手:这个 mark 还没在节点的 marks 里(isInSet 按 eq 判断,attrs 不同就算不在),且父节点允许这个 mark 类型。处理的区间取节点范围和 [from, to) 的交集。AddMarkStep.apply(src/mark_step.ts)里还有第二道校验:atom 节点要父节点允许该 mark 类型才会被加上,不允许就原样保留。注意 model 里 isAtom 的定义是叶子节点或 spec.atom 为真(第 4 篇),文本节点是叶子,也算 atom,所以这道检查覆盖文本节点。它的意义在于兜底:step 被映射到其他上下文之后,构造时合法的 mark 可能不再合法,apply 时再挡一次。

一个容易漏的分支是排除:mark.addToSet(marks) 会按 excludes 把被新 mark 排除的旧 mark 挤出集合,所以循环里还会检查每个旧 mark 在新集合里的去留,留不下的补一个 RemoveMarkStep。典型场景是给已有 link 的文本换 href:新旧两个 link 的 attrs 不同,isInSet 为 false,新 link 通过 excludes 把旧 link 挤掉,结果是一个 AddMarkStep 加一个 RemoveMarkStep,不会出现两个 link 叠在同一段文本上。

生成的 step 还做了相邻合并:上一个 AddMarkStep 的 to 正好等于本段的 start,就延长它而不再开新 step,RemoveMarkStep 同理(还要求 mark 相同)。给整段连续文本加粗,范围内每个文本节点都命中一次回调,最终只产生一个 AddMarkStep。应用顺序是先 removed 后 added,删干净再加。

removeMark(tr, from, to, mark) 的参数分三档:Mark 实例删这一个;MarkType 删该类型全部(同样要循环找,可能有多个不同 attrs 的实例并存);null 删所有 mark。合并逻辑比 addMark 显式:维护一个 matched 数组,每项记录样式、区间和 nodesBetween 的回调序号,样式相同且序号相邻(前一项的序号正好是当前减一)就延长旧区间,否则开新区间,最后每个区间一个 RemoveMarkStep。合并的意义在 step 数量:撤销栈逐条存 step,协作传输逐条发 step,一个连续区间一个 step 是最省的合法粒度。

clearIncompatible(tr, pos, parentType) 是 setBlockType、setNodeMarkup 改完节点类型之后的清理。新类型的内容表达式接不住的子节点,记一个 ReplaceStep 删掉;子节点上不被新父节点允许的 mark 立即删(RemoveMarkStep 的映射是 StepMap.empty,什么时候应用都不影响位置);父节点 whitespace 不是 pre 时,把文本里的换行符替换成空格;循环结束时 contentMatch 没走到 validEnd,用 fillBefore 补上必需的收尾节点。删除类的 replSteps 攒到最后逆序应用,原因和位置有关:ReplaceStep 会移动后续内容的位置,从后往前删,前面的删除不会让后面 step 的坐标失效。

mark.ts 里的原函数还有两个 Transform 方法签名上看不到的参数。第三个参数 match 可以传入已有的 ContentMatch 作为匹配起点,split 在分割点已经算过匹配进度,直接把它传进来,避免从头再走一遍内容表达式。第四个参数 clearNewlines 控制要不要做换行符替换,setBlockType 会按 schema 的 linebreakReplacement 配置决定:目标类型是 pre 又不支持换行节点时,先把换行节点转成 \n 字符再关掉 clearIncompatible 的替换;反过来目标类型不是 pre 但支持换行节点时,清理完再把 \n 字符换成换行节点。换行处理因此分成了两条路径,clearIncompatible 自己只负责简单的「换成空格」这一种。

小结

Transform 本身没有编辑器的概念:不持有选区,不通知任何人,也不管这串 step 来自一次按键还是一次粘贴。它只保证三件事:step 逐个应用到文档、每步之前的状态可查、每个位置都能沿 mapping 换算到修改后的坐标。便捷方法负责把「改格式」「删选区」这类意图翻译成 step 序列,mark.ts 负责范围场景下的合并与排除。命令、插件、协作、历史,后面所有包和文档修改打交道,都要经过这一层,没有第二条绕过 step 的路。到这一层,transform 包的零件全部装上了。下一阶段进 prosemirror-state:Transaction 在 Transform 之上再加选区、meta 和时间戳,EditorState 负责把应用完的 Transform 变成下一个编辑器状态。


1049 字 · 36 段落
xi ming

Written by xi mingFollow onGitHub