EditorState 与 Transaction(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。
本篇的首读顺序是:
EditorState快照里保存什么Transaction如何构造,并由state.apply(tr)落成下一份快照- Step / Mapping 为什么让变更与位置能被重放
- Selection 与 Command 如何使用这条状态变更通道
刻意不在这里展开 view.dispatch、dispatchTransaction、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?
});为什么用不可变模型
不可变不是为了「优雅」,而是为了换来四个具体能力,这也是整篇笔记的主线:
- 可预测性:状态没有副作用,变更即函数
state → state'。给定相同输入永远得到相同结果,渲染层只需对比「旧 state / 新 state」即可做受控更新(见 View 与 NodeView)。 - 可追踪:每个 state 都能被精确序列化与恢复,变更历史是一串明确的离散转移。
- 协同友好:多个客户端可以对相同的状态转移序列做重排 / 重放,最终收敛到一致状态。
- 可插件化拦截:插件能在 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(原子变更列表)、映射后的 selection、storedMarks、任意 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(替换 / 插入 / 删除内容)、ReplaceAroundStep、AddMarkStep、RemoveMarkStep 等。一个 Transaction 的 steps 就是一串这样的原子操作。
Step 有三个互相支撑的关键性质:
- 可序列化:
step.toJSON()/Step.fromJSON(schema, json),内置jsonID注册机制(Step.jsonID(id, stepClass)),使任意 Step 子类都能往返序列化。 - 可反演:
step.invert(oldDoc)返回一个反向 Step——这是高效 undo 的基础。 - 可重放:
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) → StepResult、invert(oldDoc) → Step、map(mapping) → Step | null、getMap() → StepMap、merge(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):跟随到插入点之后 → 10assoc 参数决定「正好落在变更边界的位置」往哪一侧走:< 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?) → number、StepMap.invert() → StepMap、StepMap.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) → boolean、new AllSelection(doc)、Selection.atEnd(doc) → Selection、Selection.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;核心语义只有三条,却极其重要:
dispatch可选:当dispatch未提供时,command 必须「不产生副作用,只返回布尔」。- 探测 vs 执行:不传
dispatch用于探测命令是否可用;传dispatch才真正构建并提交 transaction。 - 返回值:
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.dispatch→dispatchTransaction,插件可在其前后介入。
TipTap 的 editor.can() 链与命令系统正是建立在这条约定之上(见 TipTap 架构 与 TipTap 实战)。
交接给 EditorView:派发与 DOM 同步
从 State 的角度,完整闭环到此为止:命令或输入规则构造 tr,state.apply(tr) 推导下一份快照。谁调用 apply、是否先交给应用状态层、何时调用 updateState,以及浏览器输入如何成为 transaction,都是 View 的职责。
把 dispatch、dispatchTransaction、updateState 当成一组理解:它们的唯一权威说明、默认与手动派发路径、以及 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.doc为null),不能直接当成新 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 提供稳定的状态交接点。
参考
- ProseMirror Guide — State:https://prosemirror.net/docs/guide/#state
- ProseMirror Guide — Transform:https://prosemirror.net/docs/guide/#transform
- ProseMirror Guide — Commands:https://prosemirror.net/docs/guide/#commands
- ProseMirror Guide — View:https://prosemirror.net/docs/guide/#view
- ProseMirror Reference Manual(prosemirror-state / prosemirror-transform):https://prosemirror.net/docs/ref/