30 seconds of code:用 JavaScript Proxy 实现不可变对象
30 seconds of code:用 JavaScript Proxy 实现不可变对象
对象可变性(Object mutability)与 const 关键字的关系,是 JavaScript 开发者最常踩的坑之一。本文基于 30 seconds of code 仓库中的 immutable-object-proxy 代码片段,讲解如何用 ES6 的 Proxy 对象替代 Object.freeze(),实现一个对任意嵌套深度对象都生效的不可变包装器。读完你将掌握 get/set 陷阱的组合用法,并能在自己的项目中复制出一段几行代码即完成深度不可变约束的实用工具函数。
问题背景:const 并不等于不可变
在 JavaScript 中,对象默认是可变的(mutable),无论你用 const 还是 let 声明它。const 只保证变量本身不能被重新赋值,并不会阻止对象内部属性的修改:
const myObj = { a: 10, b: 20, c: 30 };
myObj.a = 12; // 成功!myObj = { a: 12, b: 20, c: 30 }
const myArr = [15, 25, 35];
myArr[1] = 28; // 成功!myArr = [15, 28, 35]
这段行为正是 deep-freeze-object 一文开头强调的核心事实:const 对象的属性可以被重新赋值,const 数组的元素可以被改写。真正的不可变需要额外的机制来保证。
从函数式编程的视角看,immutability 一文指出,不可变的核心是「数据一旦创建就不能被修改,而是基于旧数据创建新数据」:
const arr = [1, 2, 3];
const newArr = [...arr, 4];
// `newArr` 是 [1, 2, 3, 4],`arr` 仍然是 [1, 2, 3]
这里的要点是:无论用 let 还是 const,数组在技术上都是可变的,区别只在于你是否主动去修改它。而本文要讨论的,是如何从语言层面强制「不能改」。
Object.freeze() 的浅冻结局限
提到「让对象不可变」,最自然的方案就是 Object.freeze()。它可以阻止新增、删除和修改属性,但要实现深度冻结,就必须递归地对所有嵌套对象逐个调用 Object.freeze(),正如 deep-freeze-object 中的 deepFreeze 实现:
const deepFreeze = obj => {
Object.keys(obj).forEach(prop => {
if (typeof obj[prop] === 'object' && !Object.isFrozen(obj[prop])) deepFreeze(obj[prop]);
});
return Object.freeze(obj);
};
这种方式虽然有效,但在两类场景下并不理想:
- 对象嵌套非常深:每次新增一层嵌套,递归冻结的代价都在累积,而且冻结后想再修改对象结构会异常麻烦;
- 对象生命周期很短:为临时使用的对象做全量深冻结,性价比很低。
这正是原文选择 Proxy 方案 的动机所在——与其「冻结」数据,不如「拦截」一切写操作。
核心实现:用 Proxy 构造不可变包装器
immutable-object-proxy 给出的完整实现如下:
const term = {
id: 1,
value: 'hello',
properties: [{ type: 'usage', value: 'greeting' }],
};
const immutable = obj =>
new Proxy(obj, {
get(target, prop) {
return typeof target[prop] === 'object'
? immutable(target[prop])
: target[prop];
},
set() {
throw new Error('This object is immutable.');
},
});
const immutableTerm = immutable(term);
const immutableProperty = immutableTerm.properties[0];
immutableTerm.name = 'hi'; // Error: This object is immutable.
immutableTerm.id = 2; // Error: This object is immutable.
immutableProperty.value = 'pronoun'; // Error: This object is immutable.
这段代码的核心思想非常清晰,仅靠两个陷阱(trap)就完成了深度不可变约束:
set()陷阱——阻止一切写入:任何对属性赋值的操作(obj.prop = value)都会命中set陷阱,直接抛出Error('This object is immutable.')。因为返回值上抛出了异常,无论代码是否运行在严格模式,写入都会被中断,开发者能立刻感知到错误。get()陷阱——递归包装嵌套值:读取属性时,如果目标值typeof target[prop] === 'object',就对它再次调用immutable()生成新的 Proxy 返回;否则直接返回原始值。由于 JavaScript 中数组同样满足typeof [] === 'object',数组也会被一并包装,其索引赋值同样会被set陷阱拦截。
也就是说,你在任何时候通过这个包装器读取到的嵌套对象,拿到的都是被继续包装过的不可变版本,天然形成了「深度不可变」——与 deepFreeze 的「递归冻结」殊途同归,但代码量却少得多。
陷阱机制速览:为什么 get/set 就够用
要理解这个方案,需要先了解 Proxy 的基本形态。根据仓库中的 proxy-introduction 一文:
const proxy = new Proxy(target, handler);
target 是被包装的原始对象,handler 是一个包含一个或多个陷阱函数的对象,每个陷阱拦截一种对该对象的基本操作。与本文直接相关的两个陷阱是:
get(target, prop, receiver)—— 拦截target[prop]的读取;set(target, prop, value, receiver)—— 拦截target[prop] = value的赋值。
immutable() 只定义了这两个陷阱,其余操作(读取属性描述符、遍历键等)仍走默认行为。这种「最小拦截面」正是 Proxy 方案的优势之一:你只需要关心「读」和「写」两个动作,就足以构建不可变性约束,无需像冻结方案那样逐一处理每个嵌套节点。
适用场景与取舍:什么时候选 Proxy 而不是 Object.freeze()
综合原文的论述和仓库中的相关资料,两种方案的取舍可以这样理解:
Object.freeze()/deepFreeze更直接、更通用。deep-freeze-object 中明确指出它「覆盖更多使用场景、更稳健」,并且Object.isFrozen()可以随时验证对象的冻结状态,方便测试与调试。- Proxy 方案适合深度嵌套或短生命周期的对象。几行代码即可获得深度不可变的能力,不需要递归遍历,也不改变原始对象本身——原始对象依然保持可变,只是你通过
immutable()拿到的引用不可变。
同时,从源码结构可以推断出该实现存在一些边界局限,使用时需要留意:
- 未拦截
delete操作:handler 中没有定义deleteProperty陷阱,因此delete immutableTerm.id这类删除操作不会抛出异常,仍可能成功执行; - 未拦截
Object.defineProperty():同样地,通过defineProperty陷阱之外的路径修改对象仍存在理论上的操作空间; - 每次读取都会新建一个 Proxy:
get陷阱在每次访问嵌套对象时都会生成新的 Proxy 包装,对于高频读取的性能敏感场景,这一点值得权衡(这是由代码结构推断的,并非仓库实测数据)。
这些局限不是缺陷,而是「用最小代码实现核心约束」的必然取舍——如果你的需求还包含防删除、防属性重定义,就需要在此基础上补充对应的陷阱。
生态佐证:Proxy 在仓库中的其他应用
Proxy 方案在 30 seconds of code 中并非孤例,它还被用于实现多种设计模式,可以作为理解本文方案的延伸阅读:
- observable-proxy:利用
set陷阱拦截属性变更并派发CustomEvent,实现 Observer(观察者)模式——与本文恰好相反,它用set陷阱「放行写入并广播」,而本文用set陷阱「拒绝写入」; - singleton-proxy:利用
construct陷阱拦截new操作,保证类只被实例化一次; - dynamic-getter-setter-proxy:利用
get/set陷阱动态响应不存在的属性。
对比可见:get/set 陷阱是 Proxy 最常用的两个拦截点,而本文的不可变方案是它们最简洁的组合之一——只改动「写」的行为、递归增强「读」的结果。
小结
借助 Proxy 对象,只需十几行代码就能获得一个与对象形状、嵌套深度无关的不可变包装器:set() 陷阱兜底拦截一切写入并抛出异常,get() 陷阱把嵌套对象递归包装成新的不可变 Proxy。对于深度嵌套或生命周期很短的对象,这比 Object.freeze() + 递归冻结更轻量、更聚焦。若想进一步了解不可变与冻结的完整方案,可继续阅读 deep-freeze-object、immutability 以及 proxy-introduction 等相关文章。