一、为什么边界必须"唯一"

先设想一个没有统一变更边界的编辑器:渲染层为了让复选框即时响应,直接改了块的 props;命令层图省事,插入段落时直接 push 进根数组;历史模块为了撤销,自己实现了一套"回放"。短期内都能跑。但三个问题会准时到来:

  1. 撤销无从谈起。撤销的前提是"每一次变更都能被描述成可逆的东西"。散落在四面八方的直接修改,根本没人记录它们发生了什么。
  2. 校验形同虚设。Schema 校验若只在某些入口做,非法数据就会从没设防的入口溜进文档。
  3. 渲染通知不可信。渲染层依赖"变更后有人通知我",而直接修改的路径不保证通知。

ArkBlocks 的解法是把写入收拢到一个类上。TransactionManager 的文档注释里写着一句近乎霸道的话:

TransactionManager is the only class that calls Document mutation methods. All other layers (Command, History, Plugin) go through TransactionManager.

配套的是一条完整的流水线契约:命令构造事务 → 事务管理器校验并应用 → 逆操作进历史 → RendererDiff 给渲染层。任何代码路径——包括撤销自己、包括 AI 写入、包括一次最简单的敲字符——都走这条路,没有 fast path。

二、20 种操作:把"编辑"翻译成离散代数

用户的意图是连续的("我想把这段话拆开"),文档的变更是离散的。事务引擎的类型系统(Operation.ts)就是这两者之间的翻译层,共 20 种操作,分三组:

结构组(13 种)insertBlock / removeBlock / updateBlock / moveBlock / duplicateBlock / replaceBlock / splitBlock / mergeBlock / moveChildren / wrapBlock / unwrapBlock / indent / outdent

行内组(4 种)insertInline / deleteInline / replaceInline / updateProps

遗留组(3 种,只为了兼容 PR-0001 的测试)insertText / deleteText / applyStyle——校验器直接拒绝,执行器再兜一层底(见第八节)。

所有操作是一个 ArkTS 联合类型:

export type Operation =
  | InsertBlockOp | RemoveBlockOp | UpdateBlockOp | MoveBlockOp
  | DuplicateBlockOp | ReplaceBlockOp | SplitBlockOp | MergeBlockOp
  | MoveChildrenOp | WrapBlockOp | UnwrapBlockOp | IndentOp | OutdentOp
  | InsertInlineOp | DeleteInlineOp | ReplaceInlineOp | UpdatePropsOp
  | InsertTextOp | DeleteTextOp | ApplyStyleOp;  // legacy,仅兼容

选择自建操作集而不是复用现成事务库的理由,在架构文档里写得很直白:操作集是有限且稳定的枚举,联合类型让编译器帮我们做完备性检查——每新增一种操作,所有 switch (op.type) 的处理点都会被类型系统点名。这个"有限代数"的另一个好处是逆操作可枚举:20 种操作,每种手写一个求逆规则就够了,不需要通用 diff。

值得单独一提的是 SplitBlockOp 的签名,它藏着一个 undo 的关键决策:

export interface SplitBlockOp {
  type: 'splitBlock';
  blockId: string;      // 前半块:保留原 ID
  offset: number;       // 按 Unicode 码点计的拆分点
  newBlockId: string;   // ★ 预分配的 UUID
  newBlockType: string;
  newBlockProps: Record<string, unknown>;
}

newBlockId 必须由调用方(执行器)在构造操作之前通过 IdGenerator 生成。为什么不能执行时顺手生成?因为撤销拆分时要精确复原——如果逆操作用一个新 ID 合并回去,ID 就变了,挂在原 ID 上的评论、选区、AI 标注全部失效。ID 的确定性必须从创建那一刻就锁定。

三、事务的解剖:数据容器与生命周期管理器分离

Transaction 和 TransactionManager 是两个类,分工刻意拉平:

  • Transaction 是纯数据容器:一个 ID、一个操作数组、一个三态状态机(pending → committed | cancelled)。它不知道历史、不知道渲染、不知道命令。状态不是 pending 时 addOperation 静默丢弃——防止提交后还想往里塞操作。
  • TransactionManager 管全部生命周期begin() 发号、commit() 校验并应用、cancel() 丢弃。

commit() 是整个引擎的心脏,值得完整走读一遍(有删节,评审编号保留):

commit(tx: Transaction): CommitResult {
  if (tx.getState() !== 'pending') throw ...;
  // [CR-CRIT-004] 嵌套守卫必须在任何事件发出之前抬起——
  // 否则 beforeValidate 的处理器里发起的重入提交能溜进来
  if (this.isExecuting) throw new Error(`nested transactions are not permitted [RFC Rule 2]`);
  this.isExecuting = true;

  // [CR-CRIT-004] 用操作列表的快照(slice)而非活引用,
  // 事件处理器无法在校验完成后篡改操作序列
  const ops = tx.getOperations();
  const context = createTransactionContext(tx.id, ops);

  try {
    // ── Phase 1:校验(纯检查,零变更)──
    this.events.emit({ type: 'beforeValidate', context });
    const validationPassed = this.validator.validate(context, this.document, this.schema);
    if (!validationPassed) {
      tx.markCancelled();
      this.events.emit({ type: 'transactionFailed', ... });
      throw context.error;            // 文档保证一个字节没动
    }

    // ── Phase 2:执行(先快照,失败回滚)──
    // [CR-CRIT-001] 执行前快照整棵树,中途抛错则 restore,保证 all-or-nothing
    const snap = this.document.snapshot();
    this.events.emit({ type: 'beforeCommit', context });
    try {
      this.executor.execute(context, this.document);   // 应用操作,同步填充 RendererDiff
      tx.markCommitted();
    } catch (err) {
      this.document.restore(snap);
      tx.markCancelled();
      throw error;
    }
    this.events.emit({ type: 'afterCommit', context });
    return { diff: context.rendererDiff, transactionId: tx.id, operations: ops.slice(), context };
  } finally {
    this.isExecuting = false;
  }
}

五个防御点值得圈出来:

  1. 嵌套守卫的位置isExecuting 检查被刻意放在任何事件发出之前。如果放在 beforeValidate 之后,事件处理器里发起的重入提交就能钻进来。防御的顺序本身就是设计。
  2. 操作序列防篡改:校验用的是 getOperations() 的副本。事件系统是插件将来要用的扩展点,但不能让一个 beforeCommit 处理器偷偷往序列里加操作——校验过的东西和执行的东西必须是同一份。
  3. 快照兜底:校验管道管住了"用户错误"(非法操作在 Phase 1 全被拦下),但拦不住"程序错误"(执行器自身的 bug)。快照 + restore() 是对程序错误的最后防线。
  4. 空事务合法:零操作的事务通过校验、产出 no-op diff、不产生历史条目——这是 SPEC 里明确的边界情形,而不是巧合。
  5. 事务状态机只进不退pending → committed 或 pending → cancelled,没有第三条路,commit 一个已提交的事务直接抛错。

四、五阶段校验管道:为什么不是一道 if

校验不是一个大函数,而是五个独立阶段的管道(TransactionValidator.ts):

阶段 职责 典型拦截
1 structural 事务可提交性 状态检查(空事务放行)
2 schema 类型契约(B1/B2/B5) 未注册类型、非法 props、none 模型带内容、拆分目标类型未注册
3 operation 单操作前置条件 块不存在、删最后根块(S1)、行内偏移越界、合并块不相邻、缩进无前兄弟
4 index 跨操作 ID 碰撞 同一事务里两个操作插入同一个 ID
5 finalApproval 扩展钩子 恒通过,为插件否决/协同冲突检测预留

两条管道规则:阶段失败立即停——第三阶段不会在第二阶段失败后继续跑,防止级联错误把根因淹没在错误堆里;管道可扩展——addStage() 允许插件级校验(比如权限检查)追加在内置阶段之后。

第四阶段最容易被误解,值得单独解释。单操作的 validateXxx() 每次只看得到校验时刻的文档状态,看不到同一事务里前序操作的执行效果。设想一个事务里连续两个操作:先删块 A,再插入一个 ID 恰好也叫 A 的块——单看第二个操作,文档里 A 已不存在,预检通过;单看第一个操作也没问题。但两个操作的效果叠加起来就违反了唯一性。indexValidationStage 专门收集"本事务内所有操作将插入的 ID"做集合比对,堵住这类事务内自碰撞。这是"事务是原子单位"落到校验层的必然推论:校验粒度必须和变更粒度一致

五、逆操作:必须在"应用前"生成

撤销的质量完全取决于逆操作的质量。OperationInverter 的头注释里有一条铁律:

This method must be called before the operation is applied to the document, so that the document's current state can be captured.

原因:好几种操作的逆需要前状态快照removeBlock 的逆是 insertBlock,但插回去的必须是删除前的完整子树——应用之后原块已经没了,后补快照为时已晚。所以时序固定为:先对所有操作求逆(此时文档还是旧状态),再应用操作,逆操作进历史

几个求逆规则的设计细节:

规则一:updateBlock 的逆只捕获被改的键。 补丁改了 level,逆补丁就只带 level 的旧值——不整块快照,历史条目保持最小。

规则二:removeBlock 的逆要计算"原位置"。 光有子树快照不够,还得知道插回哪里。computeOriginalInsertPosition 用邻居表达位置:有前兄弟就 after 前兄弟,没有就 firstChild / firstRoot——绝不存下标,因为撤销时数组长度早变了,下标是最不可靠的位置表示。

规则三:split 和 merge 互为逆。 拆分的逆是"把新块合并回去"(mergeBlocksplitOffset 用原 offset);合并的逆是"按记录的 splitOffset 拆开"(splitBlock,复用被吃块的 ID)。这就是第三节 newBlockId 预分配的回报:拆了又撤,ID 分毫不差。

规则四(本篇最漂亮的技巧):replaceBlock 的逆不先删后插。 替换是"一个块换成 N 个块",朴素的逆是"删掉 N 个、插回 1 个"。但这有个隐蔽的坑:如果被替换的恰好是最后一个根块,撤销时"先删 N 个"会让根列表瞬间为空——违反不变量 S1,哪怕只存在一个瞬间。实现让逆操作仍然是 replaceBlock:用原块替换第一个替换块,剩余 N−1 个替换块由 invertAll 附加 removeBlock 处理。根列表从非空到非空,S1 全程无隙:

// invertAll:逆序遍历;replaceBlock 除主逆操作外,为第 2..N 个替换块补删除
for (const op of ops.slice().reverse()) {
  if (op.type === 'replaceBlock') {
    for (let i = 1; i < op.replacements.length; i++) {
      inverses.push({ type: 'removeBlock', id: op.replacements[i].id });
    }
  }
  inverses.push(OperationInverter.invert(op, document));  // 主逆:replace 回原块
}

诚实披露:并非所有 20 种操作都有了完整的逆。duplicateBlock 的逆目前是占位符(克隆块的 ID 执行时才生成,逆操作里只能放 __duplicate_of_xxx__ 标记),moveChildren 的逆也是 TODO(M4)。代码里明明白白注释了这一点,历史子系统(PR-0004)接手补全。架构文的义务是把"已完成"和"已规划"分开写清楚。

六、执行器:一行校验都不做

TransactionExecutor 的头注释是一份漂亮的职责排除清单:不校验(那是 Validator 的事)、不记历史(History 的事)、不更新 UI(渲染层读 diff)、不碰 IME(适配层的事)。它只做两件事:按序应用操作、顺手填充 RendererDiff

"顺手填充"是个关键设计:diff 不是执行完之后再 diff 一遍得出来的,而是应用操作的副产品。插入一个带子树的块,执行器在应用的同时把整棵子树的 ID 收进 insertedIds;删除则先收整棵子树再删。渲染层拿到的永远是精确受影响集合,不需要自己算:

case 'insertBlock': {
  const id = document.insertBlock(op.block, op.position);
  diff.insertedIds.push(id);
  this.collectDescendantIds(id, document, diff.insertedIds);  // 子树整族入 diff
  break;
}

七、mergeBlock 的实现选择:只用公开原语

合并两个块(Backspace 在块首按下时的行为)内部要做三件事:合并行内内容、迁移被吃块的子块、删掉被吃块。前两件事都有"捷径"——直接操作内部数组。但实现选择绕远路:

// applyMergeBlock —— 只用 Document 的公开 API
// 1. 合并行内内容
survivor.content = mergeInlineContent(survivor.content!, consumed.content!);
// 2. 逆序把被吃块的子块移到它前面('before' 保持原顺序)
for (let i = consumedChildren.length - 1; i >= 0; i--) {
  document.moveBlock(consumedChildren[i].id, { referenceId: op.consumedId, placement: 'before' });
}
// 3. 删除已无子块的被吃块
document.removeBlock(op.consumedId);

代价是 O(c×s)(c = 子块数,s = 插入点兄弟数)而不是一次 O(1) 的数组搬运。换来的是:复合操作完全建立在 Document 公开原语之上,索引一致性由 Document 自己保证,执行器不需要理解内部索引的维护规则。注释里写明了这个取舍——"avoids exposing internal index APIs"。这是内部分层纪律的一个样本:连框架内部的复合操作,也不因为"反正都是自己人"就去抄近道。同类的还有 wrapBlock(先在目标前插 wrapper,再把目标 moveBlock 进去当首子)和 indent/outdent(分别退化为一次 moveBlock:挪进前兄弟的 lastChild、挪到父块之后)。

八、行内内容运算:码点与规范形

行内操作(打字、删字、加粗一段文字)的底层是三种纯函数:partitionInlineContent(按偏移切两半)、insertIntoInlineContentdeleteFromInlineContent。它们建立在两组基础设施上:

其一:按 Unicode 码点计数与切片。 JS 字符串是 UTF-16 编码单元序列,一个 emoji 占两个单元。所有偏移量统一按码点(Unicode scalar value)计:

function countCodePoints(str: string): number {
  let count = 0;
  for (let i = 0; i < str.length; ) {
    const code = str.codePointAt(i);
    if (code === undefined) break;
    count += 1;
    i += code > 0xFFFF ? 2 : 1;   // 增补平面码点占 2 个 UTF-16 单元,但只算 1 个码点
  }
  return count;
}

中文、emoji 密集的输入环境里,这是"光标不错位、删字不删半个"的根。CustomInline(@提及之类)按约定计 1 个单位。切分甚至能落到链接内部:光标在一个链接中间按回车,链接会被劈成两半,各带各的文本(SPEC §7.5.20)。

其二:规范形(canonical form)。 每次行内变更后都过一遍 normalizeInlineContent:相邻且样式相同的文本 run 合并、空文本 run 删除。没有它,加粗三连击会留下三个碎片 run,文档序列化越来越胖,样式比对越来越贵。每次变更后收敛到规范形,让"等价文档有唯一表示"成为不变量——这也是 diff 和协同的隐性前提。

九、两道防线与一个不变式的再确认

最后三个细节,展示防御的层次感:

  • 遗留操作双重拦截insertText/deleteText/applyStyle 在校验阶段 3 被明确拒绝;万一有代码路径绕过校验,执行器的 switch 里还埋着一个 throw——"到达这里说明校验器失职,这是编程错误"。纵深防御不嫌多。
  • 事件顺序即语义beforeValidate → afterValidate → beforeCommit → afterCommit / transactionFailed,每个转换点发事件。将来插件事务钩子(RFC-0003 的 v1.2 规划)就挂在这条事件线上,不需要新机制。
  • 复合操作的 diff 完整性mergeBlock 一次操作会产生 updated + contentChanged + removed 三类 diff 条目、外加被迁移子块的 moved 条目。渲染层因此能在一次事务后精确知道"哪个块内容变了、哪个块没了、哪些块搬家了"。

十、小结

  1. 唯一写入者:TransactionManager 是唯一被允许调用 Document 变更方法的类;一切变更以事务为单位,没有捷径。
  2. 离散操作代数:20 种类型化操作覆盖结构与行内两个层面,联合类型让编译器守着完备性;newBlockId 预分配为确定性撤销埋好伏笔。
  3. 两阶段提交:五阶段校验管道先跑(含事务内跨操作碰撞检查),快照再兜底;嵌套守卫在事件发出前抬起,操作序列对事件处理器只读。
  4. 逆操作先于应用生成:前状态快照进历史;邻居表达位置;split/merge 互逆;replaceBlock 的"换回式"逆操作保住 S1 无隙。
  5. 规范形与码点:行内运算按 Unicode 码点计偏移,每次变更收敛到规范形——等价文档只有一种长相。

到这里,"变更"这件事被完全工程化了:可校验、可应用、可求逆、可通知。下一篇就轮到那个站在它肩膀上的子系统——把逆操作变成用户手里"撤销/重做"两个按钮的历史栈。

下一篇:《撤销/重做:逆操作历史栈》——为什么存逆操作而不是存快照、undo 的"再反转"如何以新事务的身份走完同一条流水线、以及防重入守卫如何阻止"撤销产生新历史"的死循环。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐