一、安全网的成本模型:存什么,决定了你走多远

撤销功能有两个经典实现路线,选型本质是一道内存数学题:

路线 A:文档快照。 每次提交事务前,深拷贝整棵 Block 树存进栈。撤销 = 恢复上一份快照。实现直观、正确性显然,但成本是 O(文档大小 × 步数)。一份 5000 块的文档,用户编辑 500 步,理论上要维护 500 份全量副本——即便工程上用结构共享优化,长文档 + 重度编辑的组合在移动设备上依然是不可忽视的内存压力。而且每一步都要付全量拷贝的时间,无论这一步是"插入一个章节"还是"敲了一个字"。

路线 B:逆操作(ArkBlocks 的选择)。 每次提交事务时,只记录"如何反转这一步"的操作序列。成本是 O(编辑量)——敲一个字就存一个字的操作,删一个块就存那个块的快照。代价是每次撤销要做一次求逆运算,但求逆是上一篇讲过的纯数据变换,不碰渲染、不碰平台,便宜且可测。

一个量化感受:同样是"把标题 level 从 1 改成 2",快照方案存的是整个文档,逆操作方案存的是 updateProps(level: 1) 一个对象。差距在轻文档里无所谓,在"整个知识库一个文档"的场景里就是能否接受的分界线。

二、HistoryEntry 解剖:一组"往返票"

真实的数据结构只有四个字段(HistoryEntry.ts 全文核心):

export interface HistoryEntry {
  /** 来源事务的 ID。 */
  transactionId: string;

  /** 按序应用即可撤销该事务的操作。提交时由 OperationInverter.invertAll() 计算。 */
  inverseOperations: Operation[];

  /** 产生该状态的原操作。重做时使用。 */
  originalOperations: Operation[];

  timestamp: number;
}

理解这个设计的正确心智模型是往返票:每个条目记录一次状态跃迁的两个方向——originalOperations 是"从状态 A 到状态 B",inverseOperations 是"从状态 B 回到状态 A"。两个方向都在提交时刻一次性算好、冻结进条目,之后不再依赖任何活数据。

注意"冻结"的分量:第 02 篇讲过逆操作必须捕获前状态(删掉的块要整棵快照进逆操作),第 03 篇讲过 SplitBlockOp.newBlockId 预分配。这些设计在这里兑现——历史条目是自包含的,撤销不依赖"文档现在长什么样",只依赖条目里封存的操作。这也让 HistoryStack 自身的依赖规则可以收得很紧:只许 import core/ / document/ / transaction/,与 command / renderer / adapter 彻底绝缘。

三、push 的语义:新编辑作废整个"未来"

入栈接口只有两行,但第二行是所有编辑器的通用契约:

push(entry: HistoryEntry): void {
  this.undoStack.push(entry);
  this.redoStack = [];      // 新编辑使整条 redo 链失效
}

用户撤销了三步,然后敲了一个新字符——此刻"重做"必须永远消失。原因不是技术限制而是语义正确:重做恢复的是"被撤销的旧世界",而用户的最新编辑已经宣告旧世界作废,在旧世界上重做会产生逻辑上矛盾的状态。所以 push 即清空,没有例外。

谁负责调 push?看 Editor 外观层的 exec()(真实代码):

exec(command: Command): void {
  const result = this.dispatcher.exec(command);                        // 命令 → 事务 → 提交
  const inverseOps = OperationInverter.invertAll(result.operations, this.doc);  // 求逆
  const entry: HistoryEntry = {
    transactionId: result.transactionId,
    inverseOperations: inverseOps,
    originalOperations: result.operations,
    timestamp: Date.now(),
  };
  this.historyStack.push(entry);                                       // 入史
  this.notifyRenderer(result.diff);                                    // 渲染
  this.emit({ type: 'change', blocks: this.doc.getRoot() });           // 事件
}

顺序是硬约束:先求逆、再入史。求逆必须发生在文档被下一次修改之前(第 03 篇的铁律),而入史必须在渲染通知之前——否则渲染层先看到新状态、历史却还没记录,事件处理器里如果触发撤销就会撤销一个不存在的条目。

四、undo() 的完整闭环:把逆操作再反转一次

HistoryStack.undo() 是本模块的心脏。它做的事情概括起来是:弹出的条目告诉你怎么回去;照着做;再算好"怎么回来",封成镜像条目放上对面的栈

undo(): RendererDiff {
  if (this.undoStack.length === 0) return createEmptyDiff();

  const entry = this.undoStack.pop()!;
  this._isUndoRedoInProgress = true;                 // 拉起守卫(见第六节)

  try {
    const inverseOps = entry.inverseOperations;      // ① 怎么回去

    const redoInverse = OperationInverter.invertAll(inverseOps, this.document); // ② 怎么回来

    const tx = this.txnManager.begin();              // ③ 以新事务身份回去
    for (const op of inverseOps) tx.addOperation(op);
    const result = this.txnManager.commit(tx);

    this.redoStack.push({                            // ④ 镜像条目上 redo 栈
      transactionId: result.transactionId,
      inverseOperations: redoInverse,
      originalOperations: inverseOps,
      timestamp: Date.now(),
    });
    return result.diff;
  } catch (_err) {
    this.undoStack.push(entry);                      // ⑤ 失败:条目退回,历史不丢
    return createEmptyDiff();
  } finally {
    this._isUndoRedoInProgress = false;              // ⑥ 守卫必然释放
  }
}

四个设计决策值得展开:

① 撤销也是事务。 undo 没有任何特权通道——它把逆操作打包成一个新事务,走和普通编辑完全一样的 begin → addOperation → commit 流水线。于是第 03 篇的所有机制自动生效:五阶段校验、快照兜底、RendererDiff、事件。撤销失败会走校验失败路径抛错,而不是把文档推进非法状态。

② "再反转"为什么可行。 invertAll(inverseOps, document) 对逆操作再求一次逆,得到的就是"回到被撤销状态"的操作(即原始操作)。这一步发生在应用撤销之前,符合求逆的前状态契约。配合 invertAll 对 replaceBlock 的补删除逻辑,无论操作多复合,往返票总能配平。

⑤ 失败回压:保守到近乎执拗。 提交抛错时,弹出来的条目被原样塞回 undo 栈,返回空 diff。宁可这次撤销无效,也绝不丢历史条目——丢了就永远回不去了。finally 块保证守卫在任何路径(成功、失败、抛错)下都释放。

空栈返回空 diff 而非抛错。 撤销空栈不是错误,是用户的正常操作(谁没狂按过 Ctrl+Z)。返回空 diff 让上层渲染通知成为无害空转。

五、一个漂亮的对称性:undo 和 redo 是结构孪生

把 redo() 放在 undo() 旁边看,会发现一个精心设计(或精心演化)出的对称:

redo(): RendererDiff {
  if (this.redoStack.length === 0) return createEmptyDiff();
  const entry = this.redoStack.pop()!;
  this._isUndoRedoInProgress = true;
  try {
    const ops = entry.inverseOperations;             // ★ 和 undo 一模一样
    const undoInverse = OperationInverter.invertAll(ops, this.document);
    const tx = this.txnManager.begin();
    for (const op of ops) tx.addOperation(op);
    const result = this.txnManager.commit(tx);
    this.undoStack.push({                            // 镜像条目放回对侧栈
      transactionId: result.transactionId,
      inverseOperations: undoInverse,
      originalOperations: ops,
      timestamp: Date.now(),
    });
    return result.diff;
  } catch (_err) {
    this.redoStack.push(entry);
    return createEmptyDiff();
  } finally {
    this._isUndoRedoInProgress = false;
  }
}

两个方法逐行同构,唯一的区别是操作哪边的栈、条目放回哪边。玄机在条目字段的使用上:无论 undo 还是 redo,实际应用的永远是 entry.inverseOperations。因为 mirror 条目在入栈时被翻转过一次——undo 产生的 redo 条目里,inverseOperations 字段装的是"回到被撤销状态"的原方向操作;redo 产生的 undo 条目里,装的是"再次撤销"的逆方向操作。栈的方向翻转 + 字段语义翻转,两两抵消,于是两个方法可以共用同一副骨架。

这张状态表可以帮你验证:

条目内容 应用 inverseOperations 的效果
undoStack 原 A→B,逆 B→A B→A(撤销)
redoStack(由 undo 产生) "原" B→A,逆 A→B A→B(重做)

每一次 undo/redo 都在两侧栈之间搬运镜像条目,任何一边弹空了就无事可做。对称性不是审美偏好——undo 和 redo 互为彼此的用例,实现上的孪生保证了行为上的严格互逆。

六、防重入:撤销不能产生新历史

Undo/redo 有个自指陷阱:撤销本身就是一次文档变更,而"每次变更都记入历史"是框架铁律——那么撤销会生成新的撤销条目,无限套娃。

ArkBlocks 用两层防御处理:

结构层(主防线):undo/redo 提交事务走的是 txnManager.commit() 直连,不经过 Editor.exec()——而入史只发生在 exec() 里。undo 的事务从结构上就不可能撞进入史逻辑。

约定层(对钩子)isUndoRedoInProgress 标志在 undo/redo 执行期间拉起、必然释放(finally)。它是一个公开 getter,类文档明确要求:如果将来把"记录历史"改成挂在事务事件上的钩子形式,钩子必须先检查这个标志再 push。标志此刻是给未来集成方式预留的断路器——防御先于需求存在,因为这类死循环一旦上线,用户看到的是"按一次撤销,内存涨一截"。

七、边界与诚实的现状

几个使用语义,一笔一笔说清楚:

  • 历史不序列化(RFC-0001 Rule 9):撤销栈是会话产物。文档从 JSON 加载时 clear() 清空双栈——新打开的文档没有"过去"可反悔。序列化历史不仅成本无界,还会在操作格式演进时制造迁移灾难。
  • canUndo / canRedo / 栈大小:暴露给 UI 做按钮置灰与状态显示。
  • 诚实披露一Editor.transact()(应用手动打包多个操作的底层入口)目前提交后尚未写入历史——代码里有明确的 TODO(M4): Record the transaction in History after commit。所以当下"一次撤销单位"严格对应的是 exec() 的一条命令;transact 的入史是既定路线图上的待接线项,不是架构缺口。
  • 诚实披露二:相邻同类操作的合并(连续敲字合成一个撤销步)、命名检查点、历史压缩,都在架构文档的"未来扩展"清单里,当前实现是"一个事务 = 一个撤销步"的最严格语义。

八、为什么这套设计在 AI 时代特别值钱

撤销单位是事务,这个决定在 M7(AI 集成)里程碑会加倍回报。AI 流式写入的物理形态是几十上百个增量片段陆续到达;如果以"每次到达"为撤销单位,用户按一次撤销只能消掉一个 token,按五十次才能清掉一段 AI 生成——不可用。正确的做法是:整个流式会话的所有 insertInline 操作进同一个事务,提交时产生一个历史条目。用户按一次撤销,整段 AI 内容一次消失;再按一次重做,整段回来。

第 02 篇的稳定 ID、第 03 篇的 newBlockId 预分配与本篇的往返票,三件事合起来保证了这条链路无需任何特设机制——AI 写入就是普通写入,撤销就是普通撤销。好的架构决策的特征,是让未来的功能看起来本来就该这么实现。

九、小结

  1. 存操作不存文档:历史条目是一组往返票(original + inverse),内存正比于编辑量而非文档大小 × 步数。
  2. push 即清空 redo 栈:新编辑作废整个"未来",语义正确性优先。
  3. 撤销也是事务:undo/redo 与普通编辑走同一条校验-提交-通知流水线,没有特权通道。
  4. undo 与 redo 结构孪生:永远应用 entry.inverseOperations,镜像条目在对侧栈之间搬运,行为严格互逆。
  5. 失败回压 + 必然释放的守卫:出错时条目退栈不丢历史,标志位 finally 兜底释放;防重入靠结构直连 + 钩子约定双保险。

至此,"变更"的生命周期完整闭环:命令构造它(03)、事务校验并应用它(03)、历史记住如何反悔它(本篇)。地基之下是第 02 篇的文档模型。从下一篇开始,我们转向框架的扩展面——先看所有类型化数据的守门人:Schema 体系。

下一篇:《鸿蒙编辑器框架的 Schema 体系:BlockSpec 注册与运行时校验》——一个 BlockSpec 如何同时承载类型契约与 UI 贡献,SchemaRegistry 的三张 Map 如何让"文档里出现未注册类型"成为不可能。

Logo

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

更多推荐