撤销重做不是「存快照」:一个可视化编辑器的 Operation 设计
"撤销重做还不简单?每次操作前把整个画布 JSON 存一份,撤销就把上一份塞回去。"
这句话在 demo 里永远成立,在真产品里最多撑到第三个迭代。下面这套设计来自我们对自家可视化编辑器内核的勘察记录,只讲结构,不含业务数据。

▲ 两条路线的根本差别:一个记「世界长什么样」,一个记「发生了什么」
一、存快照派会死在哪
先把话说透:全量快照不是错,错在把它当成唯一的记录方式。 它有三个绕不过去的坎。
第一,内存随对象数线性涨。 一个 1920×1080 的大屏,几百个对象、带图表配置和图片 base64,单份 JSON 就不小。历史栈留 50 步,等于把画布驻留 51 份。
第二,对象身份会漂。 序列化时 emitter、controls、editor、group 这些引用字段是被过滤掉的(内核里叫 ignoreKeys),加载时由工厂按 type 重建实例。也就是说"恢复一份快照"等于"重新实例化一批对象"------新的对象实例、新的 id 语义、新的控制点。任何外部持有旧对象引用的地方(属性面板当前绑定的那个节点、AI 面板记录的 objectIds、动画队列里的目标)全部指向空气。
第三,粒度不可控。 用户拖了一次滑块,属性从 0.2 变到 0.8,中间触发了几十次 input。全量快照只能回答"存或不存",回答不了"这几十次算一次还是几十次"。
这三个问题指向同一个结论:历史记录要记"发生了什么",而不是"世界长什么样"。
二、Operation 只有三个字段
内核里历史栈的元素叫 Operation,结构极简:
ts
interface Operation {
type: 'attrs' | 'add' | 'delete' | 'inversion' | 'config' | 'draw'
from: Snapshot // 操作前的状态
to: Snapshot // 操作后的状态
// 以及定位所需的对象 id / 分组键
}
两个栈:undoList 和 redoList。任何"有意义"的操作走 editor.store(...) 压进 undoList。按 Ctrl+Z 时,ShortcutHandler(基于 mousetrap)捕获按键,调 editor.undo();HistoryHandler 从 undoList 弹出一条 Operation,按 type 分派给对应的 handleRestoreXxx,恢复完把这条推进 redoList,并触发 history:changed,上层据此刷新那两个按钮的可用状态。
注意 from/to 仍然是快照------但快照的粒度被 type 约束住了 。attrs 类型的 from 只装这一次改动的属性键值,不是整张画布。这是"类型化快照"和"全量快照"的区别,也是整套设计的关键。

▲ 从属性面板到历史栈,四层各自只做一件事
三、六种分支,为什么不能合并
六种 type 各自对应一条恢复路径:
| type | 典型来源 | 恢复时做什么 |
|---|---|---|
attrs |
改位置、尺寸、旋转、样式 | 把 from 里的属性键回写到同一批对象上 |
add |
拖入组件、复制粘贴、绘制完成 | 按快照重建对象并挂回画布 |
delete |
删除、剪切 | 把对象及其层级位置还原(含父组合) |
inversion |
组合/解组、单面板替换 | from/to 互换即为逆操作 |
config |
画布级配置:缩放模式、背景、子屏切换 | 回写引擎配置并触发重排 |
draw |
绘制过程中的中间态 | 撤销未完成的绘制 |
看起来最该合并的是 attrs 和 add------都能用"恢复成 to"描述。但合并会立刻出问题:
attrs 的恢复是原地写回 ,对象实例不变,控制点位置只需要 updateControlsPosition() 同步一下;add 的恢复是新建实例 ,必须走工厂 Factory.getInstance(editor, {type, ...}),还要处理类型计数(typeCounts,决定新对象叫"矩形_1"还是"矩形_3")、层级顺序、以及父组合的反向引用重建。
把这两条合成一条"恢复快照"的分支,代码上省 30 行,运行时丢的是引用完整性。在编辑器里,"对象还在不在原位"比"数据对不对"更容易被用户抓到。
四、inversion:一条记录同时是正操作和逆操作
组合和解组是一对天然互逆的操作:
- 组合 = 删掉 N 个原对象 + 新增 1 个组合体
- 解组 = 删掉 1 个组合体 + 新增回 N 个子对象
如果按 add/delete 分别记,一次组合要写两条记录(一条删、一条增),撤销时得连按两次才能回到组合前------用户会认为这是 bug。所以内核给这类操作单独一个 inversion 类型:把 from 和 to 互换,就得到逆操作,一条记录一次撤销,语义自洽。
同一套机制在 AI 侧也被复用。做"单面板替换"时(用户说"把这个图表换成柱状图"),前端不是简单删旧建新,而是先合成一条可撤销的 inversion 历史,再落盘。这样用户按一次 Ctrl+Z,看到的是"换回来了",而不是"新图没了、旧图也没回来"。

▲ 一次属性修改的四步:抛事件 → 归一化 → 合并 → 入栈
五、跟着一次撤销走完一遍
把上面几块拼起来,看一个完整片段:用户拖入三个卡片、把它们水平等距对齐、再改卡片标题,然后连按三次 Ctrl+Z。
栈里应该是三条记录,形状如下:
text
undoList(栈顶在右)
┌──────────────┬──────────────────┬──────────────────┐
│ add ×3 │ inversion ×1 │ attrs ×1 │
│ to: 三个卡片的 │ from: 三卡各自原 │ from: {text:"旧"} │
│ id/位置/尺寸 │ 位置 │ to: {text:"新"} │
│ │ to: 对齐后位置 │ key: title │
└──────────────┴──────────────────┴──────────────────┘
第一次 Ctrl+Z 命中 attrs:只把 title 这个键写回原值,三个卡片的实例、id、控制点全都不动。属性面板此刻绑定的还是同一个节点,所以面板上的输入框会跟着跳回"旧"------不需要额外通知。
第二次 Ctrl+Z 命中 inversion:from/to 互换,把三个卡片的位置写回对齐前的散乱状态。注意这条记录只记了位置,没记"对齐"这个动作本身------历史栈记的是结果差值,不是命令。这也是它比命令模式(Command Pattern)轻的地方:不需要为每个动作写一个可执行对象,只要能把两种状态写回去。
第三次 Ctrl+Z 命中 add:三个对象从画布移除。如果此时属性面板正开着、指向其中一个已删除的节点,就得靠 history:changed 让上层清空面板------否则面板会往一个已不在 objects 里的实例上写值,症状是"面板能改,画布不动"。
这个片段里最容易被忽略的是第三次:删除型撤销必须联动清理外部引用 ,而 attrs 型撤销不需要。这就是六种分支不能合并的第二个理由------它们的副作用边界不一样。
六、记录的时机,比记录的格式难
数据结构定完只是开始。真正决定手感的是三个"什么时候"。
其一,什么时候该记。 内核的口径是"有意义"的操作才记:改属性、增删对象、调整配置、完成绘制。选中、框选、缩放画布视图这些不记------否则用户按一次撤销,画布跳回上一个缩放比例,比不撤销更让人困惑。
这条纪律最容易被破坏的地方是属性面板。属性面板是配置驱动的:组件开发者写一份 config.json,每项带 type、itemKey(如 chartOption.series[0].lineStyle.color)、historyKey,属性配置器据此自动生成表单。用户每改一个值,都可能触发一次记录。
其二,一次交互记几条。 拖滑块连续改值,逻辑上是"一次调整",物理上是几十次 change。解法是按 historyKey 分组:同一组键在短时间内连续变化,合并成一条 Operation。"折多少毫秒"没有标准答案------经验总结:以 300--500ms 无新变化为界比较稳,太短折不住拖动,太长会把"改颜色"和"改粗细"两个独立意图粘成一条。
其三,恢复中的操作不能又记一次。 撤销本身会改属性,改属性又会触发记录------不挡住就无限套娃。这就是 opLoding 标志位的用处:进入恢复分支时置位,恢复期间的属性变更不落栈,结束后清位再广播 history:changed。
这个标志位还顺手挡了另一个坑:用户长按 Ctrl+Z,按键事件以键盘重复率连发,上一轮恢复还没走完下一轮就进来了,画布状态会撕成两半。连击防护不是优化项,是正确性项。

▲ 这套历史栈支撑的实际产品效果(六个行业的真实大屏)
七、AI 操作也要进历史栈
最近这套编辑器接了 AI 能力,历史栈被复用到一个新场景:一句话操作画布。
接口层的设计很克制------有一类"画布操作"接口,只接受白名单里的语义 op:group/ungroup、八个方向加等距的 align、layer(前移后移置顶置底)、set-text、set-color、set-size、set-filter、set-name、set-visible、set-locked。前端拿到这些 op 后,翻译成内核已有的 action 再执行,于是自动进入编辑历史、自动可撤销。
这里有个值得说的边界:这类接口不写草稿、不加版本号、不重跑模型 ,只改当前画布。副作用是------AI 改完之后如果用户再让 AI 去改同一块面板,那块面板会按草稿重新装配,画布上的临时改动被覆盖。这是已知取舍,不是 bug:"可撤销的临时操作"和"持久化的数据变更"本来就是两套账。
八、这套设计没解决的事
诚实列三条:
- 撤销栈不过夜。 历史栈在内存里,刷新即清空。用户关页面再回来,撤销键是灰的------这是行业惯例,但每次都被新用户当成 bug 提。
- 两套账对不齐。 撤销栈记"操作序列",草稿的版本号记"数据变更序列"。AI 操作进撤销栈但不进草稿,于是"撤销到底"和"回到上一个版本"是两个不同的状态,需要靠产品文案说清。
- 跨端协同不可能靠它。 栈是本地的、串行的;一旦多人同时编辑同一屏,
Operation需要先转成可交换的操作再谈合并,那是另一套工程。
九、小结
回到开头那句"存个快照就行"。
它不是错的,它是不完整的 。一个能用的历史栈要回答四个问题:记什么(类型化而非全量)、记多细(historyKey 分组)、什么时候不记(opLoding 与恢复期禁录)、以及恢复时会不会破坏对象身份(六类分支分开走)。
这四个问题的答案,决定了用户按第十次 Ctrl+Z 时,看到的是回到十步之前,还是一屏认不出来的东西。