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

📅
2 分钟阅读
·

transform 阶段的六篇文章讨论了「怎么改文档」:Step、ReplaceStep、StepMap、Mapping、structure.ts,Transform 类提供这些对象的 API。但编辑器运行时还需要保存文档以外的状态。光标位置、下一次输入使用的格式、撤销栈中的 step 数量都不在文档树里。prosemirror-state 将这些信息与文档组织为不可变状态,主要由 EditorState 类(src/state.ts)处理。这个文件不到三百行,create、apply、reconfigure 三条路径分别处理状态的创建、更新和插件配置变更。参考代码是 prosemirror-state 的 ffad5d9。

系列目录

一个 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 和插件集合在一次配置中固定。新 state 共享 config,因此不需要重新创建配置对象。字段列表也属于 config;applyInner 重建实例时仍遍历同一份 fields,字段的 init/apply 逻辑、插件集合和 schema 保持不变。

为什么状态要不可变

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 校验与当前 state 保持一致。

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 将固定的 schema、插件配置和字段描述保存在 config 中,将 doc、selection、storedMarks 及插件状态保存在实例字段中。create 和 apply 都按 fields 顺序处理字段;reconfigure 根据字段名保留或初始化状态。下一篇说明 selection 体系中的 anchor/head 语义和选区书签。


864 字 · 44 段落
ximing

Follow onGitHub

相关文章