描述符与 Symbol:对象属性还有哪些规则
对象属性除了名称和值,还携带规则。能不能修改、能不能删除、会不会被枚举,都由属性描述符参与决定。理解这些规则,就能解释“明明看得到,展开对象时却没有了”等现象。
数据属性与访问器属性
数据属性含 value、writable,访问器属性含 get、set;两者都可以有 enumerable 和 configurable。不能在同一个描述符里同时提供 value 与 get,否则会抛 TypeError。
const card = { title: '闭包' };
Object.defineProperty(card, 'id', { value: 17 });
console.log(card.id); // 17
console.log(Object.keys(card).join(',')); // title
console.log(Object.getOwnPropertyDescriptor(card, 'id'));
// { value: 17, writable: false, enumerable: false, configurable: false }创建新属性时,defineProperty 省略的三个布尔标志默认都是 false。字面量创建的普通数据属性则通常全部为 true。修改已有属性的描述符时,未提供的字段保留已有值,不要把“创建时的默认值”套到所有调用上。
configurable: false 阻止删除、改变可枚举性和在数据/访问器之间切换。但如果数据属性仍可写,可以改变值,也能把 writable 从 true 收紧为 false;反向放宽不允许。规则不是一句“描述符完全锁死”就能概括。
不可配置可以分阶段收紧
const state = {};
Object.defineProperty(state, 'version', {
value: 1, writable: true, configurable: false
});
Object.defineProperty(state, 'version', { value: 2 });
console.log(state.version); // 2
Object.defineProperty(state, 'version', { writable: false });
console.log(Reflect.defineProperty(state, 'version', { writable: true })); // false
console.log(Reflect.defineProperty(state, 'version', { value: 3 })); // false
console.log(Reflect.defineProperty(state, 'version', { value: 2 })); // true第一次定义只承诺“属性结构不可重新配置”,尚允许值改变;第二次更新符合这个承诺。收紧 writable 以后,值也固定了,不能再放宽。最后一次仍成功,因为再次声明相同值没有破坏既有约束。这种比较采用 SameValue,即 Object.is 的相等语义:它区分正零与负零,也能把 NaN 视为相同值。
Object.defineProperty 在失败时抛错,Reflect.defineProperty 对正常的“无法定义”返回 false;描述符格式非法等异常仍可能抛出。Reflect 不是把所有异常吞掉的接口。
枚举与复制分别挑选什么
| 操作 | 自有字符串键 | 自有 Symbol 键 | 继承属性 | 读取 getter |
|---|---|---|---|---|
Object.keys | 可枚举 | 否 | 否 | 否 |
Object.getOwnPropertyNames | 全部 | 否 | 否 | 否 |
Object.getOwnPropertySymbols | 否 | 全部 | 否 | 否 |
Reflect.ownKeys | 全部 | 全部 | 否 | 否 |
对象展开、Object.assign | 可枚举 | 可枚举 | 否 | 是 |
for...in | 可枚举 | 否 | 可枚举字符串键 | 仅遍历键时不读取 |
普通对象自有键的常见顺序是:数组索引字符串按数值升序,其他字符串按创建顺序,Symbol 按创建顺序。'01' 不是数组索引字符串。代理对象和其他特殊对象还需要看各自规则,不要把任意枚举场景都当成同一个排序算法。
let reads = 0;
const source = { get n() { return ++reads; } };
const snapshot = { ...source };
const preserved = Object.create(
Object.getPrototypeOf(source),
Object.getOwnPropertyDescriptors(source)
);
console.log(snapshot.n, snapshot.n); // 1 1
console.log(preserved.n); // 2展开得到的是读取时的值,访问器被变成普通数据属性。保留描述符的副本保留 getter 函数,但这仍是浅层复制:闭包变量 reads 是共享的。Object.assign 会对目标执行赋值,可能触发目标 setter;对象展开则定义新对象属性。两者都不是深拷贝工具。
复制值时,源 getter 与目标 setter 分别扮演什么角色
const log = [];
const input = { get count() { log.push('读取源'); return 4; } };
const target = { set count(value) { log.push(`写入目标:${value}`); } };
Object.assign(target, input);
const spread = { ...target, ...input };
console.log(log.join(',')); // 读取源,写入目标:4,读取源
console.log(spread.count); // 4assign 从源读取值后,把值赋给既有 target,因此调用它的 setter。展开创建自己的新对象:读取 target.count 时它只有 setter,没有 getter,得到 undefined;随后 input.count 的值 4 覆盖前一项。新对象上的 count 是数据属性,没有沿用 target 的 setter。
如果 getter 读取会触发网络、日志或变化状态,“复制对象”也会有副作用;它不是一条纯粹的内存搬运命令。如果要保留 getter 定义,使用描述符;如果要保存当时结果,读取成快照。这项选择应在数据语义上决定,不应只比较写法长度。
封闭对象并不等于冻结对象图
preventExtensions 禁止添加新属性;seal 进一步令自有属性不可配置;freeze 再将自有数据属性设为不可写。这三者都是浅层操作。
const config = Object.freeze({ theme: { color: '蓝' } });
config.theme.color = '绿';
console.log(config.theme.color); // 绿
console.log(Reflect.set(config, 'theme', {})); // false冻结根对象没有冻结 theme。访问器 setter、私有字段、Map 的内部条目也不是普通数据属性的 writable 控制范围。真正的深冻结要处理循环引用、Symbol、访问器和内建容器,不能随意递归几层就宣称不可变。
Symbol 提供不会重名的键与协议入口
const localA = Symbol('mark');
const localB = Symbol('mark');
const sharedA = Symbol.for('coderpath.mark');
console.log(localA === localB); // false
console.log(sharedA === Symbol.for('coderpath.mark')); // true
const item = { name: '迭代', [localA]: true };
console.log(Reflect.ownKeys(item).length); // 2描述字符串不是身份;Symbol.for 从共享注册表取键,Symbol.keyFor 只对注册表中的 Symbol 有效。Symbol 不等于私有:拿到对象的人仍能通过 getOwnPropertySymbols 发现键。它主要解决命名碰撞。
内建 Symbol 还是语言协议入口:Symbol.iterator 决定如何被迭代,Symbol.asyncIterator 对应异步迭代,Symbol.toPrimitive 决定对象转原始值,Symbol.hasInstance 影响 instanceof。Symbol.toStringTag 可改变对象的展示标签,因此 Object.prototype.toString 也不是防伪证书。Symbol.isConcatSpreadable 影响 concat 是否展开;Symbol.species 是部分内建派生结果的构造定制点,使用前要查具体方法,不能假设所有复制方法都遵循它。
协议键如何让对象参与运算
const price = {
[Symbol.toPrimitive](hint) {
console.log('转换要求', hint);
return hint === 'string' ? '12元' : 12;
}
};
console.log(String(price)); // 转换要求 string;12元
console.log(price + 1); // 转换要求 default;13
console.log(price - 1); // 转换要求 number;11Symbol.toPrimitive 的键身份由语言约定,普通同名字符串不会替代它。hint 表达调用操作的倾向,不强制返回对应类型;钩子必须返回原始值,否则抛 TypeError。加法先取得原始值,再决定数值加法还是字符串连接,因此 default 返回 12 与返回字符串 '12' 会得到不同结果。
没有这个钩子时,语言才采用普通转换路线,按相应顺序尝试 valueOf/toString。钩子本身抛错,不会自动忽略并换另一条路线。协议给予对象参与机制的入口,也要求对象履行明确契约。
练习
为什么展开
source会执行 getter,而获取描述符不会?如果目标上还有同名 setter,Object.assign 与对象展开又有什么差别?freeze(new Map())为什么仍不能阻止set?两个库都写
Symbol('cache')会冲突吗?都写Symbol.for('cache')呢?写 cloneRecord(source),仅面向普通对象:保持原型、不可枚举属性、Symbol 键和访问器,不在复制时运行 getter。用读取次数断言证明这一点,再修改副本引用的嵌套对象,检查它是否影响原对象,并说明函数为什么不叫深复制。