Proxy的set拦截无法可靠实现撤销/重做,因其仅获知新值而无法自动获取旧值,且无法区分用户操作、框架更新或第三方调用,导致快照混乱;必须配合get/has/ownKeys等陷阱在变更前捕获上下文,并通过WeakMap记录路径、旧值与操作类型。

为什么直接用 Proxy 的 set 拦截无法可靠实现撤销/重做
Proxy 的 set 拦截器只告诉你“某个属性被设成了什么值”,但不提供“之前是什么”——而撤销操作必须知道变更前的旧值。更关键的是,set 不区分是用户主动修改、框架内部更新(如 Vue 的响应式赋值)、还是第三方库调用(如 Lodash 的 set()),导致快照粒度混乱、重复记录或漏记。
常见错误是仅在 set 里 push 一条“新值”,结果撤销时发现旧值丢失、数组索引错位、或对象引用被意外复用。真正可用的方案必须在变更**发生前**捕获上下文,而非事后推断。
- 必须配合
get+has+ownKeys等陷阱,识别出“即将被修改”的路径和原始状态 - 对数组操作(
push、splice)需额外拦截defineProperty和deleteProperty,否则 length 变更或稀疏索引会绕过set - 深层嵌套对象若未提前递归代理,
obj.a.b.c = 1中的a或b为普通对象时,set根本不会触发
如何用 WeakMap + 路径快照构建轻量级撤销栈
撤销不是保存整个对象快照,而是记录“路径 + 旧值 + 操作类型”的最小元组。用 WeakMap 缓存每个被代理对象的变更历史,避免内存泄漏:
const historyStack = new WeakMap();
const proxyState = new Proxy(original, {
set(target, key, value, receiver) {
const oldVal = Reflect.get(target, key, receiver);
const path = [key]; // 实际中需递归收集完整路径,如 ['user', 'profile', 'name']
const record = { path, type: 'set', oldValue: oldVal, newValue: value };
if (!historyStack.has(target)) historyStack.set(target, []);
historyStack.get(target).push(record);
return Reflect.set(target, key, value, receiver);
}
});
关键点在于:每次 set 前必须通过 Reflect.get 显式读取旧值,且该读取本身也要被 get 拦截——这样才能确保旧值是代理后的真实值(比如经过转换器处理过的)。不要依赖 target[key],它绕过代理逻辑。
- 数组索引写入(
arr[0] = x)需在get中判断key是否为数字,再决定是否记录为path: ['0'] - 删除属性(
delete obj.x)必须由deleteProperty拦截,记录type: 'delete'和oldValue,不能靠set模拟 - 批量操作(如
Object.assign)会触发多次set,需用标志位合并为单次撤销单元,否则一次 assign 变成 5 次不可逆撤销
如何安全执行撤销而不破坏响应式链路
撤销不是简单地 obj.key = oldValue。如果目标对象已被 Vue/React 包装,直接赋值可能跳过其响应式系统,导致视图不更新;如果 oldValue 是代理对象,直接赋值还会引发无限循环(撤销 → 触发 set → 再记录 → 再撤销)。
正确做法是:撤销操作必须走同一套代理通道,并临时禁用历史记录:
function undo(target) {
const stack = historyStack.get(target);
if (!stack || stack.length === 0) return;
const last = stack.pop();
// 临时关闭记录,避免撤销动作自身又被记录
const wasRecording = target.__isRecording;
target.__isRecording = false;
if (last.type === 'set') {
Reflect.set(target, last.path[0], last.oldValue);
} else if (last.type === 'delete') {
delete target[last.path[0]];
}
target.__isRecording = wasRecording;
}
- 禁止在撤销回调里调用任何可能触发新
set的逻辑(如计算属性、副作用函数),否则栈会污染 - 对嵌套路径(
['user', 'settings', 'theme'])需用lodash.set或手写递归定位,不能只改第一层 - 撤销后应触发一次统一通知(如自定义事件
state:changed),让 UI 层决定是否重绘,而非强制刷新整个组件
哪些操作天然无法被 Proxy 撤销,必须提前规避
Proxy 无法拦截所有 JS 行为。以下操作一旦发生,撤销栈就失去意义,必须在设计阶段排除:
-
JSON.parse(JSON.stringify(obj))—— 完全脱离代理,生成裸对象,后续修改永不触发set - 直接修改原型链上的属性(
obj.__proto__.x = 1),set拦截器不生效 - 使用
Object.defineProperty重新定义属性(尤其writable: false),会覆盖代理行为 - 第三方库内部使用
Object.assign、structuredClone或for...in遍历,绕过代理访问
真正落地时,撤销能力边界取决于你能否控制所有数据入口。如果业务中存在任意一处绕过代理的赋值,整套机制就不可靠。最稳妥的做法是:把代理对象设为 freeze 后的只读外壳,所有变更必须经由显式 API(如 update(path, value))触发——这才是“具备撤销能力”的前提,而不是把 Proxy 当万能胶水硬贴。











