上一篇看了插件系统的静态结构:PluginSpec、Plugin、PluginKey 三件套,以及 StateField 的 init/apply 约定。规格里还剩四个字段没拆:props、view、filterTransaction、appendTransaction。前两个把插件接到 EditorView 上,后两个介入事务的应用过程。串起后两个的是 src/state.ts 里的 applyTransaction,也就是 EditorState.apply 背后的完整实现,整个插件系统里控制流最绕的一段代码就在这个函数里:一个死循环套着一轮插件轮询,外加一本记录「每个插件看到第几个事务」的账。这篇把这段控制流和 props 的传递链看完。参考代码是 prosemirror-state 的 ffad5d9,涉及 EditorView 一侧时参考 prosemirror-view 的 ca4c78e。
系列目录
| 日期 | 标题 |
|---|---|
| 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 与插件状态 |
| 12-13 | Plugin 系统(下):props、appendTransaction 与 filterTransaction(本篇) |
applyTransaction 的三段流程
EditorState.apply(tr) 只有一行:return this.applyTransaction(tr).state。完整版 applyTransaction 的签名返回 {state, transactions},transactions 是数组,因为一次应用产生的事务可能不止用户提交的那一个,插件可以在后面追加。整个流程分三段:
- 先过
filterTransaction:任何插件返回 false,直接返回{state: this, transactions: []},state 原样不动。 - 过滤通过,
applyInner把事务应用到所有字段(doc、selection、storedMarks、各插件的 StateField)上,得到 newState。 - 进入
appendTransaction循环:插件逐个被询问要不要追加事务,每追加一个就立刻 applyInner 进 newState,直到一整轮循环没有任何插件再追加为止。
退出条件是「一整轮没有新增」,所以返回的 transactions 至少是 [rootTr],上限取决于插件什么时候收敛。下面两段分别拆开看。
顺带一提这个返回值的消费方。EditorView 的默认 dispatch(src/index.ts 里 EditorView.prototype.dispatch)走的是 this.updateState(this.state.apply(tr)),也就是只取 state、丢掉事务列表,因为追加事务已经合并进新 state 里了,界面更新不需要知道过程。需要自己接管派发的场景(比如自定义 dispatchTransaction)才用得到 applyTransaction 的完整返回值。
filterTransaction:每个插件都有否决权
src/state.ts 里的 filterTransaction(tr, ignore = -1) 本体很短:
for (let i = 0; i < this.config.plugins.length; i++) if (i != ignore) {
let plugin = this.config.plugins[i]
if (plugin.spec.filterTransaction && !plugin.spec.filterTransaction.call(plugin, tr, this))
return false
}
return true按插件数组顺序逐个问,有一个返回 false 就否决。.call(plugin, ...) 把 this 绑成插件实例,过滤函数里可以直接访问自己的 spec 或通过 key 取状态。第二个参数 state 是应用前的旧 state,过滤器能看到事务要改什么(tr.steps、tr.selection、tr.getMeta)和改之前的状态,但看不到改之后的样子,想知道结果得自己在脑子里过一遍 steps,或者用 tr.doc(事务构建过程中已经算好的新文档)去查。否决是「任一否决」,插件顺序在这里不影响结果。
否决的语义是整个事务不应用,注意此时还没有任何东西被应用过,谈不上回滚。调用方(通常是 view 的 dispatchTransaction)拿到的是原 state 和空事务列表。只读模式、输入长度上限、禁止某些节点被删除这类需求,挂的就是这个钩子:一次按键产生的事务就这样凭空消失,用户侧表现为「没反应」。过滤发生在 applyInner 之前,被否决的事务连 StateField 的 apply 都不会触发,成本就是几个函数调用。
ignore 参数在第二处调用点才起作用。appendTransaction 循环里,插件追加的事务也要过一遍过滤:newState.filterTransaction(tr, i),ignore 传的是追加者自己的下标。含义很直接:插件不能否决自己刚追加的事务,但其余插件的否决权保留。也就是说追加的事务照样可能被别的插件毙掉,被毙掉就当它没出现过,循环继续。两道过滤共用一个实现,靠 ignore 区分场景。
appendTransaction:链式修正
规格签名是 (transactions, oldState, newState) => Transaction | null | undefined。契约:事务应用完之后,插件有机会检查新状态,觉得需要修正就返回一个追加事务,跟在原事务后面一起应用。典型场景是「输入完成后顺手做一件相关联的事」,比如关闭一个打开的注记、同步一个计数。
这段循环的难点在于「每个插件该看到哪些事务」。代码里用一个 seen 数组记账,第 i 项记录插件 i 已经处理到 trs 数组的第几个(n),以及处理完那批事务时的状态(state):
let n = seen ? seen[i].n : 0, oldState = seen ? seen[i].state : this
let tr = n < trs.length &&
plugin.spec.appendTransaction.call(plugin, n ? trs.slice(n) : trs, oldState, newState)插件每次被调用时,收到的 transactions 只是它没见过的那部分(trs.slice(n)),oldState 是应用完它见过的那批之后的状态,newState 是当前最新状态。oldState 和 newState 恰好夹住它没见过的这批事务,文档注释里说的「it won’t be passed transactions that it already saw」就靠这本账保证。一个插件也永远收不到自己追加的事务:追加成功后 seen[i] = {state: newState, n: trs.length},自己的 tr 排在 n 之后。
seen 数组是惰性创建的,只在第一个追加事务出现时才初始化。初始化那一刻,排在当前插件前面的插件被标记为「已看完全部现有事务」,下一轮它们会被再叫一次,只看到那个新事务;排在后面的从 0 开始,轮到它们时看到的是完整列表。最终效果:每个插件对每个事务恰好看到一次,只是分批方式不同。
用一个两轮的例子把这过程走一遍。两个插件 A、B,都挂了 appendTransaction。rootTr 进来,applyInner 后进入循环。第一轮:A 先被问到,此时 seen 还是 null,A 收到完整的 [rootTr],oldState 是原 state;假设它返回 trA。seen 这时初始化,A、B 都先记 0,trA 入队并应用,随后 A 的账更新为 n=2(rootTr 和 trA 都算见过了)。接着 B 被问到,它的 n 是 0,看到完整的 [rootTr, trA],oldState 仍是原 state,newState 是应用完 trA 的;假设 B 返回 trB,入队、应用,B 的账更新为 n=3。第一轮有新增,循环继续。第二轮:A 的 n 是 2,trs 长度是 3,只看到 [trB],oldState 是应用完 trA 时的状态,newState 是最新;B 的 n 是 3,n < trs.length 不成立,这轮根本不会被调用。A 如果返回 null,一整轮没有新增,循环退出,返回的 transactions 是 [rootTr, trA, trB]。每个插件对 trA、trB 各恰好见到一次,见到时的 oldState 和 newState 恰好夹住它没见过的那批。
追加的事务有两个硬约束。第一,它必须基于当时的 newState 构建,因为紧接着就是 newState.applyInner(tr),而 applyInner 开头会校验 tr.before.eq(this.doc),对不上直接抛 “Applying a mismatched transaction”。实践上就是在 appendTransaction 里用 newState.tr 起一个新事务来改。第二,它会被打上 tr.setMeta("appendedTransaction", rootTr),指向根事务。prosemirror-history 的 history.ts 读这个 meta,把追加事务和根事务并进同一个撤销分组,用户按一次撤销不会只撤掉修正留下的半截。
插件顺序在循环里也有实际含义。每一轮都按数组顺序问,排前面的插件先看到当前局面、先追加;它追加之后,后面的插件本轮还没被问过,新事务落在它们的未读区间里,同一轮就能看到。反过来,后面的插件追加时前面的已经问完了,要等下一轮才轮到它们响应。有依赖关系的修正插件,被依赖的一方要往前排。
关于死循环。代码里没有迭代次数上限,保护全在这套记账规则里。插件收不到旧事务,所以「每次循环都被全部历史事务再触发一遍」的失控形态不存在;插件也收不到自己的追加,所以「自己触发自己」也不存在。剩下唯一会失控的形态是两个插件互相修正、互不收敛:A 看到 B 的追加又追加一个,B 看到 A 的再追加一个,ping-pong 下去。这层防护被放在了契约上而不是代码里:appendTransaction 返回事务前必须先验证条件,状态已经满足要求就返回 null。写这类插件时条件判断要放在最前面,返回 null 是常态,返回事务是例外。
最后值得记一下这两个钩子的生效层级。filterTransaction 和 appendTransaction 都长在 state.apply 的路径上,也就是说任何渠道派发的事务都逃不过它们:view 的输入、命令、协作收到的远端事务,只要走 state.apply 就会被过滤和修正。反过来的边界同样成立:不产生事务的交互它们看不到,一个 mousedown 被 handleDOMEvents 吃掉、没有转成事务,state 层对此一无所知。要拦截「变更」用 state 层钩子,要拦截「行为」用 props 里的事件 handler,分工是按这个边界划的。
view 规格:插件怎么接到 EditorView
PluginSpec.view 的签名是 (view: EditorView) => PluginView,PluginView 只有两个可选方法:update(view, prevState) 和 destroy()。这是插件拿到 EditorView 实例的正式入口:需要操作 DOM、挂浮层、监听状态变化做副作用的插件从这里进。它和 StateField 的分工是,StateField 存数据、随事务演进,pluginView 做副作用、随 view 生命周期存亡。常见的写法是 view 函数里创建一个 DOM 节点挂到编辑器容器上,返回的对象在 update 里根据前后 state 的差异调整这个节点,destroy 里把它摘掉。插件想留住 view 引用也在这里做,spec.view 的调用参数就是 EditorView 本体,存进闭包,update 和 destroy 里都能用。
消费方在 prosemirror-view 的 src/index.ts,updatePluginViews(prevState),逻辑分两支。插件集合变了(prevState.plugins != this.state.plugins,或直接传给 view 的 plugins 变了):把现有 pluginViews 全部 destroy,然后先遍历 directPlugins、再遍历 state.plugins,对有 spec.view 的逐个调用 plugin.spec.view(this) 重建,创建的 pluginView 按这个顺序排进数组。插件集合没变:遍历现有 pluginViews,有 update 的调 pluginView.update(this, prevState)。update 拿到的 prevState 是更新前的 state,pluginView 自己做前后对比,决定要不要动 DOM,框架不替它 diff。view 销毁时 destroyPluginViews 把数组弹空调 destroy。
「集合变了」用的是数组引用比较。EditorState.apply 产生的新 state 共享同一个 Configuration,plugins 数组是同一个引用,所以普通的事务更新都走 update 分支,不会触发重建。只有 reconfigure 换了插件集,pluginView 才会被销毁重建。这也意味着 pluginView 的 destroy 里必须清理干净自己挂的 DOM 和监听器,否则 reconfigure 一次就漏一份。
view 的 props 里也允许直接传 plugins(内部叫 directPlugins),但 checkStateComponent 会拦下带 state、filterTransaction、appendTransaction 的插件,抛 “Plugins passed directly to the view must not have a state component”。原因很直接:view 不管状态应用,这三个字段放在 directPlugins 里永远不会被执行,拦在入口处比静默失效好。view 这一层只认 props 和 view 两个字段。
props:从插件规格到 someProp 的传递链
Plugin 构造函数里有一段容易被略过的代码(src/plugin.ts):
if (spec.props) bindProps(spec.props, this, this.props)bindProps 把规格里的 props 复制到插件实例自己的 props 对象上,过程中函数值全部 bind 成插件实例。handleDOMEvents 被特殊处理:它是两层结构({mousedown: fn, ...}),普通 bind 只处理第一层,所以代码里对它递归调 bindProps 把内层函数也绑上。绑定完成后的效果是,插件写的 handleKeyDown 里 this 就是插件实例,this.getState(view.state) 可以直接取到自己的 StateField 值,不需要把 PluginKey 传来传去。
view 一侧读 props 的统一入口是 someProp,同样在 src/index.ts。它有两种用法:不传回调时,返回第一个非 undefined 的 prop 值;传回调时,对每个有定义的 prop 依次调用,回调返回真值就停并把这个结果返回。查找顺序是固定的:直接传给 view 的 props(_props)→ directPlugins 的 props → state.plugins 的 props。所以同名 prop 存在优先级:直接给的压过插件给的,排前面的插件压过排后面的。插件数组的顺序在这里第一次显出实质性的意义,上一篇说顺序影响 StateField 初始化,这里又多了一条:顺序决定事件谁先处理。
事件处理(handleKeyDown 这类)、attributes、nodeViews、editable 全部走这一个函数,区别只在调用方传的回调怎么写:事件类 prop 回调里直接执行 handler,第一个返回 true 的截获,后面的插件不再被问到;收集类 prop(比如 attributes)回调只收集不截获,每个定义都会被执行一遍。props 之间没有深合并这回事,两个插件都定义了同一个 prop,效果完全取决于 someProp 回调的写法。各个 prop 的具体语义留到 view 阶段拆,这里先记住传递链:插件规格的 props 字段 → 构造时 bind 到插件实例 → 存进 plugin.props → EditorView.someProp 按顺序消费。
另外 PluginSpec 的接口定义里留了 [key: string]: any,规格上允许挂任意额外字段,通过 plugin.spec 读回,这是插件系统留的扩展缝。不过官方扩展基本不走这条路:keymap 是把按键表闭包进 props 的 handleKeyDown 里,配置根本没落在 spec 上。这条缝主要留给自己的代码做跨插件约定。
结尾
插件规格的能力面到这里齐了:state 字段管数据(上一篇),filterTransaction 和 appendTransaction 管事务的否决与修正,view 和 props 管与 EditorView 的交互。下一篇动手写三个插件:一个纯 StateField 的字符统计,一个 filterTransaction 的输入拦截,一个带 view 的状态上报,把这套能力面全部过一遍。

