如何利用 Proxy 劫持状态变更实现一套具备“撤销/重做”功能的业务数据层

阿墨同学_6159

阿墨同学_6159

2026-05-10

761人浏览

原创

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

如何利用 proxy 劫持状态变更实现一套具备“撤销/重做”功能的业务数据层

为什么直接用 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 → 再记录 → 再撤销)。

Umbrel Proxy Manager
Umbrel Proxy Manager

高效管理与交互 Umbrel 代理服务,支持 Docker 容器。自动发现运行服务,将内部 Docker IP 映射为可访问的外部地址。

下载

正确做法是:撤销操作必须走同一套代理通道,并临时禁用历史记录:

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 当万能胶水硬贴。

相关专题

更多
js获取数组长度的方法
js获取数组长度的方法

在js中,可以利用array对象的length属性来获取数组长度,该属性可设置或返回数组中元素的数目,只需要使用“array.length”语句即可返回表示数组对象的元素个数的数值,也就是长度值。php中文网还提供JavaScript数组的相关下载、相关课程等内容,供大家免费下载使用。

2023.06.20

5566

5

js刷新当前页面
js刷新当前页面

js刷新当前页面的方法:1、reload方法,该方法强迫浏览器刷新当前页面,语法为“location.reload([bForceGet]) ”;2、replace方法,该方法通过指定URL替换当前缓存在历史里(客户端)的项目,因此当使用replace方法之后,不能通过“前进”和“后退”来访问已经被替换的URL,语法为“location.replace(URL) ”。php中文网为大家带来了js刷新当前页面的相关知识、以及相关文章等内容

2023.07.04

1349

3

js四舍五入
js四舍五入

js四舍五入的方法:1、tofixed方法,可把 Number 四舍五入为指定小数位数的数字;2、round() 方法,可把一个数字舍入为最接近的整数。php中文网为大家带来了js四舍五入的相关知识、以及相关文章等内容

2023.07.04

5484

6

js删除节点的方法
js删除节点的方法

js删除节点的方法有:1、removeChild()方法,用于从父节点中移除指定的子节点,它需要两个参数,第一个参数是要删除的子节点,第二个参数是父节点;2、parentNode.removeChild()方法,可以直接通过父节点调用来删除子节点;3、remove()方法,可以直接删除节点,而无需指定父节点;4、innerHTML属性,用于删除节点的内容。

2023.09.01

1020

4

JavaScript转义字符
JavaScript转义字符

JavaScript中的转义字符是反斜杠和引号,可以在字符串中表示特殊字符或改变字符的含义。本专题为大家提供转义字符相关的文章、下载、课程内容,供大家免费下载体验。

2023.09.04

1976

5

js生成随机数的方法
js生成随机数的方法

js生成随机数的方法有:1、使用random函数生成0-1之间的随机数;2、使用random函数和特定范围来生成随机整数;3、使用random函数和round函数生成0-99之间的随机整数;4、使用random函数和其他函数生成更复杂的随机数;5、使用random函数和其他函数生成范围内的随机小数;6、使用random函数和其他函数生成范围内的随机整数或小数。

2023.09.04

3845

4

如何启用JavaScript
如何启用JavaScript

JavaScript启用方法有内联脚本、内部脚本、外部脚本和异步加载。详细介绍:1、内联脚本是将JavaScript代码直接嵌入到HTML标签中;2、内部脚本是将JavaScript代码放置在HTML文件的`<script>`标签中;3、外部脚本是将JavaScript代码放置在一个独立的文件;4、外部脚本是将JavaScript代码放置在一个独立的文件。

2023.09.12

5033

6

Js中Symbol类详解
Js中Symbol类详解

javascript中的Symbol数据类型是一种基本数据类型,用于表示独一无二的值。Symbol的特点:1、独一无二,每个Symbol值都是唯一的,不会与其他任何值相等;2、不可变性,Symbol值一旦创建,就不能修改或者重新赋值;3、隐藏性,Symbol值不会被隐式转换为其他类型;4、无法枚举,Symbol值作为对象的属性名时,默认是不可枚举的。

2023.09.20

3300

5

java访问控制修饰符介绍
java访问控制修饰符介绍

java访问控制修饰符有四种,分别是public、protected、private、默认访问修饰符。详细介绍:1、public,public是最宽松的访问控制修饰符,被修饰的类、方法和变量可以被任何其他类访问,当一个类、方法或变量被声明为public时,它们可以在任何地方被访问,无论是同一个包中的类还是不同包中的类;2、protected修饰符等等。

2023.09.20

1048

7

热门下载

更多
网站特效
/
网站源码
/
网站素材
/
前端模板

精品课程

更多
热门推荐
/
最新课程
phpStudy极速入门视频教程
phpStudy极速入门视频教程

共6课时 | 54.7万人学习

独孤九贱(4)_PHP视频教程
独孤九贱(4)_PHP视频教程

共89课时 | 134.1万人学习