撤销重做不是「存快照」:一个可视化编辑器的Operation 设计

撤销重做不是「存快照」:一个可视化编辑器的 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 时,看到的是回到十步之前,还是一屏认不出来的东西。

相关推荐
系统集成-鹰翔1 小时前
ERP推单数据库怎么备份:SQLite WAL模式下只复制db文件为什么可能漏任务
javascript·数据库·经验分享·后端
honsor1 小时前
用 Grafana 做温湿度异常检测:从阈值告警到趋势预测
数据库·mysql·php
海绵宝宝转agent1 小时前
LeetCodehot100题解
java·笔记·学习·算法·leetcode·深度优先
SunnyDays10111 小时前
Java 操作 Word 脚注和尾注:添加、读取与删除
java·word
SunnyDays10111 小时前
Java 为 Word 添加页码:隐藏封面页码、从指定页开始编号、奇偶页页码设置等
java·word·页码
开源推荐官1 小时前
多门店 O2O 商城选型复盘:从开发视角对比主流开源系统
java·架构·开源·php
吴名氏.1 小时前
Java转AI Agent教程(第二周)超时重试与错误分类
java·人工智能·python·分类·ai agent
赵渝强老师1 小时前
【赵渝强老师】YashanDB Developer Center(YDC,崖山数据库开发者中心)
数据库·国产数据库·yashandb·崖山数据库
lusklusklusk1 小时前
SQL Server 为什么普通 SELECT 会阻塞 UPDATE?——行级 S 锁到表级 S 锁的锁升级原理与实验验证
数据库·mysql·oracle