可视化搭建引擎的撤销重做系统:Command 模式 + Immutable 快照实现操作历史树
你做低代码搭建平台,撤销重做是第一个"看起来简单、做起来怀疑人生"的功能。
用户拖了个组件,改了个属性,调了个层级,然后按了 Ctrl+Z------页面回去了。再按 Ctrl+Shift+Z------页面又回来了。看起来就是个栈操作,对吧?
直到有一天:
- 用户撤销了三步,然后做了一个新操作------中间那些"被撤销的未来"怎么办?丢掉?还是保留成分支?
- 两个人同时编辑同一个画布,A 撤销了,B 的操作还在------这算冲突吗?
- 一个复合操作(批量对齐 20 个组件)撤销时要原子回滚,但其中第 15 个组件已经被别人删了。
这时候你才发现,撤销重做不是两个栈的事,是一棵树、一套冲突解决策略、一个时间旅行引擎。
从两个栈到一棵树:撤销重做的本质问题
经典方案:双栈模型
大部分教程告诉你的版本:
ts
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 模式的核心思路:不存状态,存变化。
ts
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 个组件,撤销时必须一起回去,不能一个一个撤:
ts
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 栈变成一棵树。每个节点代表一次操作,撤销后做新操作时不丢弃旧分支,而是创建新分支。
ts
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,用户可能想直接跳到历史树的某个节点:
ts
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 快照作为"存档点"。
ts
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------当与另一个并发操作冲突时,转换自己:
ts
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 // 其他情况保持不变
}
}
每人一棵本地历史树
协同编辑中,每个用户维护自己的本地历史树,撤销只回退自己的操作:
ts
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:
ts
// ❌ 存引用,序列化直接炸
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、区块链的交易记录------底层都是同一个思路:不存结果,存过程。需要结果时,重放过程。
下次遇到类似的问题------需要回溯、需要审计、需要协同------先问自己:
- 操作是否可逆?→ Command 模式
- 历史是否需要分支?→ 树形结构
- 是否需要兜底恢复?→ 关键节点快照
- 是否多人操作?→ OT/CRDT
这四个问题的答案组合,决定了你的撤销系统的复杂度上限。选哪个方案不重要,重要的是清楚自己在哪个复杂度等级上------别用杀鸡的刀去宰牛,也别拿牛刀去削苹果。