Plugin 系统(上):StateField 与插件状态

📅
2 分钟阅读
·

上一篇说明了一次状态更新如何由旧 state 应用 transaction 得到新 state。这篇讨论插件数据的存放位置。参考代码是 prosemirror-state 的 ffad5d9,主文件 src/plugin.ts,其中导出 PluginSpec、Plugin、PluginKey。还会结合 src/state.ts 的字段装配逻辑,说明插件状态的初始化与更新顺序。

系列目录

三件套:PluginSpec、Plugin、PluginKey

plugin.ts 的导出就三个,分工各不相同。

PluginSpec 是接口,用于描述插件提供的能力。字段有六个:props 是插件贡献给 view 的属性;state 是插件自己的状态槽,类型是 StateField,本篇的重点;key 挂一个 PluginKey,让插件变成可定位的;view 让插件在 EditorView 一侧挂东西;filterTransactionappendTransaction 是 transaction 层面的两个钩子。接口最后留了一个 [key: string]: any 索引签名,插件可以在 spec 上放任意自定义字段,之后通过 plugin.spec 读回来。prosemirror-history(参考代码 445409b)的 history(config) 就把补齐默认值后的配置挂在 spec 的 config 字段上,undo 命令构造回滚 transaction 时,在 histTransaction 里用 historyKey.get(state).spec.config 把它取回来。mustPreserveItems 还展示了另一种读法:遍历 state.plugins,看哪个插件的 spec 上写了 historyPreserveItems,协作插件靠这个自定义字段通知 history 保留步骤的原始形态。

Plugin 是插件实例,构造函数处理两项内容。spec 带有 props 时,模块内的 bindProps 会将其存入 this.props:函数值绑定到插件实例;handleDOMEvents 是嵌套对象时,内层函数也会递归绑定;其余值原样拷贝。此后 props 函数里的 this 指向插件实例,事件回调可以直接调用 this.getState(view.state)。props 在 view 层的消费方式留到下一篇说明。构造函数还会确定实例的 key 字符串:spec 提供 PluginKey 时使用其 key,否则调用 createKey("plugin") 生成匿名 key。

createKey 负责生成 key 字符串。模块级的 keys 注册表对同名 key 首次返回 name + "$",后续返回 name + "$" + 递增序号。因此第一个匿名插件是 plugin$,第二个是 plugin$1new PluginKey("history") 得到 history$。该字符串有三个用途。

PluginKey 本身只是个包装:构造时调 createKey(name) 存下字符串,再提供两个查询方法。get(state)state.config.pluginsByKey[this.key],返回这个 key 对应的插件实例;getState(state) 返回 (state as any)[this.key],直接按字符串读 state 实例上的属性。Plugin 类上也有一个 getState,实现一模一样,区别在于拿到引用的方式:PluginKey 走注册表,调用方不需要持有插件实例;Plugin.getState 走实例自己。两个类都带一个 PluginState 泛型参数,写插件时把字段值的类型填进去,getState 的返回值就带上了类型,取数的一端不用再做类型断言。

key 还有一条约束在装配期:同一个 state 里同 key 的插件只能有一个,src/state.ts 的 Configuration 构造函数发现 pluginsByKey 撞 key 直接抛 RangeError。

官方的 history 插件采用以下组织方式:new PluginKey("history") 是模块级私有常量,不导出;history() 工厂函数返回挂了这个 key 的插件实例;对外的读口全部做成导出函数,undoDepth(state)redoDepth(state) 返回两个栈的深度,isHistoryTransaction(tr) 判断一个 transaction 是不是 undo/redo 产生的。外部模块想碰 history 的状态,只能走这几个函数,拿不到 key 本身。插件状态的读写面因此收窄成一组显式 API,key 字符串成了模块的实现细节。

StateField:插件状态的形状

spec.state 的类型 StateField<T> 也是接口,两个必需方法加两个可选方法:

init: (config: EditorStateConfig, instance: EditorState) => T
apply: (tr: Transaction, value: T, oldState: EditorState, newState: EditorState) => T
toJSON?: (value: T) => any
fromJSON?: (config: EditorStateConfig, value: any, state: EditorState) => T

init 在 EditorState.create 时被调,拿到用户传入的 config 和一个初始化了一半的 state 实例,返回字段的初始值。apply 在每次 transaction 应用时被调,参数依次是 transaction、字段的旧值、旧 state、构造了一半的新 state,返回字段的新值。toJSONfromJSON 是序列化对,不配就放弃这个字段的 JSON 往返,EditorState.fromJSON 恢复时退回 init

这套接口内置字段也在用。src/state.ts 顶部的 baseFields 数组里,doc、selection、storedMarks、scrollToSelection 四个内置字段就是四个 StateField,插件字段和它们由同一套逻辑装配:Configuration 构造时把 baseFields 拷一份,然后按插件数组的顺序,给每个带 spec.state 的插件追加一个 FieldDesc,字段名就是插件的 key 字符串。FieldDesc 顺手把 init 和 apply 绑定到插件实例上,所以 StateField 方法里的 this 同样是插件实例。装配完成后,EditorState.create 按字段顺序调 init,把返回值逐个写成实例属性;applyInner 按同样顺序调 apply。插件状态就是 state 实例上一个以 key 字符串命名的普通属性,没有单独的容器。

字段装配与 EditorState 实例的属性槽

接口规定,init 拿到的 instance 和 apply 拿到的 newState 都只包含排在前面的字段。插件字段永远排在四个内置字段之后,所以在 apply 里读 newState.docnewState.selection 总是安全的;读其他插件的字段要看顺序,下一节展开。

apply 的纯函数约束由接口约定而非代码强制:不能原地修改 value,发生变化时应返回新对象。EditorState 是持久数据结构,apply 后旧 state 仍可能被 view 比较或被 history 用于回滚。原地修改插件字段会同时改变旧 state。init 也不应产生副作用,因为 state 可以在没有 view 的环境中重复创建。

序列化这一对方法在 EditorState 一侧有对应的接线。EditorState.toJSON 接受一个 pluginFields 参数,形状是「JSON 属性名到插件实例」的映射:遍历时取出每个插件的 spec.state,字段实现了 toJSON 就把字段值序列化后写进结果对象,属性名用映射里给的那个。docselection 两个名字被保留,映射里用了直接抛 RangeError。EditorState.fromJSON 走反向路径:按插件的 key 字符串匹配字段,映射里给了对应属性且字段实现了 fromJSON 就从 JSON 里恢复,否则退回 init。两个方向上 JSON 属性名都由调用方决定,但字段归属的匹配始终靠 key 字符串:toJSON 用 plugin.key 从 state 上取值,fromJSON 用 plugin.key == field.name 找字段。恢复时传进来的插件实例和序列化时的 key 对不上(比如匿名插件重建后领到了新序号),旧 JSON 里的字段就找不到归属,静默退回初始值。

两个内置字段的实现能看清这套接口的用法。scrollToSelection 是个计数器:init 返回 0,apply 看 tr.scrolledIntoView,调用过就 prev + 1,否则原样返回。view 一侧盯着这个数的变化执行滚动,数值本身没有意义,变没变才是信号。storedMarks 的 apply 是 state.selection.$cursor ? tr.storedMarks : null,它读的是 newState 上的 selection,也就是这个新 state 刚算好的选区:光标状态下保留 transaction 带过来的 storedMarks,范围选区下清空。这个实现能成立,依赖的正是字段顺序:selection 排在 storedMarks 前面,apply 推进到 storedMarks 时 newState.selection 已经写好了。

例如,下面定义了一个 StateField:

const counterKey = new PluginKey("counter")

const counter = new Plugin({
  key: counterKey,
  state: {
    init() { return 0 },
    apply(tr, value) { return tr.docChanged ? value + 1 : value }
  }
})

init 没用 config 就忽略它,apply 没用到的 oldState、newState 同理,签名允许按需取参。把这个插件放进 EditorState.create 的 plugins 数组,state 实例上就多了一个 "counter$" 属性,每次改文档的 transaction 应用后加一。字段值可以是数字、数组、不可变对象,也可以是 history 的 HistoryState 这类实例。仍需遵守两个约束:不原地修改字段值,且不在 init 中产生副作用。

插件顺序的含义

插件数组的顺序在 state 层面有一个确定含义:它决定字段初始化和 apply 的顺序,进而决定一个插件的 apply 能从 newState 上读到哪些字段。

规则只有一条:apply 想读别的字段的新值,那个字段所属的插件必须排在前面。读旧值不受限,oldState 是完整的,所有字段都在。一个插件想根据「另一个插件在新 state 里的值」更新自己,顺序排错了读到的就是 undefined,因为 newState 上那个属性还没写进去。StateField 的注释把 half-initialized state 写明在签名旁边,这是接口契约的一部分,不属于实现细节。

多数插件的 apply 只需要三类输入:transaction 本身(steps、meta、时间戳)、字段旧值,以及 newState 上的 doc 和 selection。这些输入不受插件顺序影响。只有插件 B 需要读取插件 A 在新 state 中的字段时,顺序才成为约束。可以将 B 排在 A 后面,使 B 的 apply 读取 A 的新值;也可以让 B 仅依赖 A 的旧值和本次 transaction。官方包里后一种更常见,history 的 apply 读的输入是 tr、旧 HistoryState 和旧 state 的选区(开新事件分组时存一个选区书签),不碰其他插件的字段,因此 history 放在插件数组的哪个位置都能工作。

冲突检测同样发生在装配期。Configuration 构造时遍历插件数组,发现 pluginsByKey 里已有同名字符串就抛 RangeError,两个带同 key 的插件实例传进 EditorState.create,create 当场失败,不会拖到某次 apply 才出怪事。匿名插件没有这个顾虑,每个实例领到的 key 都不同。

EditorState.reconfigure 是字段机制的另一个消费场景。它拿一组新插件重建 Configuration,然后逐字段处理:新配置里的字段在旧 state 上有同名属性就保留旧值,没有才调 init。判断用的是 hasOwnProperty,而字段名是 key 字符串,所以同一个 key 的插件换了一个实例,状态会保留;插件被移除再加回来,状态丢失,重新走 init。reconfigure 只接受 plugins 一项,换 schema 不在它的能力范围内。这个设计的实际收益是插件可以按 key 热插拔:视图层需要动态增删插件时,只要 key 不变,undo 栈、协作版本号这类累积状态就不会因为重建 state 而清零。

字段顺序之外,插件数组的顺序还影响 props 的覆盖优先级和 filterTransaction、appendTransaction 的调用次序,那部分涉及 transaction 流经各插件的完整路径,放到下一篇。

key.getState:取数的实际路径

把前面几节串起来,key 字符串的三处用途就清楚了。第一次,它是插件字段在 fields 数组里的名字;第二次,它是 state 实例上的属性名,getState 那句 (state as any)[this.key] 就是全部实现;第三次,它是 pluginsByKey 的索引,PluginKey.get 靠它反查插件实例。

看一个完整例子。history 插件在 src/history.ts 里建了 historyKey = new PluginKey("history")history() 返回的插件 spec 同时挂了 key: historyKey 和一个 StateField:init 返回一个空 HistoryState(done 栈和 undone 栈都是空 Branch),apply 调模块内的 applyTransaction 根据新到的 transaction 算出新的栈结构。undo 命令执行时不需要接触插件实例,historyKey.getState(state) 直接取出 HistoryState,检查两个栈里有没有事件,有就构造回滚 transaction。插件的写路径(StateField.apply)和读路径(key.getState)通过同一个字符串对上,中间不需要任何注册代码。

getState 的返回值类型是 PluginState | undefined,undefined 对应「这个 state 里没装该插件」。插件没挂 key 时属性名是匿名 key,外部拿不到;挂了 key 但插件没进 plugins 数组时,state 上根本不存在这个属性。两种情况的读取结果都是 undefined,调用方必须判空。history 的命令构造函数 buildCommand 开头就是 let hist = historyKey.getState(state); if (!hist || ...) return false,拿不到状态直接让命令不可用,菜单系统据此把 undo 按钮置灰。这个判空对应一个实际场景:同一个 schema 完全可以创建一份不带 history 插件的 state,比如给只读预览用,命令在这种 state 上必须安全地失败。

PluginKey 还有第三种用法:做 transaction meta 的命名空间。history 文件里另有一个 closeHistoryKey = new PluginKey("closeHistory"),它从头到尾没挂到任何插件上,只在 closeHistory(tr) 里用于 tr.setMeta(closeHistoryKey, true),然后由 apply 调到的 applyTransactiontr.getMeta(closeHistoryKey) 读出来,作为「这次变更不要并进上一个历史事件」的标记。上一篇说过 meta 是 transaction 上的插件通信通道,PluginKey 给这个通道提供了不会撞名的 key:字符串来自 createKey 的注册表,天然全局唯一。setMeta 也接受普通字符串,用 PluginKey 的好处是写方和读方共享同一个对象引用,不用约定字面量,historyKey 自己在 undo 命令和 apply 之间传 {redo, historyState} 用的就是这个方式。

plugin.ts 的静态结构包括:spec 描述插件,Plugin 创建实例,PluginKey 定位插件,StateField 定义插件状态字段;插件数组顺序决定字段依赖的读取方向。下一篇讨论动态过程:filterTransaction 如何过滤事务,appendTransaction 如何追加事务并避免循环,props 如何传递到 EditorView,以及 view 规格如何接入视图层。


875 字 · 35 段落
ximing

Follow onGitHub

相关文章