JS实战:手写不可变对象函数,从原理到落地

75 阅读6分钟

一、开篇引入:为什么需要“不可变对象”?

做前端开发的同学肯定遇到过这种坑:明明没改数据,页面却乱跳;或者多人协作时,不知道谁偷偷改了公共对象,排查半天找不到问题根源。

这时候“不可变对象”就能救场——它像给数据加了把锁,一旦创建就不能修改,想改?直接抛错提醒,从源头避免“暗箱操作”。

比如状态管理场景(Vuex/Redux)、复杂表单数据保护,甚至只是不想让第三方库篡改你的数据,都需要不可变对象。今天就手把手教你实现一个能精准拦截修改的makeImmutable函数。

二、基础铺垫:Proxy是核心工具

要实现不可变对象,离不开ES6的Proxy——它就像给对象装了个“安检门”,所有对对象的操作(读、写、调用方法)都会先经过这个门,我们可以在门后定义“规则”:哪些操作允许,哪些操作拦截。

先明确两个关键概念,后续代码会用到:

  1. 目标对象(target):被Proxy代理的原始对象(比如传入的数组或普通对象);
  2. 拦截陷阱(trap):Proxy的核心配置,比如get陷阱拦截“读属性”,set陷阱拦截“写属性”。

三、核心实现:分场景拦截修改

需求里要处理三种修改场景,我们得针对性设计拦截逻辑。先上完整代码,再逐段拆解:

function makeImmutable(obj) {
  // 第一步:定义数组的“危险方法”(会修改原数组的方法)
  const mutableArrayMethods = ['pop', 'push', 'shift', 'unshift', 'splice', 'sort', 'reverse'];

  // 场景1:如果是数组,要拦截“改索引”和“调危险方法”
  if (Array.isArray(obj)) {
    return new Proxy(obj, {
      // 拦截“修改索引”(比如 arr[0] = 10)
      set(target, index, value) {
        throw `Error Modifying Index: ${index}`;
      },
      // 拦截“读属性/方法”(重点拦截危险方法)
      get(target, prop, receiver) {
        // 若访问的是危险方法,返回一个“抛错函数”
        if (mutableArrayMethods.includes(prop)) {
          return function () {
            throw `Error Calling Method: ${prop}`;
          };
        }
        // 非危险属性:正常读取,且嵌套对象要递归加锁
        const value = Reflect.get(target, prop, receiver);
        return typeof value === 'object' && value !== null ? makeImmutable(value) : value;
      }
    });
  }

  // 场景2:如果是普通对象,拦截“改属性”
  if (typeof obj === 'object' && obj !== null) {
    return new Proxy(obj, {
      // 拦截“修改属性”(比如 obj.name = '张三')
      set(target, key, value) {
        throw `Error Modifying: ${key}`;
      },
      // 读属性时,嵌套对象递归加锁(比如 obj.info = {age: 18},info也要不可变)
      get(target, key, receiver) {
        const value = Reflect.get(target, key, receiver);
        return typeof value === 'object' && value !== null ? makeImmutable(value) : value;
      }
    });
  }

  // 场景3:如果是基本类型(数字、字符串等),直接返回
  // 因为JS基本类型本身就是不可变的(比如 let a = 1; a = 2 是重新赋值,不是修改1本身)
  return obj;
}

3.1 关键逻辑1:数组的双重拦截

数组比普通对象特殊,有两种修改方式:改索引(arr[0] = 2)和调用方法(arr.push(3)),所以要双重拦截:

(1)用set陷阱拦截“改索引”

当试图修改数组索引时(比如immutableArr[1] = 10),会触发set陷阱,直接抛出“修改索引”的错误。

(2)用get陷阱拦截“危险方法”

数组的push/pop等方法会修改原数组,我们要拦截这些方法的访问:

  • 当访问immutableArr.push时,get陷阱会检测到prop'push'(在危险方法列表里);
  • 不返回原生的push方法,而是返回一个新函数——这个函数一调用就抛错,彻底阻止修改。

举个例子:

const arr = makeImmutable([1, 2, 3]);
arr.push(4); // 调用的是我们返回的“抛错函数”,直接抛出 Error Calling Method: push

3.2 关键逻辑2:嵌套对象递归加锁

如果对象里嵌套了对象(比如{ info: { age: 18 } }),只锁外层是不够的——用户可能绕开外层改内层(immutableObj.info.age = 20)。

所以在get陷阱里加了个关键操作:读取属性时,若属性值是对象/数组,递归调用makeImmutable,给嵌套结构也加上“锁”。

测试一下嵌套场景:

const obj = makeImmutable({ info: { age: 18 } });
obj.info.age = 20; // 触发内层对象的set陷阱,抛出 Error Modifying: age

3.3 关键逻辑3:Reflect.getreceiver的作用

代码里用Reflect.get(target, prop, receiver)代替target[prop],核心是为了保持this指向正确

比如数组的slice方法(非危险方法),内部会用到this

  • 如果用target[prop]slice里的this会指向原始数组(target),可能绕过代理;
  • Reflect.get(..., receiver)this会指向代理对象(receiver),确保即使方法内部操作,也受Proxy管控。

四、实战测试:验证三种错误场景

写好函数后,必须测试三种错误是否正常抛出,确保功能没问题:

测试1:修改普通对象的键

const user = makeImmutable({ name: '张三' });
user.name = '李四'; // 抛出 Error Modifying: name ✅

测试2:修改数组的索引

const arr = makeImmutable([1, 2, 3]);
arr[0] = 10; // 抛出 Error Modifying Index: 0 ✅

测试3:调用数组的危险方法

const arr = makeImmutable([1, 2, 3]);
arr.sort(); // 抛出 Error Calling Method: sort ✅
arr.pop();  // 抛出 Error Calling Method: pop ✅

测试4:嵌套对象修改(验证递归生效)

const data = makeImmutable({ list: [1, 2], info: { age: 18 } });
data.list.push(3);    // 抛出 Error Calling Method: push ✅
data.info.age = 20;   // 抛出 Error Modifying: age ✅

五、避坑指南:这些细节别踩雷

坑1:忘记处理null

typeof null会返回'object',如果不单独判断obj !== null,会把null当成对象处理,导致错误。

代码里特意加了typeof obj === 'object' && obj !== null,就是为了排除null

坑2:基本类型没必要代理

JS的基本类型(数字、字符串、布尔值)本身就是不可变的,比如let a = 1; a = 2是重新赋值,不是修改1这个值。

所以代码最后直接返回基本类型,没必要画蛇添足加Proxy。

坑3:危险方法列表漏了

需求明确了7个会修改数组的方法,如果漏写某个(比如reverse),用户调用immutableArr.reverse()就会修改原数组,导致功能失效。

建议直接复制需求里的方法列表,避免手动输入出错。

六、总结升华:核心思路+延伸思考

核心思路(3句话总结)

  1. Proxy做“安检门”,拦截对象的读写操作;
  2. 数组要双重拦截(改索引+危险方法),普通对象拦截改属性;
  3. 嵌套结构递归加锁,基本类型直接返回。

延伸思考:进一步优化方向

  1. 支持深拷贝:当前代码代理的是原始对象,如果原始对象被外部修改,代理对象也会受影响。可以先深拷贝原始对象,再代理拷贝后的对象,彻底隔离;
  2. 自定义错误信息:现在错误信息是固定的,可增加参数让用户自定义(比如makeImmutable(obj, { errMsg: '禁止修改!' }));
  3. 兼容旧环境Proxy不支持IE,如果需要兼容,可以用Object.freeze兜底(但Object.freeze只能冻结表层,且不会抛错,体验不如Proxy)。

通过这个函数,不仅能解决“数据防篡改”的问题,还能加深对Proxy和原型链的理解——毕竟实战中用到Proxy的场景(如Vue3响应式、数据校验)还有很多,掌握这个基础,后续学其他框架原理也会更轻松。