前十二篇已经分析整体结构与 prosemirror-model:文档树、位置编号、切片和解析。model 层的约束是文档不可变,Node 不提供原地修改方法。prosemirror-transform 将键入、粘贴、加粗等操作表示为一个或多个 Step,并依次应用到旧文档以生成新文档。本篇分析 Step 抽象,以及 AddMarkStep、RemoveMarkStep 与 AttrStep。参考代码是 prosemirror-transform 的 662b7a9,主要涉及 src/step.ts、src/mark_step.ts 和 src/attr_step.ts。
系列目录
| 日期 | 标题 |
|---|---|
| 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 的接口与实现(本篇) |
Step 的使用场景
单次输入可以直接调用 replace,但 Step 将每次修改表示为带位置和参数的独立数据对象。以下机制依赖这种表示:
- undo/redo:历史栈保存逆 Step,撤销时再应用逆 Step。文档快照无法映射协作中的远端修改,且占用的内存随整篇文档增长。
- 协作编辑:Step 可序列化为 JSON 发送到其他客户端,远端反序列化后应用到本地文档。
- 位置映射:选区、装饰和插件状态等位置相关状态,需要在文档变化后映射到新文档。每步的 getMap 提供该步的映射,多步映射可串联。
因此每个 Step 都需要支持应用、求逆、位置映射和序列化,Step 基类据此定义接口。直接比较两份文档的 diff 或直接操作 DOM 不包含这些可复用的修改信息,无法满足 undo/redo、协作编辑和位置映射的需求。
Step 基类的接口
src/step.ts 中的 Step 是抽象类,方法分为两类。
子类必须实现四个抽象方法:
- apply(doc):在应用前的文档上执行这步,返回 StepResult。文档不可变,所以是算出一份新文档,传入的 doc 原样保留。
- invert(doc):拿应用前的文档,返回逆 Step。为什么需要 doc 参数,AttrStep 一节会看到。
- map(mapping):把这步内部的位置映射过另一组修改(比如协作时远端先到的 Step),返回位置调整后的新 Step;这步的内容被整体删掉时返回 null。参数类型 Mappable 是 src/map.ts 里的接口,有 map 和 mapResult 两个方法,后者在映射结果之外多带 deleted、deletedBefore、deletedAfter 等删除信息;单步的 StepMap 和多步链的 Mapping 都实现这个接口,所以 step.map 两种映射都能吃。
- toJSON():序列化,结果必须带 stepType 字段标明类型。
两个方法提供默认实现:getMap 返回 StepMap.empty,表示这步不移动位置(src/map.ts 中 StepMap.empty 的区间数组为空);merge 返回 null,表示不与后续 Step 合并。子类可按需覆盖:下文的 AddMarkStep 覆盖 merge;改变位置的 ReplaceStep 才会让 getMap 返回非空映射。AttrStep 虽然覆盖 getMap,返回值与默认实现相同。
序列化还依赖注册机制。模块级 stepsByID 表将 stepType 字符串映射到 Step 类;Step.jsonID(id, stepClass) 负责注册,重复注册同一个 ID 会抛出 RangeError。Step.fromJSON(schema, json) 按 json.stepType 查表并调用对应类的静态 fromJSON,找不到类型时同样抛出 RangeError。自定义 Step 时,需要继承 Step、实现上述方法,并调用 Step.jsonID 注册不重复的 ID。
一个加粗 Step 序列化后长这样:
{"stepType": "addMark", "mark": {"type": "strong"}, "from": 5, "to": 12}fromJSON 的签名带 schema 参数,原因在这里:JSON 里的 mark 只有类型名,还原成 Mark 对象要拿 schema.markFromJSON 去 schema 里查对应的 MarkType。各 fromJSON 的参数校验风格一致,from/to 不是 number、pos 不是 number、attr 不是 string,都直接抛 RangeError,不把脏输入带进构造器。
StepResult 的失败表示
apply 返回 StepResult。StepResult 有两个字段:doc(成功时的新文档)与 failed(失败时的消息字符串);StepResult.ok(doc) 和 StepResult.fail(message) 分别创建两种结果。
StepResult.fromReplace(doc, from, to, slice) 包装 Node.replace:replace 成功时返回 ok,抛出 ReplaceError 时返回 fail。第 8 篇说明 replace 会执行内容表达式校验,校验失败时抛出 ReplaceError。该包装将 schema 校验失败表示为 apply 的正常返回值,调用方无需使用 try/catch。fromReplace 只捕获 ReplaceError;其他异常继续向上抛出,以避免隐藏代码错误。
失败通过返回值处理有两个场景。Transform 层(src/transform.ts)的 maybeStep 尝试应用一步,失败时跳过;同文件的 step 方法则在失败时抛出 TransformError。协作 rebase 中,本地 Step 可能无法应用到远端修改后的文档,这也是预期内的失败分支。
AddMarkStep 与 RemoveMarkStep
src/mark_step.ts 里这一对负责给一段内联内容加、去 mark,加粗、斜体、链接都走它们。以 AddMarkStep 为例看 apply 的完整过程:
- doc.slice(this.from, this.to) 取出覆盖区间的旧 Slice,openStart/openEnd 原样保留。
- mapFragment 递归遍历 slice 内容里的所有节点,对每个 inline 节点执行回调:非 atom(atom 即 inline 叶子,如文本、hard_break)的跳过,parent.type.allowsMarkType 不允许这种 mark 的也跳过,其余执行 node.mark(this.mark.addToSet(node.marks))。addToSet 是第 5 篇讲过的 mark 集合操作,负责处理同类型替换和 excludes 互斥。
- 用处理后的内容构造新 Slice,调 StepResult.fromReplace(doc, from, to, slice) 塞回去,replace 的闭合和校验逻辑整个复用。
第 2 步中的 parent 来自顶层调用 mapFragment 时传入的 $from.node($from.sharedDepth(this.to)):先将 from 解析为 ResolvedPos(第 7 篇),再取得 from 与 to 的共享祖先。选区跨段落时,该祖先可能是 doc 或 blockquote,allowsMarkType 据此判断 mark 是否允许。
RemoveMarkStep 的 apply 结构相同,回调换成 removeFromSet,且不检查 allowsMarkType,因为移除一个 mark 在任何位置都合法。它给 mapFragment 传的 parent 直接是 doc,反正回调里用不到。
这两个 Step 的 invert 互为逆操作。AddMarkStep.invert() 直接创建同区间、同 mark 的 RemoveMarkStep,不需要 doc 参数,因为区间和 mark 已包含在 Step 中。
merge 也值得看。两个 AddMarkStep 满足三个条件就能合并:mark 相等(mark.eq)、区间相交或相接(this.from <= other.to 且 this.to >= other.from),合并结果是覆盖并集的单个 AddMarkStep。连续两次给相邻区间加粗,历史里就只剩一步。消费方在 prosemirror-history:撤销栈条目合并时会调 step.merge,具体机制留到 history 那篇。
map 的实现只有几行:from 用 assoc=1、to 用 assoc=-1 映射,两端都被删(deleted 同时为真)或映射后区间为空,返回 null。assoc 方向决定边界语义:恰好在 from 处插入的内容不会被这步覆盖,to 处同理。assoc 和 deleted 的完整定义在 src/map.ts 的 MapResult 里,等 StepMap 那篇展开。
这一对没有覆盖 getMap:加、去 mark 不挪动任何位置,基类默认的 StepMap.empty 就是正确结果。这个设计有一个可观察的副作用,src/transform.ts 的 changedRange 计算一次 Transform 的改动区间时只看各步的 StepMap,它的注释里写明会忽略只增删 mark、没有替换内容的修改。
同文件里还有一对 AddNodeMarkStep 和 RemoveNodeMarkStep,给单个节点(比如图片这类允许 node mark 的叶子节点)加、去 mark。apply 的结构跟下面要讲的 AttrStep 几乎一样:doc.nodeAt(pos) 取节点、type.create 造新节点、替换 pos 到 pos+1。有意思的是 AddNodeMarkStep.invert 对 mark 冲突的处理。invert 拿到应用前的文档后先找节点,节点在的话把 this.mark addToSet 到节点当前的 marks 上算出应用后的集合,如果集合长度没变,说明这次添加要么是无操作(节点本来就有这个 mark,或者已有 mark 按 excludes 规则拒绝了它),要么它按 excludes 规则顶掉了集合里的另一个 mark。逐个比对旧集合,找出不在新集合里的那个,逆 Step 就是把它加回去的 AddNodeMarkStep;一个都找不到就是无操作场景,返回加 this.mark 的 AddNodeMarkStep,无操作的逆操作同样无操作,语义自洽。节点不存在,或者集合长度变了(说明 mark 确实加了进去),直接返回 RemoveNodeMarkStep。这段逻辑在为一个副作用补逆操作:undo 之后文档要回到加 mark 之前的样子,被顶掉的 mark 也得跟着回来。RemoveNodeMarkStep.invert 则有一个防御分支:节点不存在、或者这个 mark 本来就不在节点上时,返回 this 自身,apply 上去效果是把一个不存在的 mark 再移除一次,不产生实际变化。
AttrStep 与 DocAttrStep
src/attr_step.ts 解决一个更小的需求:改单个节点的单个属性,比如把标题的 level 从 1 改成 2、换图片的 src。Transform 上对应的入口是 setNodeAttribute(src/transform.ts)。
setNodeMarkup 也能修改 attrs,但其代价不同。它对叶子节点执行整节点替换,对非叶子节点使用 ReplaceAroundStep(src/structure.ts);StepMap 需要记录整个区间,invert 也需要保存被替换内容。AttrStep 只修改一个键,不改变文档位置,适用于单属性更新。
AttrStep.apply 的实现很短:doc.nodeAt(this.pos) 找不到节点就 fail(“No node at attribute step’s position”);浅拷贝一份 attrs,覆盖目标键;node.type.create(attrs, null, node.marks) 造新节点;最后用 Slice(Fragment.from(updated), 0, node.isLeaf ? 0 : 1) 替换 pos 到 pos+1。两个细节:content 传了 null,openEnd 对叶子取 0、对非叶子取 1,非叶子场景下 replace 的闭合逻辑会把原节点的内容接回新节点里,这就是 content 可以传 null 的原因。
getMap 在 AttrStep 里被显式覆盖了一次,返回 StepMap.empty,效果和基类默认完全一致:改属性不挪动任何位置,旧文档的每个位置在新文档里数值不变。行为上这个覆盖是多余的,写出来更像是把「这步不动位置」当作一条明确声明。DocAttrStep 同样覆盖了一次。
invert 需要 doc 参数,原因如下:
invert(doc: Node) {
return new AttrStep(this.pos, this.attr, doc.nodeAt(this.pos)!.attrs[this.attr])
}逆 Step 需要恢复旧属性值,而旧值只保存在应用前的文档中,Step 本身不保存该值。因此 invert 读取 doc 中的旧值,再构造对应的 AttrStep。与 AddMarkStep 相比,区别在于逆操作所需信息是否已包含在 Step 内。
map 很简单:pos 按 assoc=1 映射,结果的 deletedAfter 为真说明这个位置后面的 token(也就是目标节点本身)被删了,返回 null。
DocAttrStep 是同一思想在 doc 节点上的版本:doc 也有 attrs(应用层可以拿它存文档级元数据),但 doc 没有位置,构造参数只有 attr 和 value。apply 直接 doc.type.create(attrs, doc.content, doc.marks) 造新 doc,连 replace 都不用走。map 更省事,返回 this:内容怎么变都不影响「改文档级属性」这步操作。
invert 与 undo
invert 接口用于 undo。prosemirror-history 每次将一步修改应用到文档前,先调用 step.invert(当前 doc) 并将逆 Step 存入撤销栈;撤销时,将逆 Step 应用到当前文档。redo 栈保存正向 Step。
存储逆 Step 而非文档快照有两项影响:单步修改通常只涉及局部内容,Step 的大小随修改范围增长,而快照大小随整篇文档增长;撤销栈中的逆 Step 也可以通过 map 映射远端到达的修改,快照无法执行这种映射。
undo 的粒度是 Step 级的,但用户感知的撤销单元往往是一次「操作」。连续敲入的十个字符是十个 Step,按一次撤销通常希望它们一起回退。history 按事件分组处理这个需求,分组时还会尽量调 step.merge 把相邻 Step 压成一步,前面 AddMarkStep 的区间并集合并就是为这种场景准备的。具体机制留到 history 篇。
这也解释了 Step 基类注释里那句提醒:Step 一般只适用于它为之创建的那份文档,因为存进去的位置只对那份文档有意义。想跨文档使用,必须先 map。
本篇说明了 Step 抽象、mark 相关 Step 与属性修改 Step。ReplaceStep 处理区间替换、openStart/openEnd,以及 Fitter 为 slice 寻找闭合节点序列,下一篇分析其实现。
