可视化搭建引擎的撤销重做系统:Command 模式 + Immutable 快照实现操作历史树
你做了一个可视化搭建平台,用户拖了 30 个组件、调了 50 次样式,然后按了一下 Ctrl+Z——页面白了。
这不是段子,这是我在第一版撤销系统上线后收到的真实 bug。问题出在哪?撤销重做看起来就是个栈操作,但真正做下去你会发现:线性栈根本扛不住分支操作,快照太大内存爆炸,协同场景下两个人同时撤销直接打架。
今天聊的就是这套系统怎么从"能用"做到"能打"。
本质问题:撤销重做到底在管理什么?
很多人第一反应是"记录操作步骤",但更准确的说法是:管理状态的时间线。
这件事有两个流派:
| 方案 | 核心思路 | 类比 |
|---|---|---|
| Command 模式 | 记录每一步操作的"做"和"撤" | 像录像带,记录的是动作 |
| Immutable 快照 | 记录每一刻的完整状态 | 像相册,记录的是结果 |
单独用哪个都有明显短板。Command 模式省内存但逆操作难写,快照简单粗暴但吃内存。搭建引擎的正确答案是:Command 负责语义,Snapshot 负责兜底。
第一层:Command 模式的基本骨架
先把最小可用版本搭起来:
interface Command {
id: string
type: string
execute(): void // 做
undo(): void // 撤
// 可选:用于合并连续同类操作
merge?(prev: Command): Command | null
}
class HistoryManager {
private undoStack: Command[] = []
private redoStack: Command[] = []
execute(cmd: Command) {
cmd.execute()
this.undoStack.push(cmd)
this.redoStack = [] // 新操作进来,重做栈清空——这是线性模型的核心限制
}
undo() {
const cmd = this.undoStack.pop()
if (!cmd) return // 没得撤了,你等了个寂寞
cmd.undo()
this.redoStack.push(cmd)
}
redo() {
const cmd = this.redoStack.pop()
if (!cmd) return
cmd.execute()
this.undoStack.push(cmd)
}
}
一个真实的搭建操作长这样:
class MoveComponentCommand implements Command {
id = crypto.randomUUID()
type = 'move'
constructor(
private component: ComponentNode,
private from: Position,
private to: Position,
private canvas: CanvasState
) {}
execute() {
this.canvas.setPosition(this.component.id, this.to)
}
undo() {
this.canvas.setPosition(this.component.id, this.from)
}
// 连续拖拽合并:用户拖动过程中产生 60 帧 move,只保留首尾
merge(prev: Command): Command | null {
if (prev.type !== 'move') return null
const prevMove = prev as MoveComponentCommand
if (prevMove.component.id !== this.component.id) return null
return new MoveComponentCommand(
this.component,
prevMove.from, // 保留最初的起点
this.to, // 用最新的终点
this.canvas
)
}
}
这里 merge 是个容易忽略但极其重要的设计。没有它,用户拖一下组件要撤 60 次才能回到原位。写到这里我开始怀疑为什么第一版没加这个。
第二层:从线性栈到操作历史树
线性栈有个致命问题:用户撤销几步后做了新操作,被清掉的 redo 栈就永远回不来了。
在搭建场景下这很要命——设计师经常想"回到刚才那个分支看看效果"。所以我们需要把线性栈升级成树:
interface HistoryNode {
id: string
command: Command
parent: string | null
children: string[] // 一个节点可以有多个子节点 → 分支
snapshot?: CanvasSnapshot // 关键帧快照,不是每个节点都有
timestamp: number
}
class HistoryTree {
private nodes = new Map<string, HistoryNode>()
private currentId: string // 当前指针位置
private rootId: string
execute(cmd: Command) {
cmd.execute()
const node: HistoryNode = {
id: crypto.randomUUID(),
command: cmd,
parent: this.currentId,
children: [],
timestamp: Date.now()
}
// 新节点挂到当前节点下面,不清除其他分支
this.nodes.get(this.currentId)!.children.push(node.id)
this.nodes.set(node.id, node)
this.currentId = node.id
// 每 N 步打一个快照(关键帧策略)
if (this.shouldSnapshot()) {
node.snapshot = this.captureSnapshot()
}
}
// 撤销:沿着 parent 往上走
undo() {
const current = this.nodes.get(this.currentId)!
if (!current.parent) return
current.command.undo()
this.currentId = current.parent
}
// 跳转到任意历史节点——这是树结构的杀手级能力
jumpTo(targetId: string) {
const path = this.findPath(this.currentId, targetId)
// 先撤销到公共祖先,再重做到目标
for (const nodeId of path.undoPath) {
this.nodes.get(nodeId)!.command.undo()
}
for (const nodeId of path.redoPath) {
this.nodes.get(nodeId)!.command.execute()
}
this.currentId = targetId
}
}
关键帧快照:内存和性能的平衡点
每步都存快照?
每步都不存快照?跳转到 500 步前,要从根节点回放 500 个 Command,用户等 3 秒——他以为页面卡死了。
所以用关键帧策略,像视频编码一样:
class SnapshotStrategy {
private interval = 20 // 每 20 步打一个快照
shouldSnapshot(stepCount: number): boolean {
return stepCount % this.interval === 0
}
// 跳转时:找最近的快照 → 从快照恢复 → 回放剩余 Command
restore(tree: HistoryTree, targetId: string) {
const path = tree.getPathFromRoot(targetId)
// 从目标往上找最近的快照节点
let snapshotNode: HistoryNode | null = null
for (let i = path.length - 1; i >= 0; i--) {
if (path[i].snapshot) {
snapshotNode = path[i]
break
}
}
if (snapshotNode) {
// 从快照恢复(O(1)),再回放后面几步(最多 19 步)
canvas.restore(snapshotNode.snapshot!)
const remaining = path.slice(path.indexOf(snapshotNode) + 1)
remaining.forEach(n => n.command.execute())
} else {
// 没快照兜底,只能从头回放
path.forEach(n => n.command.execute())
}
}
}
最多回放 19 步,可以接受。快照间隔可以根据操作复杂度动态调整——简单属性修改间隔大一些,组件增删间隔小一些。
第三层:Immutable 数据结构让快照不再昂贵
"200KB 一个快照还是太大了"——如果每个快照都是完整深拷贝的话,确实。
但如果用 Immutable 数据结构(结构共享),两个相邻快照之间只有被修改的节点是新的,其他都是引用:
// 用 Immer 实现结构共享的快照
import { produce, enablePatches, Patch } from 'immer'
enablePatches()
class ImmutableCanvasState {
private current: CanvasData // 不可变状态树
applyCommand(cmd: Command): { patches: Patch[], inversePatches: Patch[] } {
let patches: Patch[] = []
let inversePatches: Patch[] = []
// produce 返回新状态,只有被改的部分是新对象
// 没改的子树共享引用 → 内存占用极小
this.current = produce(this.current, draft => {
cmd.applyTo(draft)
}, (p, ip) => {
patches = p
inversePatches = ip
})
return { patches, inversePatches }
}
}
// 现在 Command 可以用 patch 实现撤销,不用手写逆操作了
class PatchCommand implements Command {
id = crypto.randomUUID()
type: string
constructor(
private state: ImmutableCanvasState,
private patches: Patch[],
private inversePatches: Patch[]
) {
this.type = patches[0]?.path?.[0]?.toString() ?? 'unknown'
}
execute() {
this.state.applyPatches(this.patches)
}
undo() {
// 逆向 patch,不需要手写 undo 逻辑
// 这是 Immer 给我们的最大红利
this.state.applyPatches(this.inversePatches)
}
}
用 Immer 的 patches 后,Command 的 undo 不用手写了。之前每种操作都要实现 undo(),移动组件要记原位置、删除组件要保留完整数据、修改样式要存旧值……现在 Immer 自动生成逆向 patch,省了大量代码。
结构共享让快照也便宜了。两个相邻快照实际共享 90%+ 的内存,200KB 的状态树改一个属性,增量只有几十字节。
第四层:协同场景下的冲突处理
单人撤销搞定了,两个人同时编辑怎么办?
核心矛盾:A 撤销了自己的操作,但 B 的后续操作可能依赖 A 的那步操作。
比如 A 创建了一个按钮,B 给这个按钮改了颜色。A 撤销创建——按钮没了,B 的颜色修改指向了一个不存在的组件。
OT(Operational Transformation)思路
interface CollabCommand extends Command {
userId: string
vectorClock: Record<string, number> // 逻辑时钟,判断因果关系
transform(against: CollabCommand): CollabCommand | null
}
class CollabHistoryManager {
// 撤销时:不是简单 undo,而是生成一个"补偿操作"
undoForUser(userId: string) {
const lastCmd = this.findLastCommandByUser(userId)
if (!lastCmd) return
// 收集 lastCmd 之后所有其他用户的操作
const subsequent = this.getSubsequentCommands(lastCmd)
// 生成补偿命令,考虑后续操作的影响
let compensation = lastCmd.createInverse()
for (const cmd of subsequent) {
// 变换补偿操作,使其在当前状态下仍然正确
compensation = compensation.transform(cmd)
if (!compensation) {
// transform 返回 null → 操作已被覆盖,撤销无意义
console.warn('操作已被其他用户覆盖,无法撤销')
return
}
}
this.execute(compensation) // 以新操作的形式执行补偿
}
}
冲突检测与解决策略
type ConflictStrategy = 'last-write-wins' | 'manual-merge' | 'auto-rebase'
class ConflictResolver {
detect(cmdA: CollabCommand, cmdB: CollabCommand): boolean {
// 两个操作改了同一个组件的同一个属性 → 冲突
return cmdA.targetId === cmdB.targetId
&& cmdA.propertyPath === cmdB.propertyPath
&& !this.isCausallyOrdered(cmdA, cmdB) // 有因果关系的不算冲突
}
resolve(cmdA: CollabCommand, cmdB: CollabCommand, strategy: ConflictStrategy) {
switch (strategy) {
case 'last-write-wins':
// 简单粗暴,时间戳大的赢
return cmdA.timestamp > cmdB.timestamp ? cmdA : cmdB
case 'auto-rebase':
// 类似 git rebase:把一方的操作变基到另一方之后
return cmdA.transform(cmdB)
case 'manual-merge':
// 弹个 diff 界面让用户选——这不是 bug,这是特性
return { type: 'need-user-decision', options: [cmdA, cmdB] }
}
}
}
实际项目中,我们对不同操作类型用不同策略:
- 位置/尺寸修改:last-write-wins,谁最后拖的算谁的
- 组件增删:auto-rebase,自动变换
- 业务逻辑配置:manual-merge,让用户决定
设计权衡:为什么不用纯快照 / 为什么不用纯 Command?
纯快照方案的问题
内存是一方面,更关键的是丢失了语义。快照只知道"状态从 A 变成了 B",不知道用户做了什么操作。在协同场景下,没有操作语义就无法做 OT 变换,冲突解决变成了状态 diff——复杂度直接起飞。
纯 Command 方案的问题
逆操作不好写是一方面,更关键的是状态漂移。
混合方案的成本
维护两套数据(Command + Snapshot)确实增加了复杂度。序列化、存储、同步都要考虑两种格式。但对搭建引擎这个量级的产品,这个成本是值得的。
边界与踩坑
1. 异步操作的撤销
用户上传了一张图片(异步),还没传完就按了撤销。你是取消上传?还是等上传完再删?我们的做法是:异步操作拆成两个 Command——StartUpload 和 CompleteUpload,撤销 CompleteUpload 就是删图,撤销 StartUpload 就是取消上传。
2. 历史树的修剪
用户操作 10000 步,历史树不能无限增长。修剪策略:
class TreePruner {
prune(tree: HistoryTree, maxNodes = 500) {
// 只保留:当前分支 + 最近 3 个分支点 + 所有带快照的节点
const keepSet = new Set<string>()
// 1. 当前分支必须保留
this.markBranch(tree.currentId, keepSet)
// 2. 最近的分支节点保留(用户可能想切回去)
const branchPoints = this.findRecentBranchPoints(tree, 3)
branchPoints.forEach(id => this.markBranch(id, keepSet))
// 3. 删掉其他的,但保留快照节点作为"存档点"
for (const [id, node] of tree.nodes) {
if (!keepSet.has(id) && !node.snapshot) {
tree.removeNode(id)
}
}
}
}
3. 批量操作的原子性
用户框选 20 个组件一起拖动,这是 1 个操作还是 20 个?必须是 1 个。用 CompoundCommand 包装:
class CompoundCommand implements Command {
id = crypto.randomUUID()
type = 'compound'
constructor(private commands: Command[]) {}
execute() {
this.commands.forEach(cmd => cmd.execute())
}
undo() {
// 逆序撤销,这里搞反了就等着收 bug
;[...this.commands].reverse().forEach(cmd => cmd.undo())
}
}
可扩展性
这套架构可以自然延伸出几个能力:
- 操作回放:把 Command 序列存下来,可以做操作录像、用户行为分析
- 时间旅行调试:搭配 UI 做一个时间轴滑块,随意跳转到任意历史节点
- 版本管理:在快照节点上打标签,变成类似 git tag 的能力
- 插件化:Command 注册机制可以做成插件,第三方组件自带撤销逻辑
如果要做成 SaaS 产品,Command 日志天然就是审计日志,快照天然就是版本存档。这不是额外开发,是架构自带的。
总结:这类问题的通用模型
撤销重做本质上是一个状态时间线管理问题,它和数据库的 WAL(Write-Ahead Logging)、git 的版本管理、事件溯源(Event Sourcing)是同一类问题。
核心抽象就三件事:
- 操作日志(Command / Event)——记录"发生了什么"
- 状态快照(Snapshot / Checkpoint)——记录"某一刻长什么样"
- 冲突解决(OT / CRDT)——多条时间线如何合并
下次再遇到类似的问题。不管是富文本编辑器的撤销、表单的草稿恢复、还是游戏的存档系统——都可以用这个模型去套。先想清楚"记动作还是记状态",再决定两者怎么配合,最后处理并发冲突。
架构不复杂,但每一层都有坑。希望这篇能帮你少踩几个。