上一篇看了 Transaction,知道一次状态更新就是「旧 state 应用一个 transaction 得到新 state」。这篇回答另一个问题:插件自己的数据放在哪。参考代码是 prosemirror-state 的 ffad5d9,主文件 src/plugin.ts,整个文件 140 行出头,装下了 PluginSpec、Plugin、PluginKey 三个导出。配合着看的还有 src/state.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 抽象,所有修改的最小单位 |
| 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,不可变编辑器状态 |
| 11-15 | Selection 体系:四种选区与选区书签 |
| 11-22 | Transaction:Transform 加上状态语义 |
| 12-06 | Plugin 系统(上):StateField 与插件状态(本篇) |
三件套:PluginSpec、Plugin、PluginKey
plugin.ts 的导出就三个,分工各不相同。
PluginSpec 是纯接口,描述一个插件想干什么。字段有六个:props 是插件贡献给 view 的属性;state 是插件自己的状态槽,类型是 StateField,本篇的重点;key 挂一个 PluginKey,让插件变成可定位的;view 让插件在 EditorView 一侧挂东西;filterTransaction 和 appendTransaction 是 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") 领一个匿名的。
createKey 是 key 字符串的唯一来源。模块级有一个 keys 注册表,同名第一次返回 name + "$",之后每次返回 name + "$" + 递增序号。所以第一个匿名插件是 plugin$,第二个是 plugin$1;new 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) => Tinit 在 EditorState.create 时被调,拿到用户传入的 config 和一个初始化了一半的 state 实例,返回字段的初始值。apply 在每次 transaction 应用时被调,参数依次是 transaction、字段的旧值、旧 state、构造了一半的新 state,返回字段的新值。toJSON 和 fromJSON 是序列化对,不配就放弃这个字段的 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 字符串命名的普通属性,没有单独的容器。
接口约定里有一句需要特别注意:init 拿到的 instance 和 apply 拿到的 newState 都是半成品,只有排在前面的字段有值。插件字段永远排在四个内置字段之后,所以在 apply 里读 newState.doc、newState.selection 总是安全的;读其他插件的字段要看顺序,下一节展开。
apply 的纯函数约束没有代码强制,靠约定维持:不许原地修改 value,有变化就返回新对象。原因在于 EditorState 整体是持久数据结构,apply 之后旧 state 还活着,view 拿新旧 state 做比较,history 拿旧 state 回滚,插件字段原地改值会把旧 state 的内容一起改掉,破坏整条链路的假设。init 同样不该留副作用,state 可以在没有 view 的环境里反复创建,测试代码就是这么干的。
序列化这一对方法在 EditorState 一侧有对应的接线。EditorState.toJSON 接受一个 pluginFields 参数,形状是「JSON 属性名到插件实例」的映射:遍历时取出每个插件的 spec.state,字段实现了 toJSON 就把字段值序列化后写进结果对象,属性名用映射里给的那个。doc 和 selection 两个名字被保留,映射里用了直接抛 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 的状态」这种设计才会浮出来。两个稳妥的解法:把 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 调到的 applyTransaction 用 tr.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 规格怎么把插件接到视图层。

