上一篇介绍了插件系统的静态结构:PluginSpec、Plugin、PluginKey,以及 StateField 的 init/apply 约定。这里说明其余四个字段:props、view、filterTransaction、appendTransaction。props 和 view 将插件接入 EditorView;filterTransaction 和 appendTransaction 介入事务的应用过程。后两者由 src/state.ts 的 applyTransaction 串联,它是 EditorState.apply 的完整实现,通过循环调用插件并记录各插件已处理的事务。这篇说明该控制流和 props 的传递路径。参考代码是 prosemirror-state 的 ffad5d9;EditorView 部分参考 prosemirror-view 的 ca4c78e。
系列目录
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 会消费这一返回值。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)和修改前状态;若需检查新文档,可读取事务构建时已计算出的 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 是当前最新状态,二者对应这批未处理事务之前和之后的状态。文档注释中的「it won’t be passed transactions that it already saw」由此保证。插件也不会收到自己追加的事务:追加成功后 seen[i] = {state: newState, n: trs.length},自己的 tr 位于 n 之后。
seen 数组仅在第一个追加事务出现时创建。创建时,当前插件之前的插件被标记为已处理全部现有事务,因此下一轮只接收新事务;之后的插件从 0 开始,首次调用时接收完整列表。每个插件会处理每个事务一次,但接收事务的批次不同。
以下以两个带 appendTransaction 的插件 A、B 为例。rootTr 经 applyInner 后进入循环。第一轮中,A 首先收到 [rootTr],oldState 为原 state;假设它返回 trA。seen 初始化后,A、B 的 n 均为 0,trA 入队并应用,A 的 n 更新为 2。B 随后收到 [rootTr, trA],oldState 仍是原 state,newState 已应用 trA;假设 B 返回 trB,入队并应用后,B 的 n 更新为 3。第二轮中,A 的 n 为 2,因而只收到 [trB];B 的 n 等于 trs 长度,不会被调用。若 A 返回 null,本轮没有新增事务,循环结束,返回 [rootTr, trA, trB]。
追加事务有两个约束。它必须基于当前 newState 构建,因为随后执行的 newState.applyInner(tr) 会校验 tr.before.eq(this.doc),不匹配时抛出 “Applying a mismatched transaction”。因此 appendTransaction 中应使用 newState.tr 创建事务。追加事务还会设置 tr.setMeta("appendedTransaction", rootTr),指向根事务。prosemirror-history 的 history.ts 据此将追加事务与根事务归入同一撤销分组。
插件顺序在循环里也有实际含义。每一轮都按数组顺序问,排前面的插件先看到当前局面、先追加;它追加之后,后面的插件本轮还没被问过,新事务落在它们的未读区间里,同一轮就能看到。反过来,后面的插件追加时前面的已经问完了,要等下一轮才轮到它们响应。有依赖关系的修正插件,被依赖的一方要往前排。
代码没有迭代次数上限,循环控制依赖 seen 规则。插件不会再次收到已处理的事务,也不会收到自己追加的事务,因此不会由完整历史事务或自身追加反复触发。仍可能出现两个插件相互追加且不停止的情况:A 响应 B 的追加,B 再响应 A 的追加。为避免此类循环,appendTransaction 应先检查条件;状态已满足时返回 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 保存并随事务更新数据,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 中处理 props:
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 通过 src/index.ts 的 someProp 读取 props。未传回调时,它返回第一个非 undefined 的 prop;传入回调时,它按顺序调用每个已定义的 prop,回调返回真值即停止并返回结果。查找顺序为:直接传给 view 的 props(_props)、directPlugins 的 props、state.plugins 的 props。因此同名 prop 有优先级:直接传入的值优先于插件值,靠前插件优先于靠后插件。插件数组顺序还决定事件处理器的调用次序。
事件处理(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 闭包中,并不依赖此机制。额外字段主要可用于自定义代码中的跨插件约定。
结尾
至此,插件规格的字段分工如下:state 保存数据,filterTransaction 和 appendTransaction 过滤或修正事务,view 和 props 负责与 EditorView 交互。下一篇通过三个插件示例分别使用 StateField、filterTransaction 和 view。
