JavaScript 对象属性
JavaScript 的属性不只有一个值,还带有能否修改、能否枚举、能否重新配置等规则。属性描述符就是用来描述这些规则的对象。读取属性也不一定只是取出存好的值,它还可能执行 getter。
这些差异直接影响对象遍历、拷贝、只读配置和响应式实现。遇到“为什么拷贝后属性不见了”“为什么冻结后仍能改动”时,先确认操作处理哪些属性、是否触发访问器,以及限制覆盖到哪一层。
1. 面试核心
先记住这几条,再读正文- 数据属性保存
value,访问器属性保存get/set;两类都可以设置enumerable和configurable。同一个描述符不能同时声明数据字段与访问器字段。卡 · 数据属性与访问器属性 - 新建属性时,
Object.defineProperty省略的布尔标志默认是false;对象字面量建立的普通属性通常三个标志都是true。定义方式会改变后续可写、枚举和删除行为。卡 · 数据属性与访问器属性 - 自有与继承、字符串键与 Symbol 键、可枚举与不可枚举是三个独立维度。
Reflect.ownKeys返回全部自有键,Object.keys只返回可枚举的自有字符串键。卡 · 属性枚举与存在性 - 展开语法和
Object.assign读取可枚举自有属性的当前值,可能执行 getter。复制值与复制描述符不是同一件事。卡 · 值拷贝与描述符拷贝 preventExtensions禁止新增,seal再禁止删除和重新配置,freeze再使数据属性不可写。冻结只限制当前对象的属性,不自动冻结嵌套对象或内置内部状态。卡 · 对象完整性限制Object.defineProperty定义单个属性的行为,Proxy 代理对象操作。已有 getter/setter 不会自动拦截任意新属性的添加与删除。卡 · 属性定义与对象代理
2. 数据属性与访问器属性
数据属性直接保存一个值。它的描述符包含 value、writable、enumerable、configurable 四个字段:
| 字段 | 控制的行为 |
|---|---|
value | 属性保存的值 |
writable | 是否允许通过赋值修改这个值 |
enumerable | 是否参与某些枚举操作,例如 Object.keys |
configurable | 是否允许删除属性、改变枚举标志或切换描述符种类等操作 |
const record = { name: "Lin" };
Object.defineProperty(record, "id", { value: 7 });
console.log(Object.getOwnPropertyDescriptor(record, "name"));
// { value: "Lin", writable: true, enumerable: true, configurable: true }
console.log(Object.getOwnPropertyDescriptor(record, "id"));
// { value: 7, writable: false, enumerable: false, configurable: false }
console.log(Object.keys(record)); // ["name"]
console.log(record.id); // 7
id 可以直接读取,但不可枚举、不可写、不可配置。这里没有明确写 false,是因为 Object.defineProperty 新建属性时会使用这些默认值。若重新定义已有属性,省略的字段通常保留原值,并不把它们全部重置成默认值。
configurable: false 不等于一切都不能改。对仍然可写的数据属性,可以继续改变 value,也可以把 writable 从 true 收紧为 false;一旦不可写,就不能再放宽回去。把它称为“不可配置”时,要说明具体限制,不把它与“不可写”混为一谈。
2.1 访问器与副作用
访问器属性通过 getter 响应读取,通过 setter 响应赋值。它使用 get、set 替代 value、writable;这两组字段不能混用,否则 Object.defineProperty 抛 TypeError。
let score = 0;
const result = {};
Object.defineProperty(result, "score", {
get() { return score; },
set(value) {
if (!Number.isFinite(value)) throw new TypeError("score 必须是有限数字");
score = value;
},
enumerable: true,
configurable: true,
});
result.score = 80;
console.log(result.score); // 80
console.log(Object.getOwnPropertyDescriptor(result, "score").value); // undefined
score 实际保存在外层变量里,属性本身保存的是访问函数。检查描述符不会执行这个 getter,而读取 result.score 会。setter 可以校验、修改其他状态或抛错,因此一次看似普通的赋值也可能带来副作用。
只有 getter 而没有 setter 的属性不能直接赋新值;严格模式赋值会抛 TypeError,非严格模式通常静默失败。通过原型继承的访问器仍可能参与读取和赋值,这一层关系见 原型与原型链。
3. 属性枚举与存在性
“遍历一个对象”必须先明确需要哪些属性。不同 API 按自有关系、键类型和枚举标志选择不同集合:
| 操作 | 自有属性 | 继承属性 | 字符串键 | Symbol 键 | 不可枚举属性 |
|---|---|---|---|---|---|
Object.keys/values/entries | 是 | 否 | 是 | 否 | 否 |
Object.getOwnPropertyNames | 是 | 否 | 是 | 否 | 是 |
Object.getOwnPropertySymbols | 是 | 否 | 否 | 是 | 是 |
Reflect.ownKeys | 是 | 否 | 是 | 是 | 是 |
for...in | 是 | 是 | 是 | 否 | 否 |
对象展开、Object.assign 的源属性 | 是 | 否 | 是 | 是 | 否 |
const token = Symbol("token");
const record = Object.create({ inherited: 1 });
record.name = "Lin";
record[token] = 7;
Object.defineProperty(record, "hidden", { value: 2 });
console.log(Object.keys(record)); // ["name"]
console.log(Reflect.ownKeys(record)); // ["name", "hidden", Symbol(token)]
console.log(Object.hasOwn(record, "inherited")); // false
console.log("inherited" in record); // true
hidden 不可枚举,但仍是自有属性,因此会被 Reflect.ownKeys 找到。Symbol 键也不是“私有属性”:知道 Symbol 就能读取,Reflect.ownKeys 和 Object.getOwnPropertySymbols 都能列出它。
Object.hasOwn 不依赖对象自己有 hasOwnProperty 方法,所以适用于 Object.create(null) 或覆盖了同名方法的对象。它只回答是否为自有属性,不受 enumerable 影响;in 还会沿原型链检查。
普通对象的自有键顺序也不是全部按插入顺序排列:数组索引形式的字符串键先按数值升序,其余字符串键按创建顺序,Symbol 键最后按创建顺序。不要把普通对象当成无条件保持插入顺序的字典;需要明确的条目插入顺序时,可考虑 Map。
4. 值拷贝与描述符拷贝
对象展开和 Object.assign 都会读取源对象的可枚举自有属性,但不会原样保留这些属性的描述符。源属性是 getter 时,会先执行 getter,复制得到的值。
let count = 1;
const source = {
get count() { return count; },
};
const values = { ...source };
const descriptors = Object.create(
Object.getPrototypeOf(source),
Object.getOwnPropertyDescriptors(source),
);
count = 2;
console.log(values.count, descriptors.count); // 1 2
console.log(Object.getOwnPropertyDescriptor(values, "count").get); // undefined
values.count 是展开当时读取到的数字。descriptors.count 仍是访问器,并且与源对象使用同一个 getter 函数,所以仍访问同一个外层 count。保留描述符没有复制这个闭包环境,也不意味着完成了深拷贝。
两种值拷贝方式对目标的写入也不同:Object.assign(target, source) 会对目标执行赋值,可能触发目标已有的 setter;对象展开在新对象上定义普通数据属性。对源 getter、目标 setter 和写入异常都敏感的代码,不能只凭“都是浅拷贝”就认为行为相同。
Object.getOwnPropertyDescriptors 加 Object.create 适合需要保留普通对象的自有属性规则和原型的场景。但嵌套值仍然共享,私有字段与内置内部状态也不在这些普通描述符里,不能把它当成任意类实例的复制方案。
5. 对象完整性限制
可扩展性决定对象能否新增自有属性。Object.preventExtensions、Object.seal 和 Object.freeze 在此基础上逐步收紧当前对象的限制:
| 操作 | 新增自有属性 | 删除现有自有属性 | 重新配置现有属性 | 修改可写数据属性值 |
|---|---|---|---|---|
Object.preventExtensions | 禁止 | 由原描述符决定 | 由原描述符决定 | 允许 |
Object.seal | 禁止 | 禁止 | 受不可配置规则限制 | 仍允许 |
Object.freeze | 禁止 | 禁止 | 受不可配置规则限制 | 禁止 |
这些操作返回原对象,不创建副本。对应的检查是 Object.isExtensible、Object.isSealed、Object.isFrozen。普通对象一旦不可扩展,也不能再改为另一个原型;把原型设置为当前值属于不同情况。
"use strict";
const sealed = Object.seal({ count: 1 });
sealed.count = 2;
console.log(sealed.count); // 2
const frozen = Object.freeze({ count: 1, nested: { count: 1 } });
frozen.nested.count = 2;
console.log(frozen.nested.count); // 2
console.log(Object.isFrozen(frozen), Object.isFrozen(frozen.nested)); // true false
// frozen.count = 2; // 运行错误:严格模式下抛 TypeError
freeze 把 nested 这个数据属性变成不可写,所以不能给它换一个引用;它没有冻结引用指向的对象,因此 frozen.nested.count 仍能变化。这与 const 又不同:const 限制变量重新赋值,不限制对象的属性。
5.1 冻结后的可变状态
冻结不等于对象所有可观察行为都保持不变。访问器属性被冻结后,getter/setter 函数不会被移除,访问器操作的外部状态仍然可能变化:
let value = 1;
const box = Object.freeze({
get value() { return value; },
set value(next) { value = next; },
});
box.value = 2;
console.log(box.value); // 2
Map 的条目、Date 的时间值等内置内部状态也不是普通数据属性。Object.freeze(new Map()) 不会因此禁用它的 set。要设计不可变结构,需要按数据模型限制可变入口,而不是只在外层加一次 freeze。
对于仅由普通对象和数组组成的数据,可以遍历嵌套对象并逐个冻结。还要处理循环引用,明确 Symbol 键、不可枚举属性和访问器是否参与,以及防止误触 getter。若对象包含 Map、类实例或外部资源,就必须单独定义不可变语义,不能承诺一个通用递归函数自动覆盖。
6. 属性定义与对象代理
Object.defineProperty 直接定义目标对象的某个自有属性。给属性设置 getter/setter 后,读取或赋值这一属性可以执行逻辑;新增一个未定义的属性或删除原属性,不会自动调用该属性原有的 setter。
Proxy 创建一个代理对象,可以通过 get、set、deleteProperty、ownKeys 等拦截相应对象操作。它拦截的是经过代理的访问,直接操作原目标仍可能绕开代理;拦截结果还要满足目标不可配置属性等规则。
这就是讨论响应式时需要区分两者的原因:描述符方案一般要处理每个已有属性及嵌套对象;代理方案能统一接收更多种对象操作,但仍需设计依赖收集、更新通知、嵌套代理和身份关系。Proxy 本身不会自动完成响应式系统。
7 · 面试题卡片
本页题目与参考回答
7.1 数据属性和访问器属性的描述符有哪些字段,默认值是什么?
考察重点:属性种类、读写规则、可枚举性和可配置性。
相关内容:数据属性与访问器属性、访问器与副作用。
参考回答
数据描述符使用 value/writable,访问器描述符使用 get/set,两者都可以设置 enumerable/configurable。 同一描述符不能混用两组字段。
Object.defineProperty 新建属性时,省略的布尔标志默认是 false,数据值默认是 undefined;对象字面量创建的普通数据属性则通常可写、可枚举、可配置。更新已有描述符时,省略字段保留现状。不可配置也不等于立即不可写,已有可写数据属性仍可修改值或收紧为不可写。
7.2 Reflect.ownKeys 与 Object.keys 有什么区别,怎样定义不可枚举属性?
考察重点:自有关系、键类型和可枚举性三个维度。
相关内容:属性枚举与存在性、数据属性与访问器属性。
参考回答
Reflect.ownKeys 返回所有自有字符串键与 Symbol 键,包含不可枚举属性;Object.keys 只返回可枚举的自有字符串键。 两者都不枚举继承属性。
使用 Object.defineProperty(obj, key, { value, enumerable: false, ... }) 定义不可枚举属性,并按需求明确 writable、configurable。不可枚举并不等于私有,仍可以直接读取或用相应反射 API 找到。
7.3 为什么展开一个含 getter 的对象后,属性不再跟随原值变化?
考察重点:读取当前值与复制访问器行为的区别。
相关内容:值拷贝与描述符拷贝。
参考回答
展开会执行源 getter,并把结果作为新对象的普通数据属性,原 getter 不会被复制过去。 如果结果是原始值,后续源状态变化不会更新这个值;如果结果是对象,仍会共享它的引用。
需要保留属性描述符时,可以读取 Object.getOwnPropertyDescriptors 并用 Object.create 或 Object.defineProperties 建立属性。这样仍共享访问器函数及其闭包环境,也没有深拷贝嵌套对象。
7.4 怎样禁止对象修改,preventExtensions、seal 和 freeze 分别限制什么?
考察重点:新增、删除、重新配置、写入以及嵌套状态。
参考回答
preventExtensions 禁止新增自有属性,seal 在此基础上使现有自有属性不可配置,freeze 再使数据属性不可写。 密封对象上原本可写的值仍能修改,冻结对象则不能替换这些数据值。
三者都作用于原对象,且不是递归操作。冻结不会自动保护嵌套对象,也不会移除 setter 或冻结 Map 条目等内部状态;const 更只限制变量绑定。要求深层不可变时,需要按数据结构定义并控制所有可变入口。
7.5 Object.defineProperty 与 Proxy 在拦截对象变化时有什么不同?
考察重点:属性访问器、对象操作、代理访问入口及响应式职责。
相关内容:属性定义与对象代理。
参考回答
Object.defineProperty 定义某个属性的行为,Proxy 通过代理对象拦截更广泛的对象操作。 已定义的 getter/setter 可以处理对应属性读写,但不会自动得知任意新属性的加入或删除。
Proxy 可拦截读取、写入、删除、列键等操作,但直接访问原对象可能绕过它。两种机制都还需要依赖收集和触发逻辑才能构成响应式系统,不能把代理本身等同于响应式。
8. 参考资料
- MDN:Object.defineProperty
- MDN:Enumerability and ownership of properties
- MDN:Object.assign
- MDN:Object.freeze
- ECMAScript:Property Descriptors
- ECMAScript:Proxy Object Internal Methods and Internal Slots
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明