Transaction:Transform 加上状态语义

2 分钟阅读
·

前两篇把 EditorState 和 Selection 看完了。EditorState 那篇提到 state.tr 这个 getter 每次访问都 new 一个 Transaction,dispatch 出去的事务全是它的实例。这篇看 Transaction 类本身,src/transaction.ts,两百行出头。它继承 transform 阶段讲过的 Transform 类(参考代码是 prosemirror-transform 的 662b7a9),文档修改能力全部来自父类,自己加的是状态语义:选区、storedMarks、meta、时间戳,外加一个记录「这个事务动了哪些状态」的位图。参考代码是 prosemirror-state 的 ffad5d9。

系列目录

日期 标题
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 加上状态语义(本篇)

继承来的和新增的

Transform 给的东西在 Transform 类那篇展开过:doc 指向当前文档,stepsdocsmapping 三个平行数组记录已经应用的 step 和位置映射,before 是起点文档,docChanged 判断有没有 step,加上 step() 和 replace、insert、addMark 一批便捷方法。这些 Transaction 原样继承。

Transaction 自己的部分可以画成一张图。

Transaction 在 Transform 之上新增的字段

构造函数标了 @internal,只有四行实质内容:super(state.doc) 把当前文档交给父类,time = Date.now() 记下时间戳,curSelection = state.selectionstoredMarks = 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 的失效规则

storedMarks 的语义在 EditorState 那篇讲过:工具栏点了加粗还没打字,光标处没有字符可以挂 mark,这个意图先寄存在 storedMarks 里,下一次输入时取出来用。

Transaction 上有一组配套方法。setStoredMarks 直接设置并置 UPDATED_MARKS 位。ensureMarks 是底层主力:用 Mark.sameSet 比较目标 marks 和当前集合,当前集合取 storedMarks,为 null 时退回 selection.$from.marks();两边相同就直接返回,不同才调 setStoredMarks。addStoredMarkremoveStoredMark 在当前集合上加减一个 mark 再转调 ensureMarks,它们取当前集合的位置是 selection.$head.marks()storedMarksSet 读位图回答是否显式设过。

关键的一条规则藏在 addStep 的覆写里:

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) 出来的键值表,setMetagetMeta 读写它。用无原型对象是有实际考虑的:getMeta 接受任意字符串键,普通对象上 getMeta("constructor") 这类读法会拿到原型链上的值,无法区分「没设过」和「设成了 undefined」;无原型对象上任何未设置的键都读出 undefined,任何字符串都能安全地当键用。键可以是字符串,也可以是 Plugin 或 PluginKey 实例,后两者取 .key 属性。用插件实例当键是个惯例:键空间天然隔离,两个插件不会互相踩到对方的 meta;跨包约定的字符串键则靠口头协议,比如 "appendedTransaction""addToHistory"

meta 不参与文档和选区的计算,apply 时也不会进 state 的任何内置字段,它只是随事务走一趟的附加信息。为什么需要这么一条通道:插件的 StateField.apply 能拿到的输入只有 (tr, value, oldState, newState),文档和选区的新旧值都在里面,唯独「这个事务想干什么」看不出来。一次删除是用户按了 Backspace、历史插件在 undo、还是协作端在应用远端更新,光对比文档分不出来,而插件更新自己状态时常常正需要这个区分。meta 就是让事务的发起方能给下游插件传话的地方,类注释里写的就是这个意图:描述事务代表什么,让插件据此更新自己的状态。

源码里能看到一串实际的读写方,这也是理解它用途的最快路径。

state.ts 的 applyTransaction 自己就是生产者:插件 appendTransaction 追加出来的事务,会被打上 "appendedTransaction" meta 指向 rootTr,标记「我是被追加的,源头是那个事务」。

用插件实例当键的写法也有现成例子。inputrules 插件(参考代码是 prosemirror-inputrules 的 e3e5545)的 StateField.apply 第一行就是 tr.getMeta(this)。写入方在 run 函数里:规则匹配、handler 返回事务之后,只要规则标了 undoable,run 就 tr.setMeta(plugin, {transform, from, to, text}),把这次匹配产生的事务和原始输入暂存在事务上,随 dispatch 一起提交。键就是插件实例自己,不存在和别人撞名的可能。这份暂存是给 undoInputRule 命令用的:规则刚展开就触发它,undoInputRule 把暂存事务里的 step 逐个 invert 撤销,再把用户实际敲进去的原始文本放回去。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" 之一。对照 view 包(参考代码是 prosemirror-view 的 ca4c78e)能找到出处:src/domchange.tsorigin == "pointer" 时 setMeta(“pointer”, true),组合输入期间 setMeta(“composition”, compositionID);src/input.ts 的粘贴、剪切、drop 分支各自设置 uiEvent。history 的分组逻辑读 "composition",就是为了把同一次 IME 组合输入拆出来的多个事务归到同一组,撤销时一次退掉整个词。

isGeneric getter 回答 meta 是否为空,空意味着这个事务没有附加任何特殊含义,可以安全地继续往里追加操作。commands 包(参考代码是 prosemirror-commands 的 52a84a8)的 autoJoin 用了它。autoJoin 把一个命令(典型是删除类命令)的 dispatch 包成 wrapDispatchForJoin:被包装的命令先发只删内容的事务,wrapDispatchForJoin 检查 isGeneric,为真才在同一个事务上扫一遍修改范围,把能 join 的相邻同类型节点追加 join 掉;meta 不为空说明事务带着特定含义,追加操作会混淆语义,就原样 dispatch 出去。

time 字段与撤销分组

time 在构造时取 Date.now()setTime 允许改写。它的消费方是 history 的分组判断:配置项 newGroupDelay 默认 500 毫秒,当前事务的 tr.time 与上一组记录的时间差超过阈值,或者修改范围和上一组不相邻,就开一个新的撤销组。效果是连续敲字合并成一次撤销,停顿超过半秒再敲就成了另一次。这个行为完全由 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 和各插件不用 diff 前后值就能拿到答案,成本是一次位与。

selectionSet 的消费方能在扩展包里看到。inputrules 的 StateField.apply 里有一句 tr.selectionSet || tr.docChanged ? null : prev:选区被显式移动或文档变了,就丢弃暂存的规则匹配状态,否则原样保留。search 插件同样判断 tr.docChanged || tr.selectionSet,命中才重新映射搜索结果的区间。这些判断都建立在「事务自己报告动了什么」上,插件省掉了保存旧值再逐一对比的麻烦。

便捷方法与 marks 的继承

replaceSelectionreplaceSelectionWithdeleteSelection 都是转调 Selection 上的 replace 和 replaceWith,选区自己知道怎么替换自己,这是 Selection 那篇讲的设计。replaceSelectionWith 多了个 inheritMarks 参数,默认 true:插入 inline 内容时继承插入位置的 marks,优先级是 storedMarks 在前,选区为空取 selection.$from.marks(),非空取 $from.marksAcross($to) 跨过整个选区收集。

insertText 是输入路径的主力,值得按分支看。只传 text 时,空串等价于 deleteSelection,否则走 replaceSelectionWith 加 marks 继承。传了 from 和 to 时,先确定 marks:storedMarks 优先,没有就 resolve from 位置,from 等于 to 取该位置的 marks(),有范围就 marksAcross;然后 replaceRangeWith 替换。末尾还有一个选区修正:当前选区非空且选区末尾恰好等于插入文本的末尾时,调 Selection.near(this.selection.$to) 把选区贴到新文本边上,避免替换后选区悬在奇怪的位置。

marks 来源的优先级贯穿这两个方法:storedMarks 代表的显式意图在前,位置上下文推断在后。这和 storedMarks 的设计目标一致,用户刚点过加粗,接下来的输入就该是加粗,不管光标停在什么格式的文本中间。

一个输入事务的完整形状

把前面几节串起来,看敲一个字符时事务走过的路径。view 层从 DOM 读回变化后,在 state.tr 领一个事务,curSelection 是当前光标,storedMarks 带着之前点过的格式。调 insertText("字"),选区为空时走 replaceSelectionWith 分支:先用 storedMarks 给新文本节点挂上格式,再让选区 replaceWith 自己,产生的 step 经 step() 落到 addStep。addStep 的覆写随即将 storedMarks 清成 null、抹掉 UPDATED_MARKS 位,这份意图刚被消费进文本,不该留到下一次输入。需要滚动的调用方再调一次 scrollIntoView 置上 SCROLL 位,然后把事务交给 dispatch。

apply 时四层语义各就各位:doc 字段取 tr.doc;selection 字段取 tr.selection,惰性映射在这一刻结算;storedMarks 字段读 tr.storedMarks,已经是 null;scrollToSelection 计数器加一,view 更新时看到它变大,把新光标滚动进可视区。history 插件的 StateField.apply 读 meta,没有 "addToHistory": false 就把事务收进撤销栈,tr.time 和上一组的时间差决定要不要开新组。一个字符的输入,四层状态语义全部被用到一遍。

本篇小结

Transaction 在 Transform 之上加了四层状态语义。选区用 curSelectionFor 做惰性增量映射,setSelection 显式设置时作废旧意图;storedMarks 记录格式意图,任何 step 或显式换选区都会清空它,留意图必须在加完 step 之后;meta 是插件和核心之间的通信通道,history 的入栈控制、view 的 pointer 和 uiEvent 标记、state 的 appendedTransaction 标记都走它,isGeneric 供命令判断事务能否继续追加;time 驱动 history 的撤销分组,updated 位图让「动了什么」成为一次位运算。下一篇进入插件系统,看 StateField 怎么在 apply 里消费这些信息,Plugin 和 PluginKey 三件套怎么组织。


960 字 · 44 段落
xi ming

Written by xi mingFollow onGitHub