属性描述符:4个内部槽与 Object.defineProperty
每个 JavaScript 属性背后都不只有一个值——它还携带着4个控制位,决定这个属性能否被写入、枚举、重新配置,以及删除。理解这4个内部槽,是读懂 Vue 2 响应式、Object.freeze、以及各种诡异赋值失效问题的根本前提。
🔹 Level 1 · 你需要知道的
4个属性描述符
JavaScript 每个数据属性(data property)都有4个内部槽:
| 描述符 | 类型 | 默认值(直接赋值) | 默认值(defineProperty) | 含义 |
|---|---|---|---|---|
value |
任意 | 赋值的值 | undefined |
属性的当前值 |
writable |
boolean | true |
false |
是否允许修改 value |
enumerable |
boolean | true |
false |
是否出现在 for...in / Object.keys |
configurable |
boolean | true |
false |
是否允许删除或重新定义 |
关键规律:用 obj.x = 1 这种普通赋值创建的属性,三个控制位全部为 true;用 Object.defineProperty 创建的属性,未指定的控制位全部默认为 false。
查看描述符
const obj = { x: 1 };
Object.getOwnPropertyDescriptor(obj, 'x');
// { value: 1, writable: true, enumerable: true, configurable: true }
const obj2 = {};
Object.defineProperty(obj2, 'x', { value: 1 });
Object.getOwnPropertyDescriptor(obj2, 'x');
// { value: 1, writable: false, enumerable: false, configurable: false }
// 查看所有属性的描述符
Object.getOwnPropertyDescriptors(obj);
Object.defineProperty 的使用场景
场景1:Vue 2 响应式原理
Vue 2 通过 Object.defineProperty 将普通数据属性改写为访问器属性,劫持读写操作:
function defineReactive(obj, key, val) {
const dep = new Dep(); // 依赖收集器
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
dep.depend(); // 收集当前正在计算的 watcher
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
dep.notify(); // 通知所有依赖更新
}
});
}
const data = { message: 'hello' };
defineReactive(data, 'message', data.message);
// 现在 data.message 的读写都被拦截了
场景2:创建只读常量
const config = {};
Object.defineProperty(config, 'API_URL', {
value: 'https://api.example.com',
writable: false,
enumerable: true,
configurable: false
});
config.API_URL = 'http://hack.com'; // 静默失败(非严格模式)或 TypeError(严格模式)
console.log(config.API_URL); // 'https://api.example.com'
场景3:隐藏内部属性
class EventEmitter {
constructor() {
Object.defineProperty(this, '_listeners', {
value: new Map(),
writable: false,
enumerable: false, // 不出现在 for...in 和 JSON.stringify
configurable: false
});
}
}
const emitter = new EventEmitter();
console.log(Object.keys(emitter)); // [] — _listeners 被隐藏了
JSON.stringify(emitter); // '{}'
Object.freeze vs Object.seal vs Object.preventExtensions
这三个方法都是"冻结"对象的不同程度,经常被混淆:
| 方法 | 不可添加属性 | 不可删除属性 | 不可修改值 | 描述符变化 |
|---|---|---|---|---|
Object.preventExtensions |
✓ | ✗ | ✗ | 无 |
Object.seal |
✓ | ✓ | ✗ | configurable → false |
Object.freeze |
✓ | ✓ | ✓ | configurable+writable → false |
// preventExtensions:不能加新属性,但能改现有属性
const a = { x: 1 };
Object.preventExtensions(a);
a.x = 99; // 可以
a.y = 2; // 静默失败(严格模式报错)
delete a.x; // 可以
// seal:不能加也不能删,但能改值
const b = { x: 1 };
Object.seal(b);
b.x = 99; // 可以
b.y = 2; // 失败
delete b.x; // 失败
// freeze:完全不可变(但是浅冻结!)
const c = { x: 1, nested: { y: 2 } };
Object.freeze(c);
c.x = 99; // 失败
c.nested.y = 99; // 成功!— freeze 不递归
深度冻结需要自行递归:
function deepFreeze(obj) {
Object.getOwnPropertyNames(obj).forEach(name => {
const value = obj[name];
if (typeof value === 'object' && value !== null) {
deepFreeze(value);
}
});
return Object.freeze(obj);
}
5个常见错误
错误1:以为 const 能防止属性修改
const obj = { x: 1 };
obj.x = 99; // 完全可以!const 只防止重新赋值变量本身
obj = {}; // 这才报错:Assignment to constant variable
错误2:Object.defineProperty 的默认值陷阱
// 以为只设置 value,其他保持"现有值"
const obj = { x: 1 };
Object.defineProperty(obj, 'x', { value: 2 }); // writable 变成 false!
obj.x = 3; // 静默失败
// 应该写:
Object.defineProperty(obj, 'x', { value: 2, writable: true, configurable: true, enumerable: true });
错误3:freeze 后以为数组不可变
const arr = [1, 2, 3];
Object.freeze(arr);
arr.push(4); // TypeError: Cannot add property 3, object is not extensible
arr[0] = 99; // TypeError: Cannot assign to read only property '0'
// 但是:
const arr2 = Object.freeze([{ val: 1 }]);
arr2[0].val = 99; // 成功!元素是对象,没被冻结
错误4:for...in 遍历了不期望的属性
function Animal(name) { this.name = name; }
Animal.prototype.breathe = function() {};
const dog = new Animal('Rex');
for (const key in dog) {
console.log(key); // 'name' 和 'breathe' — 原型上的属性也被枚举了
}
// 正确做法:用 hasOwnProperty 过滤,或用 for...of + Object.keys
for (const key in dog) {
if (Object.prototype.hasOwnProperty.call(dog, key)) {
console.log(key); // 只有 'name'
}
}
错误5:误以为 writable:false 能阻止原型上的属性被遮蔽
// 这个在 Level 4 详细讨论,但先记住结论:
// 原型上 writable:false 的属性,在子对象上直接赋值不会创建自有属性
// 也不会报错(非严格模式),这是最隐蔽的陷阱之一
🔸 Level 2 · 它是怎么运行的
内部槽的实际存储结构
每个属性在引擎内部是一个 Property Descriptor Record,包含不同字段的组合。关键区分:数据属性和访问器属性在内部结构上完全不同,且同一属性不能同时是两种类型。
数据属性内部槽:
┌─────────────────────────────────────────────────────────┐
│ Property Descriptor (Data) │
│ │
│ [[Value]] : 任意 ECMAScript 值 │
│ [[Writable]] : Boolean(能否修改 [[Value]]) │
│ [[Enumerable]] : Boolean(能否被枚举) │
│ [[Configurable]] : Boolean(能否被重新配置/删除) │
└─────────────────────────────────────────────────────────┘
访问器属性内部槽:
┌─────────────────────────────────────────────────────────┐
│ Property Descriptor (Accessor) │
│ │
│ [[Get]] : Function | undefined(读取调用) │
│ [[Set]] : Function | undefined(写入调用) │
│ [[Enumerable]] : Boolean │
│ [[Configurable]] : Boolean │
└─────────────────────────────────────────────────────────┘
注意:数据属性有 [[Value]] 和 [[Writable]],访问器属性有 [[Get]] 和 [[Set]]。两者共享 [[Enumerable]] 和 [[Configurable]],但前面两个字段互斥。
普通赋值 vs defineProperty 的执行路径
当你写 obj.x = 1 时,引擎走的是 [[Set]] 内部方法;当你调用 Object.defineProperty 时,走的是 [[DefineOwnProperty]]。这两条路径的行为在很多边界情况下不同。
obj.x = 1 的执行路径:
┌──────────────────────────────────────────────────────────────────────────┐
│ │
│ obj.[[Set]]('x', 1, obj) │
│ │ │
│ ▼ │
│ 1. 检查 obj 是否有自有属性 'x' │
│ │ │
│ ├── 有,且是数据属性 │
│ │ ├── writable: false → 严格模式 TypeError,非严格静默失败 │
│ │ └── writable: true → 调用 [[DefineOwnProperty]] 仅更新 value │
│ │ │
│ ├── 有,且是访问器属性 │
│ │ ├── [[Set]] 为 undefined → TypeError │
│ │ └── [[Set]] 存在 → 调用 setter 函数 │
│ │ │
│ └── 没有 → 沿原型链查找(见 ch13) │
│ └── 最终:在 obj 上创建新数据属性(writable/enumerable/ │
│ configurable 全为 true) │
└──────────────────────────────────────────────────────────────────────────┘
Object.defineProperty(obj, 'x', desc) 的执行路径:
┌──────────────────────────────────────────────────────────────────────────┐
│ │
│ ValidateAndApplyPropertyDescriptor(obj, 'x', true, desc, current) │
│ │ │
│ ▼ │
│ 1. current 是 undefined(属性不存在) │
│ └── obj 是否可扩展? │
│ ├── 否 → TypeError │
│ └── 是 → 创建属性,使用 desc 指定的值,未指定字段用默认值 │
│ │
│ 2. current 存在 │
│ └── 调用合法性检查(详见 Level 3) │
│ ├── 合法 → 更新指定字段 │
│ └── 不合法 → TypeError │
└──────────────────────────────────────────────────────────────────────────┘
configurable: false 之后允许的变更
这是最容易搞错的点。configurable: false 并不是"完全不能改":
configurable: false 时的规则表:
┌──────────────────────────────────────────────────────┬────────┐
│ 操作 │ 是否允许│
├──────────────────────────────────────────────────────┼────────┤
│ 删除属性(delete) │ ✗ │
│ 将数据属性改为访问器属性 │ ✗ │
│ 将访问器属性改为数据属性 │ ✗ │
│ 修改 [[Enumerable]] │ ✗ │
│ 修改 [[Configurable]](从 false 改为 true) │ ✗ │
│ 修改 [[Value]](当 writable:true 时) │ ✓ │
│ 将 [[Writable]] 从 true 改为 false │ ✓ │
│ 将 [[Writable]] 从 false 改为 true │ ✗ │
│ 修改访问器的 [[Get]] 或 [[Set]] │ ✗ │
└──────────────────────────────────────────────────────┴────────┘
这个规则的设计逻辑是:收紧总是允许的,放开是不允许的。writable 从 true 到 false 是收紧(相当于"自愿放弃写权限"),反向是放开。
const obj = {};
Object.defineProperty(obj, 'x', {
value: 1,
writable: true,
configurable: false
});
// 允许:把 writable 从 true 改为 false
Object.defineProperty(obj, 'x', { writable: false }); // OK
// 不允许:把 writable 从 false 改为 true
Object.defineProperty(obj, 'x', { writable: true }); // TypeError
// 不允许:修改 enumerable
Object.defineProperty(obj, 'x', { enumerable: true }); // TypeError
// 如果 writable 还是 true,允许修改 value
const obj2 = {};
Object.defineProperty(obj2, 'x', { value: 1, writable: true, configurable: false });
Object.defineProperty(obj2, 'x', { value: 2 }); // OK,value 从 1 改为 2
访问器属性的完整机制
const obj = {};
let _x = 0;
Object.defineProperty(obj, 'x', {
get() {
console.log('reading x');
return _x;
},
set(val) {
console.log(`writing x: ${val}`);
_x = val;
},
enumerable: true,
configurable: true
});
// 简写语法(等价):
const obj2 = {
get x() { return _x; },
set x(val) { _x = val; }
};
访问器属性的典型模式:计算属性、懒初始化、验证:
class Temperature {
#celsius = 0;
get fahrenheit() {
return this.#celsius * 9 / 5 + 32;
}
set fahrenheit(f) {
if (typeof f !== 'number') throw new TypeError('Temperature must be a number');
this.#celsius = (f - 32) * 5 / 9;
}
get celsius() { return this.#celsius; }
set celsius(c) {
if (c < -273.15) throw new RangeError('Below absolute zero');
this.#celsius = c;
}
}
const t = new Temperature();
t.celsius = 100;
console.log(t.fahrenheit); // 212
t.fahrenheit = 32;
console.log(t.celsius); // 0
🔺 Level 3 · 规范怎么定义的
规范 6.2.6:Property Descriptor 类型
ECMAScript 规范(ECMA-262)在 §6.2.6 定义了 Property Descriptor 类型,它是规范层面的 Record 类型(不是 JavaScript 对象,是引擎内部的抽象概念)。
规范原文(§6.2.6.1 IsAccessorDescriptor):
The abstract operation IsAccessorDescriptor takes argument Desc (a Property Descriptor or undefined) and returns a Boolean. It performs the following steps when called:
- If Desc is undefined, return false.
- If Desc has a [[Get]] field, return true.
- If Desc has a [[Set]] field, return true.
- Return false.
类似地,IsDataDescriptor 检查 [[Value]] 或 [[Writable]] 字段,IsGenericDescriptor 用于两者都不是的情况(只有 enumerable/configurable 的描述符)。
[[DefineOwnProperty]] 的合法性检查算法
ValidateAndApplyPropertyDescriptor 是整个属性描述符系统的核心。以下是规范算法(§10.1.6.3)的中文逐步解读:
输入:O(对象或 undefined),P(属性键),extensible(布尔),Desc(新描述符),current(当前描述符或 undefined)
步骤解读:
Step 1-2: 如果 current 是 undefined(属性不存在)
如果 extensible 是 false → 返回 false(不能添加属性)
否则:
如果 Desc 是 IsGenericDescriptor 或 IsDataDescriptor
→ 创建数据属性,[[Value]]、[[Writable]]、[[Enumerable]]、[[Configurable]]
用 Desc 中的值,未指定的用 false/undefined
否则(Desc 是访问器描述符)
→ 创建访问器属性,[[Get]]、[[Set]]、[[Enumerable]]、[[Configurable]]
→ 返回 true
Step 3: 如果 Desc 的所有字段都和 current 相同 → 返回 true(无变化)
Step 4: 如果 current.[[Configurable]] 是 false:
4a. Desc.[[Configurable]] 是 true → 返回 false(不能改为 configurable)
4b. Desc.[[Enumerable]] 存在且与 current.[[Enumerable]] 不同 → 返回 false
Step 5: 如果 IsGenericDescriptor(Desc) → 跳到 Step 8(只更新 configurable/enumerable)
Step 6: 如果 IsDataDescriptor(current) ≠ IsDataDescriptor(Desc)(类型转换)
如果 current.[[Configurable]] 是 false → 返回 false
否则:转换属性类型(数据 ↔ 访问器),保留 configurable/enumerable
Step 7: 如果两者都是数据属性:
如果 current.[[Configurable]] 是 false 且 current.[[Writable]] 是 false:
7a. Desc.[[Writable]] 是 true → 返回 false
7b. Desc.[[Value]] 存在且 SameValue(Desc.[[Value]], current.[[Value]]) 是 false → 返回 false
Step 8: 如果两者都是访问器属性:
如果 current.[[Configurable]] 是 false:
Desc.[[Set]] 存在且与 current.[[Set]] 不同 → 返回 false
Desc.[[Get]] 存在且与 current.[[Get]] 不同 → 返回 false
Step 9: 如果 O 不是 undefined(真实对象操作,不是仅验证)
→ 将 Desc 中存在的字段更新到属性
Step 10: 返回 true
注意:Step 7b 用的是 SameValue(即 Object.is),不是 ===。这意味着 NaN 和 NaN 是相同的,+0 和 -0 不同。
Object.defineProperty 的规范实现
规范 §20.1.2.4 Object.defineProperty(O, P, Attributes):
1. 如果 O 不是对象 → 抛出 TypeError
2. key ← ToPropertyKey(P)
3. desc ← ToPropertyDescriptor(Attributes)
(将普通 JS 对象转换为内部 Property Descriptor Record)
4. 调用 O.[[DefineOwnProperty]](key, desc)
5. 如果结果是 false → 抛出 TypeError
6. 返回 O
ToPropertyDescriptor(Obj) 的关键步骤(§6.2.6.6):
// 规范算法的 JS 伪代码表示
function ToPropertyDescriptor(Obj) {
if (typeof Obj !== 'object') throw new TypeError();
const desc = {};
if ('enumerable' in Obj) desc.enumerable = Boolean(Obj.enumerable);
if ('configurable' in Obj) desc.configurable = Boolean(Obj.configurable);
if ('value' in Obj) desc.value = Obj.value;
if ('writable' in Obj) desc.writable = Boolean(Obj.writable);
if ('get' in Obj) {
if (typeof Obj.get !== 'function' && Obj.get !== undefined)
throw new TypeError();
desc.get = Obj.get;
}
if ('set' in Obj) {
if (typeof Obj.set !== 'function' && Obj.set !== undefined)
throw new TypeError();
desc.set = Obj.set;
}
// 不变量检查
if ('get' in desc || 'set' in desc) {
if ('value' in desc || 'writable' in desc)
throw new TypeError('accessor and data descriptor are mutually exclusive');
}
return desc;
}
💎 Level 4 · 边界与陷阱
陷阱1:Object.freeze 之后 Array.push 为何报 TypeError
这是最常见的"我以为我懂 freeze"的场景:
const arr = [1, 2, 3];
Object.freeze(arr);
arr.push(4); // TypeError: Cannot add property 3, object is not extensible
完整推导:
Array.prototype.push 的规范实现(§23.1.3.20)等价于:
Array.prototype.push = function(...items) {
let len = this.length; // 读取 length
for (const item of items) {
this[len] = item; // 1. 设置索引属性
len++;
}
this.length = len; // 2. 更新 length
return len;
};
Object.freeze 对数组做了什么:
Object.freeze(arr);
// 等价于:
Object.preventExtensions(arr); // 不能添加新属性(索引 3 不存在)
// 对每个属性:
Object.defineProperty(arr, '0', { writable: false, configurable: false });
Object.defineProperty(arr, '1', { writable: false, configurable: false });
Object.defineProperty(arr, '2', { writable: false, configurable: false });
Object.defineProperty(arr, 'length', { writable: false }); // length 也变为不可写!
push 操作中:
this[3] = 4:属性3不存在,但preventExtensions禁止添加新属性 → TypeError- 即使能绕过第一步,
this.length = 4:length 是writable: false→ TypeError
// 验证 length 的描述符:
Object.getOwnPropertyDescriptor(arr, 'length');
// { value: 3, writable: false, enumerable: false, configurable: false }
真实 bug 案例:Redux store 使用 Object.freeze 防止直接修改 state,开发者在 reducer 中误用 state.list.push(item) 而不是 [...state.list, item],导致严格模式下测试通过(TypeError 被测试框架捕获)但生产环境静默失败(修改了 state 但 UI 不更新),因为生产环境用了不同的 freeze 策略。
陷阱2:configurable:false 后 writable 单向可改
const obj = {};
Object.defineProperty(obj, 'x', {
value: 42,
writable: true,
configurable: false,
enumerable: true
});
// 第一步:writable true → false(允许,属于"收紧")
Object.defineProperty(obj, 'x', { writable: false });
// 现在 x 的描述符:{ value: 42, writable: false, configurable: false, enumerable: true }
// 第二步:尝试 writable false → true(不允许)
Object.defineProperty(obj, 'x', { writable: true });
// TypeError: Cannot redefine property: x
// 第三步:即使 writable: false,value 还能被 configurable:false+writable:false 的属性修改吗?
// 不能,但这里有个微妙之处:
Object.defineProperty(obj, 'x', { value: 43 });
// TypeError:因为现在 writable 已经是 false,不能修改 value
规范解释:规范的设计理念是安全性单调递减(monotonically decreasing)。一旦你放弃了某个权限,就无法再拿回来。configurable: false 是放弃"重新配置"的权限,writable: true → false 是进一步放弃"写入"的权限,这符合最小权限原则。
陷阱3:访问器属性与数据属性不能共存
// 以下代码会抛出 TypeError
Object.defineProperty({}, 'x', {
value: 1,
get() { return 1; }
});
// TypeError: Invalid property descriptor.
// Cannot both specify accessors and a value or writable attribute
// 同样不合法:
Object.defineProperty({}, 'x', {
writable: true,
set(v) {}
});
// TypeError
为什么规范要这样设计? 数据属性的 [[Value]] 和访问器属性的 [[Get]]/[[Set]] 在概念上是互斥的:数据属性直接存储值,访问器属性通过函数间接管理值。同时指定两者会产生歧义——赋值时应该调用 setter,还是直接修改 value?
// 正确方式:显式选择属性类型
// 数据属性:
Object.defineProperty(obj, 'x', { value: 1, writable: true });
// 访问器属性:
Object.defineProperty(obj, 'x', {
get() { return this._x; },
set(v) { this._x = v; }
});
// 可以从数据属性转换为访问器属性,但前提是 configurable: true
const obj = {};
Object.defineProperty(obj, 'x', { value: 1, writable: true, configurable: true });
Object.defineProperty(obj, 'x', { get() { return 42; } }); // 转换成功
陷阱4:原型上 writable:false 属性的赋值行为
这是最隐蔽的陷阱,很多有经验的开发者也会踩:
// 在原型上定义不可写属性
const proto = {};
Object.defineProperty(proto, 'x', {
value: 1,
writable: false,
configurable: true,
enumerable: true
});
const child = Object.create(proto);
// 非严格模式:
child.x = 99; // 静默失败!
console.log(child.x); // 1 — 读的是原型上的值
console.log(child.hasOwnProperty('x')); // false — 没有创建自有属性
// 严格模式:
'use strict';
child.x = 99; // TypeError: Cannot assign to read only property 'x' of object '#<Object>'
完整的 [[Set]] 算法解读:
当执行 child.x = 99 时:
- 检查
child是否有自有属性x→ 没有 - 取
child.[[Prototype]]即proto,检查proto是否有x→ 有 proto.x是数据属性,且writable: false- 不论 receiver(child)是否可写,直接返回 false(不创建自有属性)
- 非严格模式:静默失败;严格模式:抛出 TypeError
关键点:这和 child 自身的 writable 无关——child 甚至没有这个属性。决定行为的是原型链上找到的那个属性的 writable 值。
// 对比:原型上 writable:true 时
const proto2 = {};
Object.defineProperty(proto2, 'y', { value: 1, writable: true, configurable: true });
const child2 = Object.create(proto2);
child2.y = 99; // 成功!在 child2 上创建自有属性,遮蔽原型
console.log(child2.y); // 99 — 自有属性
console.log(proto2.y); // 1 — 原型未变
console.log(child2.hasOwnProperty('y')); // true
陷阱5:Vue 2 为何无法检测数组下标赋值
这是 Vue 2 最著名的局限性,根本原因在于 Object.defineProperty 的工作方式:
// Vue 2 初始化数据时做了什么:
function observe(data) {
Object.keys(data).forEach(key => {
defineReactive(data, key, data[key]);
});
}
// 对于数组:
const vm = new Vue({
data: { list: [1, 2, 3] }
});
// Vue 2 对 list 本身(作为属性)定义了 getter/setter
// 但 list[0]、list[1]、list[2] 没有被 defineProperty
问题的根源:
// Vue 2 能检测的:
vm.list = [4, 5, 6]; // 触发 list 的 setter,Vue 重新 observe 新数组
// Vue 2 不能检测的:
vm.list[0] = 99; // 直接修改数组索引,没有 setter 被触发
vm.list.length = 1; // 同样无法检测
// 为什么不对每个索引 defineProperty?
// Vue 2 作者(尤雨溪)的解释:
// 1. 性能问题:数组可能有成千上万个元素
// 2. 无法检测未来添加的元素(push 之后的新索引)
// 3. 即使定义了,也只能检测"替换",无法检测"新增"
Vue 2 的解决方案:重写数组的 7 个变异方法:
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
const original = arrayProto[method];
Object.defineProperty(arrayMethods, method, {
value(...args) {
const result = original.apply(this, args);
const ob = this.__ob__; // 观察者实例
let inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args; break;
case 'splice':
inserted = args.slice(2); break;
}
if (inserted) ob.observeArray(inserted); // 对新元素建立响应式
ob.dep.notify(); // 通知更新
return result;
},
enumerable: false,
writable: true,
configurable: true
});
});
Vue 3 的解决方案:用 Proxy 代替 Object.defineProperty(见 ch15),Proxy 的 set 陷阱可以拦截所有属性设置,包括任意索引和 length。
// Vue 3 的正确用法(不存在这个限制):
const state = reactive({ list: [1, 2, 3] });
state.list[0] = 99; // 触发更新!
state.list.length = 1; // 触发更新!
state.list.push(4); // 触发更新!
本章小结
-
每个属性有4个内部槽:数据属性的 value/writable/enumerable/configurable;访问器属性用 get/set 替换前两个。普通赋值创建的属性三个控制位全为 true,
defineProperty未指定的全为 false。 -
configurable:false 是单向门:一旦设置就不能撤销,writable 只能从 true 改为 false,不能反向。enumerable 和类型(数据/访问器)都无法再改变。
-
Object.freeze 是浅冻结:对嵌套对象无效,需要递归实现深度冻结。freeze 后数组 push 报 TypeError 是因为 length 属性也变为 writable:false。
-
原型链上的 writable:false 会阻止子对象创建自有属性:这是非严格模式下最隐蔽的"静默失败"来源。
-
Vue 2 的数组限制来自 Object.defineProperty 本身:它无法检测数组下标赋值,Vue 2 通过重写数组变异方法绕过这个限制,Vue 3 用 Proxy 从根本上解决了这个问题。