可视化搭建引擎的撤销重做系统:Command 模式 + Immutable 快照实现操作历史树

6 阅读1分钟

可视化搭建引擎的撤销重做系统:Command 模式 + Immutable 快照实现操作历史树

你做低代码搭建平台,撤销重做是第一个"看起来简单、做起来怀疑人生"的功能。

用户拖了个组件,改了个属性,调了个层级,然后按了 Ctrl+Z——页面回去了。再按 Ctrl+Shift+Z——页面又回来了。看起来就是个栈操作,对吧?

直到有一天:

  • 用户撤销了三步,然后做了一个新操作——中间那些"被撤销的未来"怎么办?丢掉?还是保留成分支?
  • 两个人同时编辑同一个画布,A 撤销了,B 的操作还在——这算冲突吗?
  • 一个复合操作(批量对齐 20 个组件)撤销时要原子回滚,但其中第 15 个组件已经被别人删了。

这时候你才发现,撤销重做不是两个栈的事,是一棵树、一套冲突解决策略、一个时间旅行引擎。


从两个栈到一棵树:撤销重做的本质问题

经典方案:双栈模型

大部分教程告诉你的版本:

const undoStack: Command[] = []
const redoStack: Command[] = []

function execute(cmd: Command) {
  cmd.execute()
  undoStack.push(cmd)
  redoStack.length = 0 // 新操作一来,redo 全清空
}

function undo() {
  const cmd = undoStack.pop()
  cmd?.undo()
  if (cmd) redoStack.push(cmd)
}

function redo() {
  const cmd = redoStack.pop()
  cmd?.execute()
  if (cmd) undoStack.push(cmd)
}

能用,但有个致命问题:redoStack.length = 0 这一行,把用户的"平行宇宙"直接抹杀了。

用户撤销三步后做了新操作,之前的三步操作永远消失了。在文本编辑器里这可以接受,但在可视化搭建引擎里,用户可能花了十分钟拖出来的布局——说没就没了。

本质问题

撤销重做的本质不是"线性回退",而是操作历史的版本管理。你需要的是 Git,不是浏览器的前进后退。


Command 模式:让每一步操作都可逆

为什么不直接存快照?

先回答一个绕不开的问题:为什么不每一步存一份完整状态快照,撤销就直接恢复快照?

因为搭建引擎的状态可能有几百个组件、上千个属性,每步都深拷贝整棵树,内存直接爆炸。况且快照方案无法回答"这一步到底做了什么"——在协同场景下,这个信息至关重要。

Command 模式的核心思路:不存状态,存变化。

interface Command {
  readonly type: string
  execute(): void    // 正向执行
  undo(): void       // 反向撤销
  merge?(other: Command): Command | null  // 可选:合并连续同类操作
}

// 移动组件的命令
class MoveCommand implements Command {
  type = 'move' as const

  constructor(
    private node: CanvasNode,
    private from: Position,  // 记住旧位置
    private to: Position     // 记住新位置
  ) {}

  execute() {
    this.node.position = { ...this.to }
  }

  undo() {
    this.node.position = { ...this.from } // 反向操作:回到旧位置
  }

  // 连续拖拽只保留首尾位置,不然 undo 一次只回退 1px
  merge(other: Command): Command | null {
    if (other instanceof MoveCommand && other.node === this.node) {
      return new MoveCommand(this.node, this.from, other.to)
    }
    return null
  }
}

复合命令:批量操作的原子性

批量对齐 20 个组件,撤销时必须一起回去,不能一个一个撤:

class CompoundCommand implements Command {
  type = 'compound' as const

  constructor(private commands: Command[]) {}

  execute() {
    this.commands.forEach(cmd => cmd.execute()) // 顺序执行
  }

  undo() {
    // ✅ 反序撤销!先执行的最后撤销,保证状态一致
    ;[...this.commands].reverse().forEach(cmd => cmd.undo())
  }
}

// 使用:批量对齐
function alignComponents(nodes: CanvasNode[], baseline: number) {
  const commands = nodes.map(node =>
    new MoveCommand(node, node.position, { ...node.position, y: baseline })
  )
  const batch = new CompoundCommand(commands)
  historyManager.execute(batch)
}

从链表到树:操作历史的分支管理

历史树的数据结构

关键转变:把线性的 undo/redo 栈变成一棵树。每个节点代表一次操作,撤销后做新操作时不丢弃旧分支,而是创建新分支。

interface HistoryNode {
  id: string
  command: Command
  parent: HistoryNode | null
  children: HistoryNode[]       // 多个子节点 = 多个分支
  timestamp: number
  branchLabel?: string          // 可选:给分支起名
}

class HistoryTree {
  root: HistoryNode
  current: HistoryNode          // 当前指针,指向"现在"

  execute(cmd: Command) {
    cmd.execute()
    const node: HistoryNode = {
      id: nanoid(),
      command: cmd,
      parent: this.current,
      children: [],
      timestamp: Date.now(),
    }
    this.current.children.push(node)  // 挂到当前节点下面
    this.current = node               // 指针前进
  }

  undo() {
    if (!this.current.parent) return   // 已经在根节点,没得撤了
    this.current.command.undo()
    this.current = this.current.parent // 指针回退,但子节点还在
  }

  redo(branchIndex = 0) {
    const next = this.current.children[branchIndex]
    if (!next) return                  // 没有可 redo 的分支
    next.command.execute()
    this.current = next
  }
}

children.length > 1 时,用户面临一个分支选择。UI 上可以展示成一棵可视化的历史树,让用户点击任意节点"穿越"回去。

穿越到任意历史节点

不只是一步步 undo/redo,用户可能想直接跳到历史树的某个节点:

class HistoryTree {
  // ...接上文

  travelTo(target: HistoryNode) {
    // 1. 找到 current 和 target 的最近公共祖先(LCA)
    const currentPath = this.getPathToRoot(this.current)
    const targetPath = this.getPathToRoot(target)
    const lca = this.findLCA(currentPath, targetPath)

    // 2. 从 current 撤销到 LCA
    let node = this.current
    while (node !== lca) {
      node.command.undo()
      node = node.parent!
    }

    // 3. 从 LCA 重做到 target
    const replayPath = targetPath.slice(0, targetPath.indexOf(lca)).reverse()
    for (const step of replayPath) {
      step.command.execute()
    }

    this.current = target
  }

  private getPathToRoot(node: HistoryNode): HistoryNode[] {
    const path: HistoryNode[] = []
    while (node) {
      path.push(node)
      node = node.parent!
    }
    return path
  }
}

这就是为什么叫"时间旅行引擎"——你不是在前进后退,你是在一棵操作树上随意跳转。


Immutable 快照:Command 模式的保险丝

纯 Command 模式有个隐患:undo/redo 链条一旦断裂,整个历史就废了。

比如某个 Command 的 undo() 实现有 bug,或者外部直接修改了状态绕过了 Command 系统,后续所有的 undo 都会产生错误的结果,而且这个错误会累积。

解决方案:在关键节点插入 Immutable 快照作为"存档点"。

import { produce, freeze } from 'immer'

interface HistoryNode {
  id: string
  command: Command
  parent: HistoryNode | null
  children: HistoryNode[]
  timestamp: number
  snapshot?: Readonly<CanvasState>  // 关键节点的完整状态快照
}

class HistoryTree {
  private operationsSinceSnapshot = 0
  private SNAPSHOT_INTERVAL = 20   // 每 20 步存一次快照

  execute(cmd: Command) {
    cmd.execute()
    const node: HistoryNode = {
      id: nanoid(),
      command: cmd,
      parent: this.current,
      children: [],
      timestamp: Date.now(),
    }

    this.operationsSinceSnapshot++

    // 每 N 步自动存一次"存档"
    if (this.operationsSinceSnapshot >= this.SNAPSHOT_INTERVAL) {
      node.snapshot = freeze(structuredClone(this.getState()))
      this.operationsSinceSnapshot = 0
    }

    this.current.children.push(node)
    this.current = node
  }

  // 快照校验:检测 command 链是否出了问题
  verify() {
    const nearestSnapshot = this.findNearestSnapshot(this.current)
    if (!nearestSnapshot?.snapshot) return true

    // 从快照重放到当前位置
    const expectedState = this.replayFrom(nearestSnapshot)
    const actualState = this.getState()

    // 不一致说明有 command 的 undo/redo 实现出了 bug
    return deepEqual(expectedState, actualState)
  }
}

这样做的好处是双重保障:Command 负责增量操作,Snapshot 负责兜底校验和快速恢复。 就像 Redis 的 AOF + RDB 策略,一个记操作日志,一个存完整快照。


协同场景:当两个人同时操作一棵历史树

这是真正让人头秃的部分。

问题场景

  • A 把按钮拖到了右边
  • B 同时把同一个按钮改成了红色
  • A 按了撤销——按钮回到左边。但 B 的红色怎么办?

操作转换(OT)的简化思路

每个 Command 需要支持 transform——当与另一个并发操作冲突时,转换自己:

interface CollaborativeCommand extends Command {
  targetId: string              // 操作目标的组件 ID
  vectorClock: VectorClock      // 逻辑时钟,判定因果关系

  // 核心:当检测到并发冲突时,转换命令
  transform(against: CollaborativeCommand): CollaborativeCommand
}

class CollaborativeMoveCommand implements CollaborativeCommand {
  // ...基本属性

  transform(against: CollaborativeCommand): CollaborativeCommand {
    // 不同组件,互不影响
    if (against.targetId !== this.targetId) return this

    // 同一组件,对方也在移动 → 以时间戳晚的为准
    if (against instanceof CollaborativeMoveCommand) {
      if (against.vectorClock.isAfter(this.vectorClock)) {
        // 对方操作更晚,我的操作变成 no-op
        return new NoOpCommand()
      }
    }

    return this // 其他情况保持不变
  }
}

每人一棵本地历史树

协同编辑中,每个用户维护自己的本地历史树,撤销只回退自己的操作:

class CollaborativeHistoryManager {
  private localTree: HistoryTree        // 我的操作历史
  private userId: string

  undo() {
    // 只撤销"我自己的"最近一次操作
    let node = this.localTree.current
    while (node && node.command.userId !== this.userId) {
      node = node.parent!  // 跳过别人的操作
    }
    if (node) {
      // 撤销时需要对中间别人的操作做 transform
      this.undoWithTransform(node)
    }
  }

  // 收到远程操作时
  applyRemote(remoteCmd: CollaborativeCommand) {
    // 对本地未同步的操作做 OT 转换
    const localPending = this.getUnsynced()
    let transformed = remoteCmd
    for (const local of localPending) {
      transformed = transformed.transform(local)
    }
    transformed.execute()
  }
}

说实话,写到这里已经能感受到协同冲突解决的复杂度了。这就是为什么很多搭建平台选择"锁定编辑"而不是"自由协同"——不是不想做,是性价比的考量。


设计权衡:没有银弹

Command vs 纯快照

维度Command 模式纯快照
内存占用低(只存 diff)高(每步存全量)
实现复杂度高(每种操作都要写 undo)低(clone 一把就完事)
协同支持好(可以做 OT)差(快照无法合并)
调试难度中(链条断裂难追踪)低(直接对比快照)
适用场景组件多、操作频繁状态小、原型阶段

实际工程建议:混合方案。 Command 为主,关键节点存快照做校验和快速恢复。就像前面写的那样。

历史树 vs 线性栈

历史树的代价是 UI 复杂度显著上升。你得给用户展示分支、提供选择入口、处理分支合并。如果你的产品场景是"普通运营人员搭页面",线性栈可能就够了——用户根本不理解什么叫"分支"。

历史树适合:专业设计工具、开发者向的搭建平台、需要"方案对比"的场景。

OT vs CRDT

协同冲突解决还有另一条路——CRDT(无冲突复制数据类型)。OT 需要中心服务器做转换,CRDT 可以完全去中心化。但 CRDT 对搭建引擎的树形结构支持还不够成熟,目前大部分生产级方案(Google Docs、Figma)仍然基于 OT 或其变体。


边界与踩坑

1. 命令的序列化

操作历史如果要持久化(刷新不丢失),Command 必须可序列化。这意味着 Command 里不能存组件的引用,只能存 ID:

// ❌ 存引用,序列化直接炸
class BadCommand {
  constructor(private node: CanvasNode) {} // 引用无法序列化
}

// ✅ 存 ID,执行时再查
class GoodCommand {
  constructor(private nodeId: string) {}
  execute() {
    const node = store.getNodeById(this.nodeId) // 执行时动态查找
    if (!node) return // 组件可能已被删除,需要防御
  }
}

2. 快照的内存策略

无限制存快照迟早 OOM。需要 LRU 淘汰或者按时间窗口清理:

  • 最近 50 步的快照全保留
  • 超过 50 步的,每 10 步保留一个
  • 超过 200 步的,只保留分支点的快照

3. 外部副作用

有些操作有外部副作用——比如"发布页面"。这种操作即使放进了 Command,undo 也不可能真的"取消发布"。对这类操作,要么不纳入撤销体系,要么 undo 时只回退本地状态并提示用户"线上版本需手动处理"。


技术升华:这到底是什么问题?

退一步看,撤销重做系统本质上是一个事件溯源(Event Sourcing)系统

  • 每个 Command 就是一个 Event
  • 操作历史就是 Event Log
  • 当前状态 = 初始状态 + 按序重放所有 Event
  • 快照就是物化视图的 Checkpoint

这个模型不只在前端出现。数据库的 WAL(预写日志)、Redux 的 Action/Reducer、区块链的交易记录——底层都是同一个思路:不存结果,存过程。需要结果时,重放过程。

下次遇到类似的问题——需要回溯、需要审计、需要协同——先问自己:

  1. 操作是否可逆?→ Command 模式
  2. 历史是否需要分支?→ 树形结构
  3. 是否需要兜底恢复?→ 关键节点快照
  4. 是否多人操作?→ OT/CRDT

这四个问题的答案组合,决定了你的撤销系统的复杂度上限。选哪个方案不重要,重要的是清楚自己在哪个复杂度等级上——别用杀鸡的刀去宰牛,也别拿牛刀去削苹果。