Object静态方法用于选择自有属性、转换键值对、控制属性描述符,以及限制对象结构。
不同方法选择的键并不相同;Object.assign()与Object.freeze()又都是浅层操作。
先明确自有/继承、可枚举/不可枚举、字符串/Symbol三个维度,再选择方法并测试嵌套对象的标识。
是什么,为什么存在
Object静态方法是挂在Object构造函数上的对象操作,例如
Object.keys(value)、Object.assign(target, source)和Object.freeze(value)。
它们不是任意对象都能安全调用的实例方法;调用形式中的第一个实参明确指出要检查或修改哪个对象。
JavaScript对象以属性键保存数据。属性键只能是字符串或Symbol,而属性还可能来自对象自身或 原型链(prototype chain) ,并带有是否可枚举等元数据。 因此,“取得对象的所有字段”并不是一个完整需求:调用方必须先说明需要哪些类别的属性。
这些方法解决四类常见问题:把对象投影成键值列表、从键值列表构造对象、精确读取或定义属性行为, 以及限制对象能否新增、删除或改写属性。配置合并、API数据投影、库边界和调试工具中经常会遇到它们。
这一组API并不提供统一的“复制对象”语义。复制普通数据、保留访问器、保留原型,以及复制整个 对象图是不同契约。选错方法时,代码表面上得到一个新对象,实际却可能丢失元数据、调用getter, 或继续与来源共享嵌套对象。
删除属性仍使用delete运算符,检查原型链使用in,需要统一反射接口时则使用Reflect。
Object方法与这些语言能力相邻,但不能互相替代。
工作原理
三个选择维度
枚举相关API主要沿三个维度筛选属性:属性是否属于对象自身、是否可枚举,以及键是字符串还是 Symbol。先写清这三个条件,比凭方法名猜测结果可靠。
| 操作 | 自有字符串键 | 自有Symbol键 | 继承键 | 不可枚举键 |
|---|---|---|---|---|
Object.keys() | 是 | 否 | 否 | 否 |
Object.values() | 对应值 | 否 | 否 | 否 |
Object.entries() | 对应键值对 | 否 | 否 | 否 |
Object.getOwnPropertyNames() | 是 | 否 | 否 | 是 |
Object.getOwnPropertySymbols() | 否 | 是 | 否 | 是 |
Reflect.ownKeys() | 是 | 是 | 否 | 是 |
for...in | 是 | 否 | 是 | 否 |
表中的“不可枚举键”表示该操作是否可能包含这类键。Object.values()和Object.entries()与
Object.keys()使用相同键集合,只是返回形状不同。即使某个Symbol属性设为可枚举,前三个
Object方法仍会忽略它。
Object.hasOwn(object, key)只回答某个键是否为自有属性,不受可枚举性影响,也支持Symbol。
它比object.hasOwnProperty(key)更稳妥,因为目标可能覆盖该方法,或者根本没有
Object.prototype。
属性描述符控制行为
每个自有属性都有一个 属性描述符(property descriptor) 。
数据描述符使用value和writable;访问器描述符使用get和set。两类描述符都可以使用
enumerable与configurable,但不能在同一描述符中同时指定数据字段和访问器字段。
对象字面量或普通赋值创建的属性通常可写、可枚举、可配置。Object.defineProperty()的省略值
完全不同:未指定的布尔标志默认为false。因此只写{ value: 1 }会得到不可写、不可枚举、
不可配置的属性。
Object.getOwnPropertyDescriptor()返回一个描述符快照,修改返回对象不会改动原属性。
Object.getOwnPropertyDescriptors()返回全部自有字符串键与Symbol键的描述符,可与
Object.create()组合,建立保留自有属性行为与原型的新外层对象。
复制与转换仍是浅层的
Object.assign(target, ...sources)从左到右读取每个来源的可枚举自有字符串键和Symbol键,
再把值写入目标。后面的来源会覆盖前面的同名键,目标会被原地修改,返回值仍是该目标。
该操作产生的是 浅拷贝(shallow copy) 。属性值若是对象,复制的 只是同一个对象引用;嵌套对象不会递归合并。来源属性若是getter,复制时还会执行getter,目标 得到的是本次读取的值,而不是原访问器描述符。
Object.entries()把对象投影为字符串键值对数组,便于使用数组方法过滤或映射;
Object.fromEntries(iterable)再从键值对构造普通对象。两者组合适合数据转换,但不是无损往返:
Symbol键、不可枚举属性、描述符和原型都不会由Object.entries()保留。
完整性级别只约束一层
Object.preventExtensions()、Object.seal()与Object.freeze()都修改并返回传入对象。它们逐级
限制对象自身的结构,但不会递归处理属性值所引用的对象。
| 操作后的对象 | 新增属性 | 删除属性 | 重新配置属性 | 写入原本可写的数据属性 |
|---|---|---|---|---|
preventExtensions() | 否 | 是 | 是 | 是 |
seal() | 否 | 否 | 否 | 是 |
freeze() | 否 | 否 | 否 | 否 |
seal()会让所有自有属性不可配置;freeze()还会让自有数据属性不可写。访问器属性没有
writable标志,所以冻结对象后getter仍可执行,已有setter也仍可能修改其他状态。
“已冻结”因此是对象自身的完整性状态,不等于任意对象图都不可变。
直接赋值、delete与完整性限制冲突时,严格模式会抛出TypeError,非严格脚本可能静默失败。
Reflect.set()和Reflect.deleteProperty()则用布尔值报告这类普通失败,适合在示例中明确展示结果。
示例
以下四个示例依次验证属性选择、键值转换、描述符复制与完整性级别。所有输出都来自本地 Node 24.14.0。
选择准确的属性集合
这个对象同时包含继承属性、不可枚举属性和Symbol属性。把各类键放在一个样本中,能直接看出 常用API的边界。
const token = Symbol('token');
const baseProfile = { inheritedRole: 'reader' };
const profile = Object.create(baseProfile);
Object.defineProperties(profile, {
name: { value: 'Ada', enumerable: true },
internalId: { value: 17, enumerable: false },
[token]: { value: 'secret', enumerable: true },
});
console.log(JSON.stringify(Object.keys(profile)));
console.log(JSON.stringify(Object.values(profile)));
console.log(JSON.stringify(Object.entries(profile)));
console.log(Reflect.ownKeys(profile).map(String).join(','));
console.log(Object.hasOwn(profile, 'inheritedRole'));
console.log('inheritedRole' in profile);["name"]
["Ada"]
[["name","Ada"]]
name,internalId,Symbol(token)
false
trueObject.keys()的结果只有name,不是因为其他属性不存在,而是它们没有同时满足“自有、可枚举、
字符串键”三个条件。in会沿原型链查找,所以最后两行针对同一个键给出不同答案。
当边界需要允许列表时,不要先取得“所有键”再删除已知危险项。直接按允许的字段名读取,并用
Object.hasOwn()区分缺失字段与继承字段,契约会更稳定。
转换并合并普通数据
第一个转换删除内部字段。接着的配置合并展示两个独立事实:后出现的来源覆盖前面的键,而且嵌套
limits对象整体替换,不会递归合并。
const flags = {
checkout: true,
internalNote: 'remove',
retries: 0,
};
const publicFlags = Object.fromEntries(
Object.entries(flags).filter(([key]) => !key.startsWith('internal')),
);
console.log(JSON.stringify(publicFlags));
const defaults = { theme: 'light', limits: { requests: 100 } };
const input = { theme: 'dark', limits: { burst: 10 } };
const merged = Object.assign({}, defaults, input);
console.log(JSON.stringify(merged));
console.log(merged === defaults, merged.limits === input.limits);
const nestedMerge = {
...defaults,
...input,
limits: { ...defaults.limits, ...input.limits },
};
console.log(JSON.stringify(nestedMerge));{"checkout":true,"retries":0}
{"theme":"dark","limits":{"burst":10}}
false true
{"theme":"dark","limits":{"requests":100,"burst":10}}把空对象作为Object.assign()的目标,避免了修改defaults,但没有让嵌套值独立。第三行中的
true说明merged.limits与input.limits仍是同一个对象。
显式展开limits适用于已经知道数据形状的配置。通用“深合并”必须另外定义数组、循环引用、
访问器、危险键和不同对象类型的策略,不能从Object.assign()自然推导出来。
定义并保留属性描述符
这个示例先展示defineProperty()的默认标志,再比较普通赋值复制与描述符复制。来源getter的
读取次数让副作用可见。
'use strict';
const account = {};
Object.defineProperty(account, 'id', {
value: 'A-17',
enumerable: true,
});
const idDescriptor = Object.getOwnPropertyDescriptor(account, 'id');
console.log(JSON.stringify({
writable: idDescriptor.writable,
enumerable: idDescriptor.enumerable,
configurable: idDescriptor.configurable,
}));
let reads = 0;
Object.defineProperty(account, 'balance', {
get() {
reads += 1;
return 42;
},
enumerable: true,
configurable: true,
});
const assigned = Object.assign({}, account);
const exact = Object.create(
Object.getPrototypeOf(account),
Object.getOwnPropertyDescriptors(account),
);
console.log(assigned.balance, reads);
console.log(typeof Object.getOwnPropertyDescriptor(assigned, 'balance').get);
console.log(typeof Object.getOwnPropertyDescriptor(exact, 'balance').get);{"writable":false,"enumerable":true,"configurable":false}
42 1
undefined
functionObject.assign()为了取得balance的值执行了一次getter,并在目标上创建普通数据属性。
Object.getOwnPropertyDescriptors()读取的是访问器函数本身,所以建立exact时没有再次执行getter。
这里的exact只表示外层原型和自有属性描述符得到保留。属性值仍共享引用,带有内部槽或私有字段
的内建对象与类实例也不能靠这段组合变成通用的精确克隆。
比较三个完整性级别
使用Reflect可以在不依赖严格模式报错文本的情况下观察每次操作是否成功。最后两行还验证了
冻结只作用于外层对象。
const extensible = { count: 1 };
Object.preventExtensions(extensible);
console.log(
Reflect.set(extensible, 'count', 2),
Reflect.set(extensible, 'extra', true),
Reflect.deleteProperty(extensible, 'count'),
);
const sealed = Object.seal({ count: 1 });
console.log(
Reflect.set(sealed, 'count', 2),
Reflect.set(sealed, 'extra', true),
Reflect.deleteProperty(sealed, 'count'),
);
const preferences = { theme: 'light' };
const frozen = Object.freeze({ count: 1, preferences });
console.log(
Reflect.set(frozen, 'count', 2),
Reflect.deleteProperty(frozen, 'count'),
Object.isFrozen(frozen),
);
preferences.theme = 'dark';
console.log(frozen.preferences.theme);
console.log(Object.isFrozen(frozen.preferences));true false true
true false false
false false true
dark
false不可扩展对象仍能修改和删除现有属性;密闭对象仍能修改原本可写的值;冻结对象连这种写入也拒绝。
三种操作都不跟随preferences引用,所以嵌套对象保持可变。
调用Object.isFrozen()只能回答传入的那个对象是否冻结。若契约声称整个输入图不可变,测试必须
继续检查每个相关嵌套对象,或者采用明确拥有不可变数据的建模方式。
陷阱
把Object.keys()当成所有属性
修复方法: 按需求选择键集合。需要全部自有键时使用Reflect.ownKeys();需要判断单个自有键时
使用Object.hasOwn();确实需要沿原型链枚举时,再明确使用for...in并过滤。
让Object.assign()修改共享默认值
修复方法: 普通数据合并使用新的目标,例如Object.assign({}, defaults, input),并单独处理需要
合并的嵌套字段。若输入不可信,应按允许列表构造结果,而不是把所有可枚举键直接写入普通对象。
把浅拷贝当成独立副本
修复方法: 先写出哪些层级必须拥有独立标识,再只复制这些层级。需要复制一般对象图时使用 符合数据类型契约的机制,并用共享引用、循环引用和不受支持的值测试它,而不是套用JSON往返。
忘记描述符的默认值
修复方法: 显式写出业务依赖的每个标志,并用Object.getOwnPropertyDescriptor()断言结果。
不可配置是很强的单向决定;设置前先确认后续是否需要改写描述符或删除属性。
假设冻结会递归或阻止访问器
修复方法: 把冻结描述为外层完整性约束,并测试嵌套标识与访问器副作用。若要递归冻结,先定义 如何处理循环、Symbol键、访问器、Proxy、类型化数组和带私有状态的实例。
属性顺序与往返边界
普通自有键遵循确定的顺序。数组索引形式的字符串键先按数值升序出现,其他字符串键按创建顺序
出现,Symbol键最后按创建顺序出现。Object.keys()、Object.values()、Object.entries()和
Reflect.ownKeys()在各自筛选范围内沿用这套顺序。
“数组索引形式”不是“看起来像数字”的宽泛说法。'2'与'10'会进入索引排序,而'01'、
'-1'和普通名称留在字符串创建顺序中。依赖顺序时应使用能体现这些边界的测试数据。
Object.fromEntries()接受任意可迭代键值对,并把非Symbol键转换为字符串。重复键按迭代顺序
覆盖,最后一个值留下;Symbol键则可以直接成为结果的属性键。
这也解释了Object.fromEntries(Object.entries(object))为什么不是一般对象的逆操作。
Object.entries()已经删除Symbol、不可枚举属性、继承属性、描述符与原型信息,后一步无法凭空
恢复它们。把这组组合称为“数据投影”比称为“对象克隆”更准确。
Object.keys()只选择键,不读取对应属性值,因此本身不会执行getter。Object.values()、
Object.entries()、对象展开和Object.assign()都要读取选中属性的值,getter会在这一步运行。
如果来源是Proxy,相关内部操作还可能触发其陷阱,结果必须按代理契约单独检查。
不同复制操作的可观察行为
复制方法的差异不仅是语法。来源读取、目标写入、键集合和属性创建方式都可能被程序观察到。
| 操作 | 来源键 | 来源getter | 目标setter | 保留描述符 |
|---|---|---|---|---|
Object.assign(target, source) | 可枚举自有字符串与Symbol | 执行 | 可能执行 | 否 |
{ ...source } | 可枚举自有字符串与Symbol | 执行 | 不执行已有目标setter | 否 |
Object.fromEntries(entries) | 迭代器提供的键 | 取决于迭代器 | 否 | 否 |
Object.create(proto, descriptors) | 描述符对象提供的键 | 不读取来源属性值 | 否 | 是 |
对象展开在新对象字面量上创建自有数据属性,不会像Object.assign()那样通过普通赋值写入一个
已有目标。不过两者都会读取来源属性,所以不能用展开避免来源getter的副作用。
描述符复制保留getter、setter和标志,而不是把getter的当前结果保存成数据属性。它仍会复用 描述符中的对象值与访问器函数,也不会复制闭包状态、私有字段或内建对象的内部槽。
因此,Object.create(Object.getPrototypeOf(source), Object.getOwnPropertyDescriptors(source))
只能实现一个明确而有限的契约:保留外层原型与全部自有属性描述符。把它命名为
cloneWithDescriptors可以,把它命名为没有限定词的deepClone会误导调用方。
Object.assign()会忽略值为null或undefined的来源,但目标若是这两个值则会抛出
TypeError。其他原始值会先转换成包装对象;除字符串字符外,它们通常没有可复制的可枚举自有属性。
边界API若只接受普通记录,应主动验证输入,而不是依赖这些强制转换细节。
完整性边界与递归冻结
完整性方法作用于传入对象本身,并尝试调整其可扩展性与自有属性描述符。它们不是复制操作,变量
仍指向同一个对象。Object.freeze(value) === value因此总是成立。
冻结数组会阻止元素写入、删除和改变length,因为这些都由数组自身属性表示。但数组元素引用的
对象仍可修改。相同的浅层边界适用于普通对象:冻结容器不等于冻结容器中的成员。
一个可靠的递归冻结函数至少要记录已经访问的对象以处理循环,并通过Reflect.ownKeys()考虑
Symbol键。它还要检查描述符,以免为了遍历一个访问器属性而意外执行getter。Proxy可能拦截
检查与冻结操作,不同内建对象也可能有额外限制。
这正是短小的Object.values(value).forEach(deepFreeze)配方不适合被当作通用工具的原因。它会漏掉
不可枚举值与Symbol值,不处理循环,还会在读取访问器时执行用户代码。若数据模型固定,可以为该
模型实现并测试递归策略;否则应先缩小所谓“深度不可变”的契约。
冻结也不是机密性或授权边界。读取权限没有被撤销,闭包、私有字段、WeakMap或外部服务中的状态
也不受对象自有描述符控制。安全审查必须跟踪数据暴露与权限检查,而不是把isFrozen()当成信任标记。
无原型字典与原型选择
Object.create(null)创建没有Object.prototype的对象。它不会继承toString、constructor或
hasOwnProperty,所以把实例方法当作所有对象共有能力的代码会失败。
Object.keys()、Object.entries()、Reflect.ownKeys()与Object.hasOwn()都能直接处理无原型
对象。若字符串键来自外部输入,无原型字典还能避免与Object.prototype上的名称发生普通继承冲突。
无原型对象仍不是Map的完全替代。需要任意类型的键、直接的size、清晰的增删API或避免
字符串键转换时,Map往往更符合契约。选择依据应是键类型和接口,不是笼统地把某一种容器叫作更快。
Object.create(prototype)适合在创建时明确指定原型。Object.setPrototypeOf()会改变已有对象的
属性查找关系,也可能破坏调用方对对象类型的假设;业务代码通常应直接用预期原型创建对象,或用类
表达公开接口,而不是在对象流转途中改换原型。
延伸阅读
4个问题 · 1道输出预测题 · 1道找错题