Proxy实现多级撤销/重做核心是捕获状态快照而非记录操作,通过set拦截校验变更、更新数据、生成独立快照,并支持语义分组、批提交去冗余、结构化克隆保引用、钩子控副作用。

用 Proxy 实现多级撤销/重做,核心不是“记录操作”,而是“捕获每次状态变更的快照”,并让这些快照可回溯、可还原。关键在于把数据变更行为与历史管理逻辑解耦,由 Proxy 在 set 拦截中统一触发快照采集,同时避免重复或无效记录。
一、用 Proxy 拦截变更并生成原子快照
每次对代理对象属性赋值时,set 陷阱必须完成三件事:校验变更有效性、更新原始数据、生成当前完整状态快照。注意不是只存 diff,而是存整个 {nodes, edges} 或业务主对象的浅拷贝(或结构化克隆)——这样能保证快照独立、可直接还原,不依赖上下文。
示例逻辑:
- 仅当新值与当前值深度不等(如用
lodash.isEqual)才触发快照 - 快照写入
history.past前,清空history.future(用户执行新操作后,重做路径失效) - 限制
past长度(如最多 50 步),超出则移除最早项,防止内存泄漏
二、支持“多级”需区分粒度:字段级 vs 结构级
多级撤销不是指“撤到第 N 步”,而是指能按不同语义层级回退:比如一次拖拽节点位置是“细粒度操作”,一次批量导入 10 个节点是“粗粒度操作”。Proxy 本身不感知语义,所以需要上层配合标记:
- 在调用
proxy.xxx = newValue前,临时设置一个操作标签:proxy.$$batch = 'import-nodes' -
set 拦截器检测到该标记,将本次快照归入对应分组,并在快照中记录
type: 'import-nodes' - 撤销时可选择“撤销上一次导入”而非“撤销上一步移动”,靠的就是这个语义标记
三、避免冗余快照:跳过中间态与批量合并
真实业务中,一次用户动作常引发多次状态更新(如拖动节点时连续触发 position 更新)。若每 set 一次就存一帧,会快速填满 history。解决方案是引入“批提交”机制:
- 提供显式 API 如
commitBatch(),内部调用flushPendingChanges() - Proxy 的 set 拦截器不立即存快照,而是将变更暂存于 pending buffer
- commit 时,基于 buffer 计算最终状态,生成唯一快照,再更新目标对象
- React/Vue 等框架的批量更新机制(如
unstable_batchedUpdates)可与之对齐
四、还原时保持引用一致性与副作用可控
还原快照不只是覆盖属性值。若状态中含函数、Date、RegExp 或其他非 JSON 可序列化值,直接浅拷贝会出问题。因此:
- 快照应通过
structuredClone(现代环境)或轻量 clone 工具生成,保留引用关系 - 还原前,先触发
beforeRestore钩子,允许业务清理副作用(如取消定时器、中断请求) - 还原后,触发
afterRestore,通知 UI 重绘或同步外部系统(如 Flow 中重连 edge) - Proxy 的 get 拦截器可在此阶段注入调试信息,例如返回带
$$snapshotId的包装对象,便于追踪来源











