Object静态方法

理清对象自有属性的枚举、浅拷贝、属性描述符与完整性级别,准确使用JavaScript的Object静态方法。

难度 进阶 时长 标准深度约14分钟
版本 Node 24
what

Object静态方法用于选择自有属性、转换键值对、控制属性描述符,以及限制对象结构。

trap

不同方法选择的键并不相同;Object.assign()与Object.freeze()又都是浅层操作。

fix

先明确自有/继承、可枚举/不可枚举、字符串/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的边界。

inspect-profile.js
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
true

Object.keys()的结果只有name,不是因为其他属性不存在,而是它们没有同时满足“自有、可枚举、 字符串键”三个条件。in会沿原型链查找,所以最后两行针对同一个键给出不同答案。

当边界需要允许列表时,不要先取得“所有键”再删除已知危险项。直接按允许的字段名读取,并用 Object.hasOwn()区分缺失字段与继承字段,契约会更稳定。

转换并合并普通数据

第一个转换删除内部字段。接着的配置合并展示两个独立事实:后出现的来源覆盖前面的键,而且嵌套 limits对象整体替换,不会递归合并。

transform-config.js
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的 读取次数让副作用可见。

copy-descriptors.js
'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
function

Object.assign()为了取得balance的值执行了一次getter,并在目标上创建普通数据属性。 Object.getOwnPropertyDescriptors()读取的是访问器函数本身,所以建立exact时没有再次执行getter。

这里的exact只表示外层原型和自有属性描述符得到保留。属性值仍共享引用,带有内部槽或私有字段 的内建对象与类实例也不能靠这段组合变成通用的精确克隆。

比较三个完整性级别

使用Reflect可以在不依赖严格模式报错文本的情况下观察每次操作是否成功。最后两行还验证了 冻结只作用于外层对象。

integrity-levels.js
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道找错题

复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?