一、开篇引入:为什么需要“不可变对象”?
做前端开发的同学肯定遇到过这种坑:明明没改数据,页面却乱跳;或者多人协作时,不知道谁偷偷改了公共对象,排查半天找不到问题根源。
这时候“不可变对象”就能救场——它像给数据加了把锁,一旦创建就不能修改,想改?直接抛错提醒,从源头避免“暗箱操作”。
比如状态管理场景(Vuex/Redux)、复杂表单数据保护,甚至只是不想让第三方库篡改你的数据,都需要不可变对象。今天就手把手教你实现一个能精准拦截修改的makeImmutable函数。
二、基础铺垫:Proxy是核心工具
要实现不可变对象,离不开ES6的Proxy——它就像给对象装了个“安检门”,所有对对象的操作(读、写、调用方法)都会先经过这个门,我们可以在门后定义“规则”:哪些操作允许,哪些操作拦截。
先明确两个关键概念,后续代码会用到:
- 目标对象(target):被Proxy代理的原始对象(比如传入的数组或普通对象);
- 拦截陷阱(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.get和receiver的作用
代码里用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句话总结)
- 用
Proxy做“安检门”,拦截对象的读写操作; - 数组要双重拦截(改索引+危险方法),普通对象拦截改属性;
- 嵌套结构递归加锁,基本类型直接返回。
延伸思考:进一步优化方向
- 支持深拷贝:当前代码代理的是原始对象,如果原始对象被外部修改,代理对象也会受影响。可以先深拷贝原始对象,再代理拷贝后的对象,彻底隔离;
- 自定义错误信息:现在错误信息是固定的,可增加参数让用户自定义(比如
makeImmutable(obj, { errMsg: '禁止修改!' })); - 兼容旧环境:
Proxy不支持IE,如果需要兼容,可以用Object.freeze兜底(但Object.freeze只能冻结表层,且不会抛错,体验不如Proxy)。
通过这个函数,不仅能解决“数据防篡改”的问题,还能加深对Proxy和原型链的理解——毕竟实战中用到Proxy的场景(如Vue3响应式、数据校验)还有很多,掌握这个基础,后续学其他框架原理也会更轻松。