先记住这个答案
Object.freeze(obj) 会让 obj 自己的属性不能新增、删除和修改,但它只处理这一层。如果某个属性值是对象,冻结的只是「这个属性指向哪个对象」,被指向的对象内部仍然可以改。要做到整棵对象树都不可变,就要自己写深度冻结:遍历每个属性,是对象就递归冻结,同时记录已经处理过的对象,避免遇到循环引用时无限递归。
freeze只冻第一层,嵌套对象不受影响- 冻结后不能增、删、改属性,也不能换原型
- 非严格模式静默失败,严格模式抛
TypeError - 深度冻结要递归,并用
WeakSet防循环引用
冻住了外层,里层还能改
最常见的场景是把配置对象冻结起来,防止被人误改。看看实际效果。
const config = { api: '/v1', retry: { times: 3 } }
Object.freeze(config)
config.api = '/v2' // 非严格模式下静默失败
config.retry.times = 5 // 能改成功,retry 没被冻结
console.log(config.api, config.retry.times)
console.log(Object.isFrozen(config), Object.isFrozen(config.retry))查看输出与解释
/v1 5
true falseconfig.api 没改掉,而且没有任何报错。这是非严格模式下的行为,写入失败时静默忽略。在严格模式(ES Module 和 class 内部默认就是严格模式)下,同样的赋值会抛 TypeError,更容易发现问题。
config.retry.times 改成功了,因为 retry 属性里存的只是一个引用。freeze 让这个引用不能换成别的对象,但引用指向的对象本身没被冻结,Object.isFrozen(config.retry) 返回 false 就说明了这点。
freeze 具体做了三件事:把对象标记为不可扩展(不能加新属性),把所有自有属性设为不可配置(不能删、不能改描述符),把数据属性设为不可写。另外冻结后原型也不能再换,Object.setPrototypeOf 会失败。
手写深度冻结
思路很直接:先冻结当前对象,再对每个属性值递归调用。难点在细节。
function deepFreeze(obj, seen = new WeakSet()) {
// 原始值不用冻结;处理过的对象直接返回,防止循环引用死循环
if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) return obj
if (seen.has(obj)) return obj
seen.add(obj)
// Reflect.ownKeys 能拿到 Symbol 键和不可枚举的键
for (const key of Reflect.ownKeys(obj)) {
const desc = Object.getOwnPropertyDescriptor(obj, key)
if ('value' in desc) deepFreeze(desc.value, seen) // 跳过 getter,不去触发它
}
return Object.freeze(obj)
}这里有三个容易漏的地方。第一,用 Reflect.ownKeys 而不是 Object.keys,后者会漏掉 Symbol 键和不可枚举的键。第二,用描述符取值而不是直接 obj[key],因为属性可能是 getter,直接读会执行 getter 里的代码,甚至产生副作用。第三,用 WeakSet 记录访问过的对象,a.self = a 这种循环引用不加判断就会栈溢出。
- 不要用
Object.isFrozen代替seen - 有人会写成「已经冻结的就跳过」。但对象可能在别处被浅冻结过,外层是冻结状态,里层还没冻,这样一跳过里层就漏了。用
WeakSet记录「自己处理过」才可靠。
什么时候真的需要冻结
冻结是运行时的保护,每一层都要遍历一次,大对象深度冻结有成本。常见用法是冻结全局常量、枚举对象、共享的默认配置,防止某处代码不小心改了影响全局。
状态管理里的不可变数据是另一种思路。Redux Toolkit 用的 Immer 默认会冻结产出的 state,有人绕过它直接修改时,修改不会生效(严格模式下直接报错),问题能尽早暴露。而 TypeScript 的 readonly 和 as const 只在编译期检查,运行时对象还是能改,和 freeze 是互补的关系。
容易答错的地方
- 以为
const加freeze就完全不可变 const只保证变量不能重新赋值,freeze只保证第一层属性不能改。两者叠加,嵌套对象还是能改。要完全不可变只能深度冻结。- 以为冻结失败会报错
- 非严格模式下给冻结对象赋值、删属性都是静默失败,代码继续往下跑。排查「为什么赋值没生效」时,冻结对象是一个常被忽略的原因。
- 以为
freeze能冻住Map和Set的内容 Map和Set的数据存在内部结构里,不是对象属性。冻结一个Map之后,map.set()照样能加数据。
面试官还会怎么问?
freeze、seal、preventExtensions 有什么区别?
preventExtensions 只禁止新增属性。seal 在此基础上再禁止删除和重新配置,但已有属性的值还能改。freeze 最严格,连值也不能改。三者都是浅的。
冻结的数组能 push 吗?
不能。push 要新增索引并修改 length,都会失败,而且 push 内部按严格规则执行,即使在非严格模式下也会抛 TypeError。
冻结了的对象还想修改,怎么办?
冻结不可逆,只能复制一份再改,比如 { ...config, api: '/v2' }。展开得到的新对象没有被冻结,嵌套对象还是和原对象共用的同一个,如果原来深度冻结过,改嵌套属性前也要一起复制。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。