EditorStateTransaction(Step / Mapping / Selection / Command)

返回 🗂 富文本编辑专题 · 相关私有笔记

ProseMirror 把「编辑器在某一刻的全部状态」收敛成一个不可变对象 EditorState,把「一次状态变更」收敛成一个 Transaction。本文只回答一件事:给定旧 state 和一条已经构造好的 transaction,state.apply(tr) 如何得到新 state。由 EditorView 接收输入、派发 transaction 并同步 DOM 的完整闭环,统一在 View 与 NodeView 解释。

文档树本身的值模型和位置模型见 PM 核心模型;本文只在需要时回链到相应细节。所有状态变更都应落在 PM Schema 定义的文档语言内。

阅读边界:State 负责「新状态如何产生」

阅读本篇前,先知道 doc 是 Schema 约束的文档值,位置是文档坐标;这两件事分别见 PM 核心模型PM Schema

本篇的首读顺序是:

  1. EditorState 快照里保存什么
  2. Transaction 如何构造,并由 state.apply(tr) 落成下一份快照
  3. Step / Mapping 为什么让变更与位置能被重放
  4. Selection 与 Command 如何使用这条状态变更通道

刻意不在这里展开 view.dispatchdispatchTransaction、DOM diff、输入事件、IME 或 NodeView。它们属于 View;Plugin 的事务钩子属于进阶扩展,见 Plugin 与 Decoration

EditorState:不可变的状态中心

EditorState 是编辑器某一时刻的完整快照,它聚合了若干字段:

  • doc:Node,当前文档树。
  • selection:Selection,光标 / 选区。
  • storedMarks:Mark[] | null,下一次输入的内容将带上的标记(注意不是当前选区的标记)。
  • schema:Schema,文档模式。
  • plugins:readonly Plugin[],当前激活的插件,以及每个插件各自的私有 state field。

关键性质是:EditorState 是不可变的。任何一次编辑都不会原地修改它,而是产生一个全新的 EditorState 对象。这种「从旧 state 计算出新 state」的范式,让每个状态都能被独立持有、对比与恢复。

import { EditorState } from "prosemirror-state";
 
// 创建初始状态(schema 与 doc 至少给其一,其余可选)
const state = EditorState.create({
  schema,
  // doc?, selection?, storedMarks?, plugins?
});

为什么用不可变模型

不可变不是为了「优雅」,而是为了换来四个具体能力,这也是整篇笔记的主线:

  1. 可预测性:状态没有副作用,变更即函数 state → state'。给定相同输入永远得到相同结果,渲染层只需对比「旧 state / 新 state」即可做受控更新(见 View 与 NodeView)。
  2. 可追踪:每个 state 都能被精确序列化与恢复,变更历史是一串明确的离散转移。
  3. 协同友好:多个客户端可以对相同的状态转移序列做重排 / 重放,最终收敛到一致状态。
  4. 可插件化拦截:插件能在 transaction 真正落地前介入修改,而不必担心读到被破坏的中间态。

一句话:PM 的核心原则是「你的代码对文档及其变化拥有完全控制权」,而不可变状态 + 显式事务正是实现这种「完全控制」的形式。

Transaction:可变构建,经 apply 落成新状态

Transaction 表示一次对 state 的变更。先把 Transform 理解为“只描述文档怎么变”的构建器;Transaction 在它之上再带上 selection、storedMarks、metadata 和时间,所以它是 Transform 的子类。它有意做成“两副面孔”:

  • 构建期可变:提供 fluent API,可链式调用 .step().delete().insertText().setSelection().setStoredMarks().setMeta() 等,这些方法返回 this 以便链式书写。
  • 落地后视为不可变:一旦经由 state.apply(tr) 应用,它就成为历史记录的一部分。技术上它仍可被修改,但应当当作不可变——已应用的事务不要再改。

Transaction 携带的信息:steps(原子变更列表)、映射后的 selectionstoredMarks、任意 metadata(通过 setMeta / getMeta 读写)、time(时间戳,与 Date.now() 同格式)。

// state.tr 返回一个基于当前 state 的空 transaction
const tr = state.tr;
 
tr.delete(6, 8)           // 删除位置 6..8
  .insertText("hello")    // 在当前位置插入文本
 
const newState = state.apply(tr); // 产生全新 state

后面学 Selection 时会看到:除非显式设置,新旧选区会随文档变更自动调整。StepMap 是这件事的底层工具,不必在第一段 transaction 代码里先记住它。

这就是 State 的边界:apply 只从旧 state 和 tr 推导新 state,既不接收 DOM 事件,也不决定何时重画 DOM。构造完成的 tr 交给 view.dispatch(tr) 后,View 才会选择默认或自定义的派发路径并把新 state 同步到 DOM。

为什么把 Transaction 设计成 Transform 的子类

把变更建模为 Transform(只关心文档怎么变)再加一层 Transaction(关心 selection / storedMarks / metadata / 时间),好处在于:

  • 构建便捷:链式 API 适合人写,也适合 Command 组合。
  • 历史可靠:落地即冻结语义,使它能直接进入 undo 历史或被发往协同服务端。
  • 可扩展:metadata 让插件 / 上层应用塞入额外语义(例如标记 "pointer": true 表示这是鼠标操作),而不污染核心字段。
  • 可序列化:一个 transaction 的核心信息(主要是它的 steps)能被保存,用于重放或协同。

Step:最小、可序列化、可反演、可重放的变更单位

Step 是 PM 中最小的原子变更。常见子类:ReplaceStep(替换 / 插入 / 删除内容)、ReplaceAroundStepAddMarkStepRemoveMarkStep 等。一个 Transactionsteps 就是一串这样的原子操作。

Step 有三个互相支撑的关键性质:

  1. 可序列化:step.toJSON() / Step.fromJSON(schema, json),内置 jsonID 注册机制(Step.jsonID(id, stepClass)),使任意 Step 子类都能往返序列化。
  2. 可反演:step.invert(oldDoc) 返回一个反向 Step——这是高效 undo 的基础。
  3. 可重放:step.apply(doc) 返回 StepResult(含新 doc 与本步的 StepMap),且对同一初始 doc 确定性可重放,可在任意客户端重新应用。
import { ReplaceStep, Step } from "prosemirror-transform";
import { Slice } from "prosemirror-model";
 
const step = new ReplaceStep(2, 5, Slice.empty); // 删除位置 2..5
 
// 应用:返回 StepResult,必须检查 failed
const result = step.apply(oldDoc);
if (result.failed) throw new Error(result.failed);
const newDoc = result.doc;          // 新文档(失败时为 null)
 
// 取得本步引起的位置映射
const map = step.getMap();          // StepMap
 
// 序列化 / 反序列化(网络传输、持久化)
const json = step.toJSON();
const restored = Step.fromJSON(schema, json);
 
// 反演(撤销):对 newDoc 应用反向 step 可回到 oldDoc
const inverted = step.invert(oldDoc);
 
// 重放(协同):对同一初始 doc 顺序应用一串 step
let doc = initialDoc;
for (const s of steps) {
  const r = s.apply(doc);
  if (!r.failed) doc = r.doc;
}

主要 API:apply(doc) → StepResultinvert(oldDoc) → Stepmap(mapping) → Step | nullgetMap() → StepMapmerge(other) → Step | null

为什么编辑要拆成 Step

把变更拆成可序列化 / 可反演 / 可重放的最小单位,直接换来 PM 的几项核心能力:

  • 撤销不需要快照:undo 不必保存整篇文档的副本,只需保存反向 Step 序列。可恢复到任意历史点,内存开销远小于「每步存一份 doc」。
  • 协同基于 Step 而非全量:并发编辑时,各客户端可以对 Step 序列重排,并把相同的 Step 应用到相同的 doc,最终收敛。这正是 OT(Operational Transformation)型协同的内核(详见 协同编辑)。
  • 可组合:多个 Step 串在一个 Transform 中,后续 Step 的位置会自动经前面 Step 的 map 调整,无需手工算偏移。

Mapping 与 StepMap:把旧位置映射到新位置

这是整套模型里最容易被低估、却最关键的数据结构。

每个 Step 应用后会产生一个 StepMap,负责把旧文档中的位置映射到变更后文档中的位置Mapping 则是多个 StepMap 的有序组合容器,用来管理一连串变更累积出的位置关系(两者都实现 Mappable 接口)。

// StepMap.map(pos, assoc?) —— assoc 控制插入边界两侧的归属
const map = step.getMap();
 
// 例:在位置 5 插入 "hello"
map.map(5, -1); // assoc < 0:停留在插入点之前 → 5
map.map(5, 1);  // assoc > 0(默认 1):跟随到插入点之后 → 10

assoc 参数决定「正好落在变更边界的位置」往哪一侧走:< 0 偏向变更前一侧,> 0(默认 1)偏向变更后一侧。这个看似细枝末节的参数,在协同冲突解决里决定了两个并发插入谁排在前。

Transaction 中的 selection 默认会自动穿过每个 Step 的 StepMap 被映射(除非你显式 setSelection)。这就是为什么前面例子里 tr.delete(6,8)tr.selection 不需要你手动修正。

// 重基(rebase)直观示例:两个客户端同时在位置 5 插入
const map1 = tr1.steps[0].getMap();           // tr1 先落地,产生 map1
const step2Adjusted = tr2.steps[0].map(map1); // 用 map1 把 tr2 的 step 重新定位
// step2Adjusted 现在会落在正确的位置(被 map1 推移之后);可能为 null(被删除范围吞掉)

主要 API:StepMap.map(pos, assoc?) → numberStepMap.invert() → StepMapStepMap.forEach((oldStart, oldEnd, newStart, newEnd) => …);Mapping 用于 rebase 这类需要把一组变更穿过另一组变更的场景。

为什么 Mapping 是撤销与协同的基石

文档一变,所有「指向旧位置的东西」——光标、选区、Decoration、插件里记录的位置——都会失效。Mapping 的作用就是让这些引用自动跟随文档变化:

  • 位置无需手工维护:Selection 自动映射,Decoration 也通过 mapping 跟随(见 Plugin 与 Decoration)。
  • 支持 rebase:两个并发变更可以通过 mapping 互相穿过、重新定位,这是中心化 rebasing 与 CRDT/OT 协同的共同前提。
  • 省内存:相比保存整份文档状态,只需保存 Step + 它的 StepMap,即可精确复原位置关系。

注意:StepMap 是单向的(沿文档变更方向)。要做反向映射(新位置 → 旧位置),需显式 StepMap.invert()

Selection:三种选区类型

Selection 是抽象基类,代表光标 / 选区。它持有 $from / $to(ResolvedPos,含路径信息,不要from / to 这两个整数位置混淆——后者见 PM 核心模型)。三个主要实现:

  • TextSelection:文本范围,由 anchor / head 界定(from / to 为其规范化后的端点);from === to 即为光标。覆盖绝大多数文本编辑。
  • NodeSelection:整体选中一个节点(如图片、表格、块级元素),通过 node 属性拿到被选节点。它选中的是单个节点,不表达跨多个节点的范围。
  • AllSelection:选中整个文档的特殊选区,用于文档以「不可进入的叶节点」开头 / 结尾这类边界情况。
import { TextSelection, NodeSelection, AllSelection } from "prosemirror-state";
 
const textSel  = TextSelection.create(doc, 5);      // 光标在 5
const textSel2 = TextSelection.create(doc, 5, 10);  // 选中 5..10(anchor, head)
 
if (NodeSelection.isSelectable(imageNode)) {        // 必须先校验
  const nodeSel = NodeSelection.create(doc, imagePos);
}
 
const allSel = new AllSelection(doc);               // 全选

主要 API:TextSelection.create(doc, anchor, head?)NodeSelection.create(doc, from)NodeSelection.isSelectable(node) → booleannew AllSelection(doc)Selection.atEnd(doc) → SelectionSelection.fromJSON(doc, json);自定义 Selection 子类用 Selection.jsonID(id, cls) 注册以支持反序列化。

为什么分三种

三种类型对应三种不同的编辑语义,既是为了表达力,也是为了维护文档有效性:TextSelection 服务连续文本范围;NodeSelection 让「选中并整体操作一个节点」成为一等公民,避免嵌套节点被局部破坏;AllSelection 兜住边界场景。isSelectable() 则确保用户只能把整个节点选成 NodeSelection(节点需 spec 中标记 selectable)。

Command:(state, dispatch?, view?) => boolean 约定

PM 用统一的函数签名编码「一个可编辑操作」(删除选区、加粗、插入列表等):

type Command = (
  state: EditorState,
  dispatch?: (tr: Transaction) => void,
  view?: EditorView
) => boolean;

核心语义只有三条,却极其重要:

  1. dispatch 可选:当 dispatch 未提供时,command 必须「不产生副作用,只返回布尔」。
  2. 探测 vs 执行:不传 dispatch 用于探测命令是否可用;传 dispatch 才真正构建并提交 transaction。
  3. 返回值:true 表示「已执行 / 可执行」,false 表示「当前不可用」。
// 官方范式:有副作用的部分全部包在 if (dispatch) 里
function deleteSelection(state: EditorState, dispatch?: (tr: Transaction) => void) {
  if (state.selection.empty) return false;      // 不可用
  if (dispatch) dispatch(state.tr.deleteSelection());
  return true;
}
 
// 用法 1:探测(不传 dispatch)→ 决定菜单按钮是否灰显
const canDelete = deleteSelection(view.state);
 
// 用法 2:执行(传 dispatch)
deleteSelection(view.state, (tr) => view.dispatch(tr));

为什么用「dispatch 可选」来做 can()

这是一个很省事的 API 设计:它用同一个函数同时支持「能不能做(can())」与「去做(do())」两种用法,避免了常见的「写两套并行 API」的冗余与不一致。

  • 无副作用探测:cmd(state) 不传 dispatch,即可查询适用性,绝不改动状态。
  • UI 与状态天然同步:工具栏 / 快捷键可用 cmd(state) 决定按钮启用与否,完全跟随编辑器实际状态。
  • 可被分层拦截:command 产出的 transaction 会经过 view.dispatchdispatchTransaction,插件可在其前后介入。

TipTap 的 editor.can() 链与命令系统正是建立在这条约定之上(见 TipTap 架构TipTap 实战)。

交接给 EditorView:派发与 DOM 同步

从 State 的角度,完整闭环到此为止:命令或输入规则构造 trstate.apply(tr) 推导下一份快照。谁调用 apply、是否先交给应用状态层、何时调用 updateState,以及浏览器输入如何成为 transaction,都是 View 的职责。

dispatchdispatchTransactionupdateState 当成一组理解:它们的唯一权威说明、默认与手动派发路径、以及 DOM/IME 同步都在 View 与 NodeView。不要在 State 层复制一套派发流程。

进阶交接:state.applyTransaction(rootTr) 会让 Plugin 的 filterTransaction / appendTransaction 参与一批事务的最终结果。首次阅读可先跳过;需要事务门禁或补充事务时,再读 Plugin 与 Decoration 的进阶 hooks。

常见坑

  • 不要原地改 state / 已应用的 transaction:务必走「创建新 state / 新 tr」的路径,把已落地的事务当作不可变历史。
  • Step.apply() 返回 StepResult 而非 doc:必须判断 result.failed(失败时为错误字符串,result.docnull),不能直接当成新 doc 用。
  • StepMap 是单向的:反向映射需显式 invert()
  • $from / $to(ResolvedPos)≠ from / to(整数):前者含路径信息,别混用。
  • Command 给了 dispatch 就必须调用,否则返回 false:既不提交事务又返回 true,会让 UI 状态与实际状态脱节。
  • storedMarks 是下一次输入的标记,不是当前选区的标记:选区一变就可能被重置。
  • Step.map(mapping) 可能返回 null:当该 step 被另一组变更完全吞掉(如落在被删除范围内)时,重定位结果为 null,需判空。
  • 多 Step 的位置映射是累积的:Transform 中每个 step 的 map 都会改变后续 step 的位置参考,手工传位置时务必意识到这一点。
  • state.apply(tr) 不会更新 DOM:它只生成下一份 state;把 transaction 派发、把 state 同步到 DOM,交给 View。

要点回顾

  • EditorState 的不可变设计是 PM 支撑协同、撤销/重做与可预测插件系统的根本基础:每次编辑产生全新 state,无副作用。
  • Transaction 作为 Transform 子类,兼具构建期的可变 fluent API 与落地后的不可变历史;单一 transaction 可序列化、传输、重放、反演,是协同的最小单位。
  • Step 的可序列化 / 可反演 / 可重放,直接实现了 OT 型协同与「无需快照」的高效撤销。
  • Mapping / StepMap 是协同与撤销中最核心的数据结构:自动把旧位置映射到新位置,让 Selection、Decoration、插件状态无缝跟随文档变化。
  • Command 的「dispatch 可选」约定优雅地用一个函数同时解决了 can-do / do 两套 API。
  • State 的边界是 state.apply(tr)EditorView 负责派发 transaction 并把下一份 state 同步到 DOM,Plugin 则负责进阶的事务 hooks。
  • 一句话总结事务式不可变模型的四大优势:可预测(无副作用)、天然支持撤销(inverted steps)、可重放与协同(step / mapping)、可向 View 和 Plugin 提供稳定的状态交接点。

参考