ProseMirror state(上):EditorState,不可变编辑器状态

5 分钟阅读
·

transform 阶段六篇看完了「怎么改文档」:Step、ReplaceStep、StepMap、Mapping、structure.ts,最后由 Transform 类把这些零件收拢成一套 API。但编辑器运行时的状态不止文档。光标在哪、下一次输入要带什么格式、撤销栈里压了多少步,这些信息都不在文档树里。prosemirror-state 的职责就是把这些和文档捆在一起,提供一个不可变的整体状态,核心是 EditorState 类(src/state.ts)。这个文件不到三百行,但 create、apply、reconfigure 三条路径覆盖了编辑器状态的全部生命周期。参考代码是 prosemirror-state 的 ffad5d9。

系列目录

日期 标题
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 层
11-08 ProseMirror state(上):EditorState,不可变编辑器状态(本篇)

一个 state 装什么

EditorState 的内容习惯上说成四元组:schema、doc、selection、plugins。schema 决定文档允许的结构,doc 是文档本体,selection 是选区,plugins 提供扩展字段和行为钩子。落到代码里,这四样东西分成两层存放。

第一层是 Configuration 类的实例,存在 state 的 config 属性上。它持有 schema、plugins 数组、pluginsByKey 索引表,以及一份 fields 列表。这份配置在一次事务前后不变,apply 产生的新 state 直接共享同一个 config 对象,不重新构造。

第二层是挂在 EditorState 实例上的字段。fields 列表里每一项是一个 FieldDesc,有 name、init、apply 三个属性。实例上以 name 为属性名直接挂值:doc、selection、storedMarks、scrollToSelection 这四个内置字段,加上每个声明了 spec.state 的插件贡献的字段,属性名就是插件的 key。所以插件取自己的状态有两种写法,plugin.getState(state)state[plugin.key],读的是同一个属性:src/plugin.ts 里 Plugin.getState 的实现就是 return state[this.key]

schema 和 plugins 不进字段列表,而是两个 getter,转发到 config 上。这个划分的依据是变化频率:doc 和 selection 每次事务都可能变,schema 和插件集合在一次配置里固定,放到共享的 config 里,新 state 的构造成本就和插件数量无关了。字段列表本身也是 config 的一部分,applyInner 重建实例时遍历的是同一份 fields,字段的 init/apply 逻辑、插件集合、schema 在 state 的一生中始终是同一批对象。

为什么状态要不可变

EditorState 的类注释把自己定义成 persistent data structure:实例从不被修改,apply 总是算出一个新实例。这个约定在编辑器里有几处实际收益。

旧 state 在 apply 之后依然完整可用。appendTransaction 的签名同时拿到 oldState 和 newState,插件可以对比两者决定要不要追加事务;view 层更新时也能拿着前后两个 state 做差异判断。如果状态是就地改写的,这些对比就得靠提前拷贝,成本和行为都不明朗。

引用可以安全地长期持有。文档树本身也是不可变的,插件字段里存一份旧文档的引用做对比或留档,不用担心它被后续编辑改掉。同理,把某个 state 整个缓存起来做「恢复到此状态」的操作,不需要任何深拷贝。

还有一点对调试友好:每次更新都产出新对象,把一连串 state 存下来回放,每一步的完整现场都在。代价是每次事务都要重建实例,但前面说过,重建只是逐字段算一遍,内置字段全是 O(1) 搬运,文档树本身也是不可变结构,apply 时大部分子树被新文档直接共享,不复制内容。

baseFields:四个内置字段

src/state.ts 顶部的 baseFields 数组定义了所有 state 都有的四个字段,逐个看它们的 init 和 apply。

doc。init 时优先用 config.doc,没传就用 config.schema.topNodeType.createAndFill() 造一个最小合法文档,这也是 create 允许只传 schema 不传 doc 的原因。apply 直接返回 tr.doc,文档的新值在 Transform 阶段已经算好了,这里只是搬运。

selection。init 时用 config.selection,缺省调 Selection.atStart(instance.doc) 放到文档开头。注意它读了 instance.doc,所以 baseFields 的顺序是有约束的:doc 必须排在 selection 前面先初始化。apply 返回 tr.selection,transaction 在构造和加 step 的过程中一直维护着一个选区,这里同样是搬运。

storedMarks。含义是「下一次输入要带的格式」。在工具栏点了加粗但还没打字,光标处并没有任何字符可以挂 mark,这个意图就先存在 storedMarks 里。完整走一遍这个流程:点加粗按钮,命令发出一个调了 setStoredMarks 的 transaction,apply 后新 state 的 storedMarks 带上 strong;接下来敲一个字符,输入路径调 transaction 的 insertText,它优先用 tr.storedMarks 作为新文本节点的 marks(src/transaction.ts 的 Transaction.insertText),于是打出来的字就是粗的。init 用 config.storedMarks 或 null。apply 里有个条件:(state.selection as TextSelection).$cursor ? tr.storedMarks : null,只有新选区是文本光标时才保留,选区变成 NodeSelection 之类就清空。光标都不在文本里,「下次输入的格式」就没有意义了。

scrollToSelection。一个从 0 开始的计数器,transaction 上调过 scrollIntoView(tr.scrolledIntoView 为真)就加一,否则不变。view 层靠比较这个字段的前后值判断要不要滚动,用计数器而不用布尔值,是因为连续两次滚动请求之间状态可能被替换,布尔值会丢掉第二次请求。

create:先建 Configuration,再逐字段初始化

EditorState.create(config) 的入参是 EditorStateConfig,五个可选字段:schema、doc、selection、storedMarks、plugins。schema 和 doc 至少给一个:只给 schema 时用 topNodeType.createAndFill 造空文档,只给 doc 时 schema 从 doc.type.schema 反推。selection 和 storedMarks 对应两个内置字段的初始值,plugins 是激活的插件集合。

create 分两步。先构造 Configuration:把 baseFields 复制一份,然后遍历 config.plugins,每个插件登记进 plugins 数组和 pluginsByKey 表,key 重复直接抛 RangeError;插件的 spec.state 存在,就追加一个 FieldDesc,init 和 apply 都 bind 到插件实例上(src/state.ts 顶部的 bind 辅助函数,让插件方法里的 this 指向插件实例,StateField 的注释也说明了这个约定)。

pluginsByKey 这张表是给外部取插件用的。PluginKey.get(state) 的实现就是查 state.config.pluginsByKey[this.key]src/plugin.ts),不持有插件实例的代码也能通过 key 找到激活的插件。每个插件的 key 由 createKey 生成,同名自动加后缀编号,所以 key 冲突只会发生在同一个 PluginKey 被用了两次的场景,比如用同一个 key 构造两个插件实例都加进来,这正是 RangeError 要挡的情况,报错信息就是 “Adding different instances of a keyed plugin”。

然后创建实例并逐字段 init:

let instance = new EditorState($config)
for (let i = 0; i < $config.fields.length; i++)
  (instance as any)[$config.fields[i].name] = $config.fields[i].init(config, instance)

循环顺序就是 fields 的顺序:四个内置字段先,插件字段按插件注册顺序跟在后面。init 拿到的 instance 是半成品,排在自己后面的字段还没有值。src/plugin.ts 里 StateField.init 的注释把这一点写明了,插件的 init 只能依赖内置字段和排在自己前面的插件字段。写插件时如果想在 init 里读另一个插件的状态,插件顺序就得保证对方在前,这在 example-setup 那批官方插件的拼装顺序里会看到实际影响。

apply 的两阶段

state 不可变,所有更新都走 apply(tr) 产出新 state。apply 本体只有一行,转发给 applyTransaction 再取 .state。真正的工作在 applyTransaction 里,分两阶段。

第一阶段是过滤和落地。filterTransaction(rootTr) 逐个问插件的 spec.filterTransaction,任何一个返回 false,整个事务作废,直接返回 {state: this, transactions: []},state 原样不动。通过之后调 applyInner 落地:

applyInner(tr: Transaction) {
  if (!tr.before.eq(this.doc)) throw new RangeError("Applying a mismatched transaction")
  let newInstance = new EditorState(this.config), fields = this.config.fields
  for (let i = 0; i < fields.length; i++) {
    let field = fields[i]
    ;(newInstance as any)[field.name] = field.apply(tr, (this as any)[field.name], this, newInstance)
  }
  return newInstance
}

开头的 tr.before.eq(this.doc) 校验值得注意。Transform 那篇说过,tr.before 是事务的起点文档。这个事务必须是从当前 state 长出来的,拿一个基于旧文档构造的 transaction 来 apply,文档对不上,直接抛错。之后新实例共享 this.config,逐字段调 apply 算新值。四个内置字段的 apply 前面看过,全是 O(1) 的搬运;插件字段各自按自己的 StateField.apply 重算,签名是 (tr, value, oldState, newState)。newState 同样是半成品,排到后面的字段还没算出来,这个约束和 init 一致。

第二阶段是 appendTransaction 循环。插件的 spec.appendTransaction 可以在看到新事务后追加自己的事务。典型的用法有两类:一类是修正,发现新文档处于某种不合法或不完整的形状时,追加一个事务把它修正过来;另一类是同步,事务本身不用改文档,但插件要借一个事务把派生数据写进自己的字段,因为字段只能在 apply 流程里更新。循环里维护一个 seen 数组,给每个插件记下它已经见过的 transaction 数量和当时基于的 state,下一轮调用只传 trs.slice(n),也就是它没见过的那批,避免同一个事务被同一个插件反复响应。追加出来的 tr 也要过 filterTransaction,但调用时把当前插件自己的下标传进 ignore 参数跳过自己,防止插件把自己追加的事务否决掉造成的死结。通过后的 tr 打上 appendedTransaction meta 指向 rootTr,压进 trs,再 applyInner 一次得到更新的 newState。某一整轮没有任何插件追加,循环结束,返回 {state: newState, transactions: trs}

apply 和 applyTransaction 的差别就在这个返回值上。apply 丢弃 transactions 只给 state,日常路径用的就是它:view 层 dispatchTransaction 的默认实现就是 updateState(state.apply(tr))。applyTransaction 把实际生效的事务完整带回来,包括插件追加的部分,调用方需要知道「这次到底执行了哪些事务」时才用它,比如要把追加出来的 step 也纳入后续处理的场景。这套循环的死循环保护和 seen 的语义还有不少细节,留到插件系统那篇展开,这里先记住整体形状。

applyTransaction 的两阶段流程

另外提一下 state.tr 这个 getter,每次访问 new 一个 Transaction,以当前 doc 为起点、当前 selection 和 storedMarks 为初始值。所有要修改状态的操作都从这里领一个 transaction,改完交给 dispatch,dispatch 内部再走 applyTransaction。这条惯例保证 tr.before 校验几乎不会失败。

reconfigure:换插件不动文档

reconfigure({plugins}) 用一套新插件集合重建 state。场景是运行时切换插件:切只读模式时摘掉编辑类插件,开协作时挂上 collab,或者按权限动态加载功能。

实现上先按新插件集合构造新的 Configuration,然后逐字段处理:

;(instance as any)[name] = this.hasOwnProperty(name) ? (this as any)[name] : fields[i].init(config, instance)

新旧字段表按名字对齐。当前 state 上已有的字段(hasOwnProperty 为真)原样保留,没有的走 init。baseFields 永远在两边都存在,所以 doc、selection、storedMarks 总是保留,文档内容不会因为换插件丢掉。被摘掉的插件,它的字段从新 fields 里消失,值随之丢弃;新挂的插件走 init 拿到初始值。

两个边界要说清。一是 reconfigure 的 config 参数只接受 plugins,schema 沿用旧的(new Configuration(this.schema, ...)),想换 schema 只能从头 create。二是保留的前提是 key 不变,同一个插件用同一个 PluginKey 重新构造实例,字段能对上;如果新旧两套用了不同的 key,旧值会被当成陌生字段丢弃。

toJSON 与 fromJSON

最后看序列化。toJSON 固定产出 doc 和 selection 两项,storedMarks 非空时再带上。插件字段不会自动序列化,要调用方显式传一个 pluginFields 映射,把 JSON 属性名关联到插件实例,字段的 StateField.toJSON 存在才会写入。doc 和 selection 两个属性名是保留字,映射里用了就抛 RangeError。

fromJSON 是静态方法,config 里必须带 schema。doc 走 Node.fromJSON,selection 走 Selection.fromJSON,storedMarks 逐个 schema.markFromJSON。插件字段先在 pluginFields 里找对应的映射,找到映射、插件实现了 StateField.fromJSON、且 JSON 里确实有该属性,三个条件都满足就从 JSON 恢复,否则退回 field.init 用默认值兜底。也就是说 JSON 里缺的插件字段不会让恢复失败,插件以初始状态启动。

这套设计把「文档可序列化」和「插件状态可序列化」拆开了:文档的往返是完整的,插件状态由插件自己决定是否支持往返,state 层只提供挂载点。

本篇小结

EditorState 的机制收敛成几句话:config 存不变的 schema 和插件配置,实例字段存会变的 doc、selection、storedMarks 和插件状态;create 按 fields 顺序逐字段 init,apply 先过滤再 applyInner 逐字段重建,appendTransaction 循环处理插件追加;reconfigure 按字段名对齐换插件,序列化只保证文档完整往返。下一篇看 selection 体系,TextSelection、NodeSelection 这几种选区的 anchor/head 语义和选区书签。


887 字 · 44 段落
xi ming

Written by xi ming You should follow him on Github