前十二篇把整体结构和 prosemirror-model 看完了:文档树长什么样、位置怎么编号、怎么切片怎么解析。model 层有个贯穿始终的约束:文档不可变,Node 上没有任何修改自身的方法。那修改从哪来?答案在 prosemirror-transform。用户敲一个键、粘一段内容、点一下加粗按钮,最后都会被拆成一个或多个 Step,逐个施加到旧文档上,产出新文档。这篇看 Step 这个抽象本身,以及三个最简单的 Step 实现。参考代码是 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 调用也能工作,ProseMirror 选择多走一层抽象。拆成 Step 之后,每步修改是一个独立的数据对象,带位置、带参数、知道自己做了什么。几个消费方都依赖这个形态:
- undo/redo:历史栈里存的是逆 Step,撤销时对逆 Step 再 apply。存文档快照的替代方案在协作下没法用,内存也吃不消。
- 协作编辑:Step 可以序列化成 JSON 发到别的端,远端反序列化后在本地文档上 apply。
- 位置映射:凡是按位置记状态的地方(选区、装饰、插件状态),文档一变就要把旧位置映射到新文档。每步的 getMap 给出这一步的映射,多步串起来就是完整映射。
这三件事要求每个 Step 可应用、可逆、可映射、可序列化。Step 基类的接口就是按这几条划出来的。反过来看,不拆成 Step 的方案,比如直接对比两份文档求 diff、或者直接操作 DOM,在这三件事上都使不上力,这也是开篇第一篇谈「文档即数据」时埋下的伏笔。
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;getMap 的覆盖要等到改变位置的 ReplaceStep 才返回非空映射(下一篇),这篇里 AttrStep 虽然覆盖了它,返回值和默认实现一样。
序列化侧还有一套注册机制。模块级的 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,其他异常原样向上抛:schema 校验失败是预期内的结果,代码 bug 造成的异常不该被吞掉。
失败走返回值有两个实际场景。一是 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 时传的 parent 是 from.sharedDepth(this.to)):把 from 解析成 ResolvedPos(第 7 篇),取 from 和 to 共享祖先所在的深度,拿到那个祖先节点。选区跨段落时共享祖先可能是 doc 或 blockquote,allowsMarkType 的判断就以它为准。
RemoveMarkStep 的 apply 结构相同,回调换成 removeFromSet,且不检查 allowsMarkType,因为移除一个 mark 在任何位置都合法。它给 mapFragment 传的 parent 直接是 doc,反正回调里用不到。
invert 上这一对是特例:互为逆操作。AddMarkStep.invert() 直接 new 一个同区间同 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)。
看到 AttrStep 之前容易有个疑问:setNodeMarkup 也能改 attrs,为什么要单独一种 Step。区别在于代价。setNodeMarkup 对叶子节点是整节点替换,对非叶子节点走 ReplaceAroundStep(src/structure.ts),StepMap 里要记整段区间,invert 要把被替换的内容存进逆 Step;AttrStep 只改一个键,文档位置一个都不动。改一个属性的场景,用替换类 Step 付出的成本明显超出需要。
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 是这节的重点,也是 invert 签名带 doc 参数的原因:
invert(doc: Node) {
return new AttrStep(this.pos, this.attr, doc.nodeAt(this.pos)!.attrs[this.attr])
}逆 Step 要把属性改回旧值,而旧值只存在应用前的文档里,Step 自身不带(带了会让 Step 体积变大,也给序列化增加负担)。所以 invert 必须拿到 doc,读出旧值,构造一个设回旧值的 AttrStep。对照 AddMarkStep 那个不需要 doc 的 invert,差异就在逆操作所需信息是否自足。
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:内容怎么变都不影响「改文档级属性」这步操作。
可逆性对 undo 的意义
回到开头列的消费方,invert 这条接口是为 undo 准备的。prosemirror-history 的做法是:每往文档上 apply 一步,先调 step.invert(当前 doc) 把逆 Step 存进撤销栈;用户撤销时,把逆 Step apply 到当前文档,文档回到这步之前;redo 栈里存正向 Step。
存逆 Step 而不存文档快照,有两个直接好处。一是内存:一步修改通常只动一小块,Step 的大小和修改范围成正比,快照的大小和整篇文档成正比。二是协作兼容:撤销栈里的逆 Step 和本地未提交的 Step 一样,可以用 map 映射过远端到达的修改,快照做不到这一点。
undo 的粒度是 Step 级的,但用户感知的撤销单元往往是一次「操作」。连续敲入的十个字符是十个 Step,按一次撤销通常希望它们一起回退。history 按事件分组处理这个需求,分组时还会尽量调 step.merge 把相邻 Step 压成一步,前面 AddMarkStep 的区间并集合并就是为这种场景准备的。具体机制留到 history 篇。
这也解释了 Step 基类注释里那句提醒:Step 一般只适用于它为之创建的那份文档,因为存进去的位置只对那份文档有意义。想跨文档使用,必须先 map。
这篇看完了 Step 抽象和三个最简单的实现。transform 包真正的重头在 ReplaceStep:区间替换、openStart/openEnd 的消费、Fitter 给 slice 找闭合节点序列,下一篇拆它。

