前两篇介绍了 EditorState 和 Selection。EditorState 中的 state.tr getter 每次访问都会创建一个 Transaction,dispatch 的事务均由此创建。本文分析 Transaction 类本身,位于 src/transaction.ts,文件两百行出头。它继承 transform 阶段介绍的 Transform 类(参考代码是 prosemirror-transform 的 662b7a9),文档修改能力来自父类;它增加选区、storedMarks、meta、时间戳,以及记录事务修改状态的位图。参考代码是 prosemirror-state 的 ffad5d9。
系列目录
继承来的和新增的
Transform 的内容在 Transform 类一篇展开过:doc 指向当前文档,steps、docs、mapping 三个平行数组记录已应用的 step 和位置映射,before 是起点文档,docChanged 判断是否存在 step,另有 step() 以及 replace、insert、addMark 等便捷方法。Transaction 原样继承这些成员。
下图列出 Transaction 新增的字段。
构造函数标记为 @internal,实质上执行四项操作:super(state.doc) 将当前文档传给父类,time = Date.now() 记录时间戳,curSelection = state.selection 和 storedMarks = state.storedMarks 复制当前 state 的选区和 storedMarks。事务创建时包含 state 的文档、选区和格式意图。外部通过 state.tr 创建事务,插件和命令也遵循这一入口。
Transform 的 step() 方法先计算 step 的应用结果,成功后调用 addStep(step, result.doc) 记录结果;replace、insert、addMark 等便捷方法最终都会调用 step()。Transaction 覆写 addStep(在 storedMarks 一节说明),因此可以在每个修改被记录时执行处理,而不必覆写每个便捷方法。
文件开头还定义了 Command 类型:(state, dispatch?, view?) => boolean,返回 false 表示命令不适用,接收到 dispatch 时执行操作。这个签名是 commands 包的约定,本文只说明其定义,后续 commands 一篇会展开。
选区的惰性映射
事务持续添加 step 时,文档会变化,选区需要跟随映射。Transaction 使用惰性映射,selection 是一个 getter:
get selection(): Selection {
if (this.curSelectionFor < this.steps.length) {
this.curSelection = this.curSelection.map(this.doc, this.mapping.slice(this.curSelectionFor))
this.curSelectionFor = this.steps.length
}
return this.curSelection
}curSelectionFor 记录 curSelection 已对前多少个 step 生效。getter 发现它小于 steps.length 时,使用 mapping.slice(curSelectionFor) 映射新增部分。Mapping.slice 返回只包含后半段 StepMap 的新 Mapping。根据 Mapping 一篇介绍的规则,位置只需经过其后发生的 step 映射;再次映射前面的 step 会使用错误的文档版本。添加 3 个 step 后读取 selection,需要执行 3 步映射;再添加 2 个 step 后读取,只执行 2 步。中间不读取选区时不会执行映射。频繁添加 step、较少读取选区时,该策略避免了每一步都映射的开销。
setSelection 用于显式设置选区。它先校验 selection.$from.doc == this.doc,选区必须指向事务当前的文档;传入基于旧文档的选区会抛出 RangeError,这与 state 一篇中的 tr.before 校验属于同类防护。设置时会置 UPDATED_SEL 位、清除 UPDATED_MARKS 位,并将 storedMarks 设为 null。选区变化后,storedMarks 对应的输入位置也随之变化;继续保留会将旧位置的格式应用到新位置。selectionSet getter 读取 UPDATED_SEL 位,表示事务是否显式修改过选区。
storedMarks 的失效规则
EditorState 一篇已说明 storedMarks 的语义:工具栏点加粗后尚未输入时,光标处没有可挂载 mark 的字符,格式意图保存在 storedMarks 中,下一次输入时使用。
Transaction 有一组配套方法。setStoredMarks 直接设置该值并置 UPDATED_MARKS 位。ensureMarks 使用 Mark.sameSet 比较目标 marks 和当前集合;当前集合取 storedMarks,为 null 时取 selection.$from.marks()。两边相同时直接返回,不同时调用 setStoredMarks。addStoredMark 和 removeStoredMark 在当前集合上增减一个 mark 后调用 ensureMarks,它们从 selection.$head.marks() 获取当前集合。storedMarksSet 通过位图表示是否已显式设置。
addStep 的覆写定义了 storedMarks 的失效规则:
addStep(step: Step, doc: Node) {
super.addStep(step, doc)
this.updated = this.updated & ~UPDATED_MARKS
this.storedMarks = null
}Transform 的 step() 成功后会通过 addStep 记录结果。Transaction 在记录每个 step 时都会清除 storedMarks:文档改变后,原有格式意图立即失效。原因与 setSelection 清除它相同:storedMarks 表示某个位置上下文的格式意图,文档改变后,该上下文可能不再存在。命令若要为下一次输入设置格式,必须在添加所有 step 后调用 setStoredMarks,否则该值会被清除。
state 侧消费 storedMarks 时还会过滤。state.ts 的 baseFields 中,storedMarks 字段的 apply 并非无条件读取 tr.storedMarks:只有新选区是光标型选区(TextSelection 存在 $cursor)时才保留,否则返回 null。storedMarks 对应光标处的输入意图,范围选区没有单一光标位置。因此,storedMarks 会先在事务中因任何 step 被清除,再在 apply 时因非光标选区被清除。
meta:插件通信的公共通道
meta 是由 Object.create(null) 创建的键值表,setMeta 和 getMeta 读写它。无原型对象避免了普通对象的原型链影响:getMeta 接收任意字符串键,普通对象中 getMeta("constructor") 会读取原型链上的值,无法区分「未设置」和「设为 undefined」;无原型对象中,未设置的键均返回 undefined,任意字符串都可安全作为键。键可以是字符串,也可以是 Plugin 或 PluginKey 实例,后两者使用 .key 属性。使用插件实例作为键可隔离键空间,避免两个插件覆盖对方的 meta;跨包约定的字符串键则依赖约定,例如 "appendedTransaction"、"addToHistory"。
meta 不参与文档和选区的计算,apply 时也不会写入 state 的内置字段;它是随事务传递的附加信息。插件的 StateField.apply 只接收 (tr, value, oldState, newState),可以访问文档和选区的新旧值,却无法仅据此判断事务的来源或目的。用户按 Backspace、历史插件执行 undo、协作端应用远端更新都可能产生删除,文档差异无法区分这些来源,而插件更新状态时可能需要该信息。meta 允许事务发起方描述事务含义,供下游插件据此更新状态。
源码中的读写方说明了 meta 的用途。
state.ts 的 applyTransaction 会为插件 appendTransaction 追加的事务设置 "appendedTransaction" meta,并使其指向 rootTr,标明该事务由 rootTr 派生。
inputrules 插件(参考代码是 prosemirror-inputrules 的 e3e5545)展示了以插件实例作为键的用法。它的 StateField.apply 首先调用 tr.getMeta(this)。run 函数在规则匹配、handler 返回事务后,若规则标为 undoable,就调用 tr.setMeta(plugin, {transform, from, to, text}),将本次匹配生成的事务和原始输入保存在事务中,并随 dispatch 提交。该信息由 undoInputRule 命令使用:规则展开后执行该命令时,undoInputRule 逐个 invert 暂存事务中的 step,再插回用户实际输入的原始文本。search 插件(参考代码是 prosemirror-search 的 647a36f)使用自己的 PluginKey,tr.getMeta(searchKey) 读取调用方设置的新查询。
history 插件(参考代码是 prosemirror-history 的 445409b)在 StateField.apply 中读取多种 meta:historyKey 表示 undo/redo 指令事务;closeHistoryKey 用于关闭当前事件组;"addToHistory" === false 使事务跳过撤销栈,光标移动和协作远端更新等不应撤销的操作会设置它;"rebased" 由 collab 模块通知历史栈已重定基位置;"appendedTransaction" 与 "composition" 参与撤销分组判断。这些键分布在三个包中,通过 meta 传递信息。
view 层也会写入 meta。transaction.ts 顶部的类注释列出三种:鼠标或触摸直接引起的选区事务带有 "pointer": true;IME 组合输入引起的事务带有 "composition",值为组合输入 ID;粘贴、剪切和拖拽带有 "uiEvent",值为 "paste"、"cut"、"drop" 之一。prosemirror-view(参考代码是 ca4c78e)中,src/domchange.ts 在 origin == "pointer" 时调用 setMeta(“pointer”, true),组合输入期间调用 setMeta(“composition”, compositionID);src/input.ts 的粘贴、剪切、drop 分支分别设置 uiEvent。history 的分组逻辑读取 "composition",将同一次 IME 组合输入产生的多个事务归为一组,撤销时一并处理。
isGeneric getter 表示 meta 是否为空。meta 为空时,事务不含额外语义,可继续附加操作。prosemirror-commands 的 autoJoin(参考代码是 52a84a8)使用它:autoJoin 将一个命令(通常是删除命令)的 dispatch 包装为 wrapDispatchForJoin。被包装的命令先发出只删除内容的事务;wrapDispatchForJoin 检查 isGeneric,仅在为真时扫描修改范围并追加 join 相邻同类型节点的操作。meta 非空时,追加操作可能混合事务语义,因此直接 dispatch 原事务。
time 字段与撤销分组
time 在构造时取 Date.now(),setTime 可改写它。history 使用该值分组:配置项 newGroupDelay 默认 500 毫秒,当前事务的 tr.time 与上一组记录的时间差超过阈值,或修改范围与上一组不相邻时,会创建新的撤销组。因此连续输入可合并为一次撤销,停顿超过半秒后的输入则属于另一组。setTime 主要用于测试和重放场景,以精确控制分组结果而无需等待。
scrollIntoView 与 updated 位图
scrollIntoView() 仅设置 UPDATED_SCROLL 位,scrolledIntoView 读取该位;实际滚动由 view 层执行。它与 EditorState 一篇中的 scrollToSelection 字段配合:apply 时 tr.scrolledIntoView 为真就将 scrollToSelection 计数器加一;view 的 updateStateInner(prosemirror-view 的 src/index.ts)比较新旧 state 中该字段,值增加时调用 scrollToSelection() 执行滚动。计数器而非布尔值能保留连续两次请求中被状态替换的后一次滚动请求。
UPDATED_SEL、UPDATED_MARKS、UPDATED_SCROLL 三个位保存在 updated 字段中,值分别为 1、2、4。它们记录「这个事务显式修改了哪些状态」,apply 和插件无需比较前后值即可获取这一信息,读取成本是一次位与。
扩展包中可以看到 selectionSet 的消费者。inputrules 的 StateField.apply 有 tr.selectionSet || tr.docChanged ? null : prev:选区被显式移动或文档改变时,丢弃暂存的规则匹配状态,否则保留。search 插件同样判断 tr.docChanged || tr.selectionSet,满足时重新映射搜索结果区间。这些判断基于事务自身记录的修改类型,插件不必保存旧值并逐一比较。
便捷方法与 marks 的继承
replaceSelection、replaceSelectionWith、deleteSelection 都调用 Selection 上的 replace 和 replaceWith,选区类型决定替换逻辑。replaceSelectionWith 额外接收 inheritMarks 参数,默认 true:插入 inline 内容时继承插入位置的 marks,优先使用 storedMarks;选区为空时取 selection.$from.marks(),非空时通过 $from.marksAcross($to) 收集整个选区的 marks。
insertText 是输入路径的主要方法。只传 text 时,空字符串等价于 deleteSelection,否则调用 replaceSelectionWith 并继承 marks。传入 from 和 to 时,先确定 marks:优先使用 storedMarks;没有时解析 from 位置,from 等于 to 时取该位置的 marks(),有范围时取 marksAcross;之后调用 replaceRangeWith。末尾还会修正选区:当前选区非空且选区末尾恰好等于插入文本末尾时,调用 Selection.near(this.selection.$to) 将选区移至新文本边界。
两个方法都遵循相同的 marks 优先级:storedMarks 表示的显式意图优先于位置上下文推断。用户刚设置加粗时,下一次输入会使用粗体格式,即使光标位于其他格式的文本中。
输入字符时的事务流程
输入一个字符时,view 层从 DOM 读取变更后,通过 state.tr 创建事务;curSelection 是当前光标,storedMarks 包含此前设置的格式。调用 insertText("字") 后,空选区会进入 replaceSelectionWith 分支:先使用 storedMarks 为新文本节点设置格式,再调用选区的 replaceWith。产生的 step 经 step() 进入 addStep,Transaction 覆写的 addStep 将 storedMarks 设为 null,并清除 UPDATED_MARKS 位,因为该格式意图已应用到文本。需要滚动时,调用方再调用 scrollIntoView 设置 SCROLL 位,然后将事务交给 dispatch。
apply 时,doc 字段取 tr.doc;selection 字段取 tr.selection,此时会完成惰性映射;storedMarks 字段读取的 tr.storedMarks 已为 null;tr.scrolledIntoView 为真时,scrollToSelection 计数器加一,view 更新后将新光标滚动到可视区。history 插件的 StateField.apply 读取 meta,未设置 "addToHistory": false 时将事务加入撤销栈,tr.time 与上一组的时间差决定是否创建新组。
后续
Transaction 在 Transform 的基础上维护选区、storedMarks、meta、time 和 updated 位图。选区按需映射;文档或选区变化会清除 storedMarks;meta 用于在核心和插件之间传递事务语义;time 用于 history 的撤销分组。下一篇介绍插件系统中 StateField、Plugin 和 PluginKey 的职责。
