ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JavaScript Object 全解:从属性描述符到原型链,彻底搞懂对象方法

JavaScript Object 全解:从属性描述符到原型链,彻底搞懂对象方法 你列这个标题的时候大概率以为把这些 API 背下来就够了Object.keys、Object.assign、Object.entries……但真到排查问题的时候会发现卡你的往往不是“这个方法怎么用”而是“这个属性为什么没被拷贝过来”“为什么 freeze 之后还能改”“为什么 configurable 会锁死一切”。所以这篇我把 Object 相关的所有方法和知识点揉成一个体系来讲从静态方法到原型链从属性描述符到遍历顺序再到跨语言报错里常见的object reference not set to an instance of an object这类误读都一起过一遍。适合正在系统补基础的人也很适合那些长期写框架、已经快忘了原生对象 API 的老手。1. Object 的方法到底分几类先看全貌1.1 为什么要单独把 Object 拉出来讲Object 在 JavaScript 里不只是“对象”这么简单它同时是构造函数、命名空间和原型链的起点。每一个用{}创建的对象默认都会挂在Object.prototype上所以你在控制台里敲一个对象它能调用toString()、hasOwnProperty()、valueOf()这些方法靠的全是原型链。换句话说Object 的方法分两拨一拨是Object.xxx()这种静态方法自己带参数调用另一拨是obj.xxx()这种实例方法通过原型链继承下来。实际项目里很少有人把这两拨完整分清楚。常见的误解是“obj.keys()为什么不能用”因为keys是静态方法只能Object.keys(obj)这样调而obj.toString()能直接用因为它是实例方法。搞清楚这个边界后面用起来才不会到处查文档。而且 Object 的方法里还有一批是配合内置 Symbol 工作的比如Symbol.toStringTag、Symbol.toPrimitive它们不像普通方法那样有函数名却在类型判断、隐式转换里扮演关键角色。这一节先建立整体框架后面每一类都会拆开来讲。1.2 一张表看清分类和调用方式可以把 Object 相关方法分成三类静态方法Object.keys、Object.values、Object.entries、Object.fromEntries、Object.assign、Object.create、Object.defineProperty、Object.defineProperties、Object.getOwnPropertyDescriptor、Object.getOwnPropertyDescriptors、Object.getOwnPropertyNames、Object.getOwnPropertySymbols、Object.getPrototypeOf、Object.setPrototypeOf、Object.hasOwn、Object.is、Object.preventExtensions、Object.seal、Object.freeze、Object.isExtensible、Object.isSealed、Object.isFrozen、Object.groupBy。实例方法hasOwnProperty、propertyIsEnumerable、valueOf、toString、toLocaleString、__proto__的 getter/setter。内置 Symbol 相关Symbol.toStringTag、Symbol.toPrimitive等通常配合Object.prototype.toString和ToPrimitive转换规则生效。调用时有个基本规律凡是需要传入目标对象的几乎都是静态方法凡是直接挂在一个对象实例上、不需要额外传“谁”的几乎都是实例方法。搞清楚这一个原则你就不会再混淆Object.hasOwn和hasOwnProperty的关系也更清楚obj.hasOwnProperty为什么可能被覆盖掉而Object.hasOwn为什么更安全。后面的内容会反复用到这张分类表。2. 高频静态方法逐个拆从 keys/values/entries 到 create/assign2.1 keys、values、entries 三兄弟的行为边界Object.keys()返回一个数组里面是对象自身的可枚举属性名不包含 Symbol 键也不包含不可枚举的属性更不会跑到原型链上拿属性。Object.values()和Object.entries()规则一致只是分别返回值和键值对数组。我用一个例子说明这个“自有、可枚举”的边界const person { name: jc, age: 30 }; Object.defineProperty(person, privateId, { value: p_001, enumerable: false, }); const proto { city: Shanghai }; Object.setPrototypeOf(person, proto); Object.keys(person); // [name, age] Object.values(person); // [jc, 30] Object.entries(person); // [[name,jc], [age,30]]privateId不可枚举所以三个方法都看不到它city来自原型链同样看不到。这个过滤逻辑在很多业务场景里非常有用尤其是你需要“只处理对象自己定义的属性”时。还有一个容易忽略的点这三个方法对原始值也会做自动装箱处理。Object.keys(hi)返回的是[0, 1]因为字符串会被包装成 String 对象字符串下标就成了可枚举的索引属性。这个行为 ES2015 之后已经标准化遇到这类代码别再觉得是 bug。2.2 fromEntries 反向构造与 Map 互转Object.fromEntries是Object.entries的反向操作它的入参是一个可迭代的键值对列表通常就是二维数组或者 Map。前端经常用它把 Map 转成普通对象const map new Map([ [name, jc], [level, senior], ]); const obj Object.fromEntries(map); // { name: jc, level: senior }它更常见的用途是做“先转换再重组”比如把对象里的字段统一加前缀或者从原始数据里挑出一部分字段。思路是先Object.entries得到数组然后map或filter处理最后Object.fromEntries拼回对象。有几个坑需要注意入参里的键值对如果包含null或undefined作为元素会直接抛 TypeErrorObject.fromEntries只会处理一层不像 lodash 的fromPairs能自动处理嵌套。所以业务里如果要用它做数据清洗得先确保传入的每一项都是[key, value]二元组否则代码会崩得毫无预兆。2.3 assign 的“浅拷贝”到底浅在哪Object.assign(target, ...sources)用来把源对象的可枚举自有属性复制到目标对象这个过程是浅拷贝。很多人把它当深拷贝用结果嵌套对象被改得乱七八糟const original { user: { name: jc, age: 30 }, tags: [js] }; const copy Object.assign({}, original); copy.user.name new name; console.log(original.user.name); // new name原因很简单Object.assign只会拷贝属性引用不会递归复制内部对象。copy.user和original.user指向同一个对象。需要深拷贝的时候应该优先考虑浏览器内置的structuredClone或者用JSON.parse(JSON.stringify(obj))来兜底。但JSON方案有天然缺陷——无法处理函数、undefined、Symbol、循环引用遇到Date还会变成字符串structuredClone能处理循环引用和大多数内置类型但对函数、DOM 节点依然无能为力。选择拷贝方案时先看看你的对象里到底有什么类型的数据再决定用哪种。还有一个特别容易踩的问题Object.assign在复制过程中会读取源对象的 getter也会触发目标对象的 setter。如果你在源对象上定义了一个“计算属性”复制时会执行它的 getter 逻辑而不是把它原样搬过去。这不只是“浅拷贝”问题还牵扯副作用。比如const source { get count() { console.log(getter triggered); return 42; }, }; const target Object.assign({}, source); // 控制台会打印 getter triggered所以项目里如果涉及访问器属性尽量用后面的Object.getOwnPropertyDescriptors配合Object.defineProperties来保留描述符本身而不要指望assign给你完整搬运。2.4 create 与 Object.create(null) 的神奇字典Object.create(proto, propertiesObject)可以指定新对象的原型第二个参数还能顺便定义属性描述符。比{}更灵活因为你可以得到一个完全没有原型的对象const dict Object.create(null); dict[__proto__] { hacked: true }; console.log(dict.hacked); // undefined这个例子很有代表性。普通对象里__proto__是一个访问器你给它赋对象容易触发原型设置逻辑导致原型链被污染。而Object.create(null)创建的对象没有Object.prototype自然也就不存在__proto__访问器所有键都老老实实作为普通数据属性存着。所以在做安全字典、防止原型污染攻击时Object.create(null)几乎是标配。但也要提醒这种对象没有toString、hasOwnProperty这些常用方法直接console.log也会显示成[Object: null prototype]调试时容易发懵。你可以在自己封装的工具函数里给原型补上打印辅助或者用Reflect.ownKeys去查看键。2.5 新增的 groupBy、hasOwn 值得用Object.groupBy是 ES2024 的新方法可以把数组按回调函数的返回值分组。它和手写reduce最大的区别是返回结果的 prototype 为 null直接规避了__proto__污染问题同时分组键会统一转成字符串。示例const users [ { name: a, role: admin }, { name: b, role: user }, { name: c, role: admin }, ]; const byRole Object.groupBy(users, (user) user.role); // { admin: [{name:a}, {name:c}], user: [{name:b}] }Object.hasOwn(obj, key)则是hasOwnProperty的现代替代品专门解决对象自己实现了同名字段导致误判的问题。下面在原型链章节里会专门展开。3. 属性描述符与不可变控制defineProperty/seal/freeze 的底层逻辑3.1 属性描述符长什么样JavaScript 对象的属性不只是“名字 值”每个属性背后还跟着一组描述符。Object.getOwnPropertyDescriptor能把这个描述符拿出来const obj { a: 1 }; const descriptor Object.getOwnPropertyDescriptor(obj, a); // { value: 1, writable: true, enumerable: true, configurable: true }数据属性描述符有四个字段value、writable、enumerable、configurable。访问器属性则不同字段是get、set、enumerable、configurable没有value和writable。整套字段看起来简单但引擎对对象属性的增删改权限全靠这几个布尔值控制。我建议你在排查属性问题时先打一个Object.getOwnPropertyDescriptors(obj)出来看这个方法会返回对象自身所有属性的完整描述符比一个个getOwnPropertyDescriptor方便得多。很多时候你以为“属性不存在”实际是“属性不可枚举”你以为“不能赋值”其实是writable: false。先把描述符看清楚再去猜问题效率完全不同。3.2 defineProperty 的默认值陷阱Object.defineProperty(obj, key, descriptor)用来定义或修改属性。它最大的坑是如果不显式声明writable、enumerable、configurable这三个值默认全都是false。这和对象字面量属性默认全为true完全相反const obj {}; Object.defineProperty(obj, x, { value: 1 }); Object.keys(obj); // []因为 enumerable 是 false obj.x; // 1很多初学者用defineProperty加个属性结果发现for...in、Object.keys、展开运算符都拿不到它困惑半天。从原理上讲这是有意设计当你使用低层 API 时引擎假设你需要精细控制而不是猜测“应该可枚举”。另外还要注意数据描述符和访问器描述符不能混用例如{ value: 1, get() {} }会直接抛 TypeError。定义新属性时这四个字段要么走数据分支要么走访问器分支不存在“既有值又有 getter”的属性。做组件库或者框架源码时defineProperty的默认值反而是优势可以精确控制属性是否出现在枚举结果里。业务代码里我建议尽量显式声明三个布尔值让别人看代码时不用猜。即使你真的需要默认false写出来也更符合直觉。3.3 writable、enumerable、configurable 三个布尔权限这三个描述符字段可以理解成三种独立的权限开关writable控制“能不能通过赋值改变值”。writable: false时非严格模式下赋值会静默失败严格模式直接抛TypeError: Cannot assign to read only property。enumerable控制“会不会出现在Object.keys、for...in、JSON.stringify、对象展开里”。configurable控制“能不能删除属性、能不能重新定义描述符”。configurable: false是三者里最强的限制它会让属性变成一个无法删除、无法调整权限的“固定位”。我在真实项目里遇到最多的就是configurable: false。比如后端返回的数据被框架层做了响应式包装某个属性被Object.defineProperty锁定过前端想再Object.assign覆盖它结果控制台报Cannot redefine property。这种问题不是业务逻辑写错而是属性权限被冻结了。处理办法是不要用整体覆盖要么新建对象要么在defineProperty之前把描述符权限留好。还有一个细节值得记configurable: false并不是绝对“完全不能动”规范允许把writable: true改成writable: false但不允许反方向改回去。这个单向限制经常出现在源码题里搞懂它就能解释很多“为什么这里能改、那里不能改”的矛盾现象。3.4 seal、freeze、preventExtensions 的层级关系Object.preventExtensions、Object.seal、Object.freeze是三个递进的不可变控制方案很多人只记住了freeze但不知道他们之间的权限关系preventExtensions禁止新增属性已有属性不受影响。seal在禁止新增的基础上把所有属性描述符的configurable设为false已有的不可配置属性不能删除也不能再改描述符。freeze在seal基础上把所有数据属性的writable设为false连值都不能改了。注意freeze之后属性值如果是对象那内部依然可变。这是所有“浅冻结”方案的共同弱点。真要深度冻结超大对象要么递归冻结要么用框架里的深冻结工具要么靠运行时代理拦截。实际项目中Object.freeze最适合用来定义常量配置、错误码表、权限清单这类纯前台展示数据不适合直接套在有嵌套对象且有业务可变字段的数据上。经手过一个字段面板项目把配置对象 freeze 之后仍然能改里层的数值排查了半天才反应过来是浅冻结。后来我在代码里加了一层递归冻结工具才彻底避免线上数据被意外改动。所以别相信“freeze 就完事了”它只保证第一层不可变。3.5 getOwnPropertyDescriptors 搭配 defineProperties 做完整复制前面提到Object.assign会把 getter 的值复制过来丢失访问器特性。完整复制一个对象包括描述符和符号属性需要组合使用const source { get total() { return 42; }, set total(value) {}, }; const copy Object.defineProperties( {}, Object.getOwnPropertyDescriptors(source) ); console.log(Object.getOwnPropertyDescriptor(copy, total)); // { get: [Function: get total], set: [Function: set total], ... }Object.getOwnPropertyDescriptors会返回对象自身的所有属性描述符包括不可枚举和符号属性Object.defineProperties能一次定义多个属性。两者结合是很多库内部做“copy with descriptors”的标准做法。如果你看到一个空对象被defineProperties一次性塞进一堆属性大概率就是在做完整备份。这组 API 还能解决一个常见需求把一个对象浅拷贝到目标对象时保留enumerable和writable的状态不把原本不可枚举的字段意外暴露出去。这时候assign是不行的只有描述符级复制能精确做到。4. 原型链与属性归属getPrototypeOf、hasOwn 与 instanceof 关系4.1 getPrototypeOf 与 setPrototypeOf 的正确姿势Object.getPrototypeOf(obj)返回对象的原型只能读不能改Object.setPrototypeOf(obj, proto)用来重设原型。浏览器里的__proto__其实就是一组访问器getter 调用getPrototypeOfsetter 调用setPrototypeOf。看起来很简单但改原型是会带来性能代价的。现代 JS 引擎为了优化对象属性访问会假设对象的形状是稳定的同一类对象共享隐式类。一旦你在对象创建之后频繁setPrototypeOf引擎就必须放弃很多优化属性访问速度可能明显下降。所以日常代码不要用setPrototypeOf来“模拟继承”更不要在一个对象上来回切换原型。如果需要动态构造继承关系一开始就用Object.create(proto)创建后续保持原型不变。如果你真要判断一个对象是不是某个构造函数的实例obj instanceof Constructor是最常用手段。但Object.create(null)的对象因为没有原型链obj instanceof Object会返回false哪怕它明明是个对象。这一点在跨模块传值时确实会造成困惑判断前最好知道对方是不是 null-proto 对象。4.2 用 hasOwn 而不是 hasOwnProperty实例方法hasOwnProperty是从原型链拿来的问题在于对象完全可能覆盖它const obj { hasOwnProperty: () true, name: jc, }; obj.hasOwnProperty(name); // true但这是被覆盖后的结果 obj.hasOwnProperty(not_exists); // true误报 Object.hasOwn(obj, not_exists); // false正确Object.hasOwn是 ES2022 加入的静态方法不依赖对象原型所以不会被同名方法污染。只要运行环境支持 ES2022 以上我都建议一律用Object.hasOwn替代Object.prototype.hasOwnProperty.call这种绕来绕去的写法。不要觉得自己不会写hasOwnProperty字段就不需要防第三方数据、JSON 解析结果都可能带出这种奇奇怪怪的键。同样的逻辑也适用于in操作符。key in obj会连原型链一起查而Object.hasOwn(obj, key)只查自有属性。判断“这个属性是不是对象亲生的”永远要用hasOwn判断“能不能通过对象访问到这个属性”才用in。这两种语义差别是代码里很多隐蔽 bug 的来源。4.3 instanceof 与 typeof null 的经典误区typeof null object是 JavaScript 的历史遗留 bug但“null 是对象”这种说法是错的。null既不是对象也不继承自Object.prototype所以null instanceof Object直接返回false。真正的对象字面量{}和数组、函数一样instanceof Object都是true因为它们都在原型链上找到了Object.prototype。另一种判断方式是Object.prototype.isPrototypeOf(obj)它和instanceof很接近但语义是“检查某个对象是不是另一个对象原型链上的成员”。调试原型链问题的时候我会同时打出来看console.log(Object.getPrototypeOf(arr) Array.prototype); // true console.log(Array.prototype.isPrototypeOf(arr)); // true console.log(arr instanceof Array); // trueinstanceof虽然好用但它会沿着整条原型链搜索所以子类实例对父类构造函数也返回true。如果你要非常严格地判断“直接原型是谁”只能用Object.getPrototypeOf或者Object.getPrototypeOf(obj) SomeConstructor.prototype。4.4 new Object 与 {} 的等价与差异new Object()、{}、Object.create(Object.prototype)创建出来的对象在大多数表现上等价但底层细节有一个关键差异new Object()传参不同有意义。new Object(1)会返回一个 Number 对象new Object(abc)会返回 String 对象new Object({})则原样返回传入对象。这其实是“包装器”行为。日常开发中{}就是最直观靠谱的创建方式new Object()只是历史遗留的上位替代。真正需要灵活控制原型时只会用Object.create。从方法角度理解Object.create更像是“指定原型 可选描述符”的低层工具其他构造方式都以它为基础。如果你想挑战原型链题目可以把new Foo()拆解成Object.create(Foo.prototype)再执行构造函数里的初始化逻辑很多原型链面试题都能这么推。5. 实例方法、内置 Symbol 与隐式转换5.1 toString 与 Symbol.toStringTagObject.prototype.toString()默认返回[object Object]它不关心对象里有什么字段只看内部类型标签。数组、Date、RegExp 都重写了各自的toString所以[1,2].toString()是1,2new Date().toString()是一长串日期字符串。真正可靠的通用类型判断是借用Object.prototype.toString.callObject.prototype.toString.call([]); // [object Array] Object.prototype.toString.call(new Date()); // [object Date] Object.prototype.toString.call(null); // [object Null]ES6 之后Symbol.toStringTag可以自定义这个标签const custom { [Symbol.toStringTag]: MyModule, }; Object.prototype.toString.call(custom); // [object MyModule]很多框架会用这个技巧给Promise、fetch之类的内部类型设置可读标签让调试输出更漂亮。理解它你就明白为什么Object.prototype.toString比typeof精细得多也比instanceof更能规避跨 iframe 带来的原型不一致问题。5.2 valueOf 与 Symbol.toPrimitive 在转换中的位置Object.prototype.valueOf默认返回对象自身对于普通对象来说基本没用它主要被数字和布尔包装类重写。真正改变对象在加减法、比较、字符串拼接中的行为是Symbol.toPrimitiveconst obj { value: 42, [Symbol.toPrimitive]() { return this.value; }, }; console.log(obj 1); // 43 console.log(${obj}); // 42如果对象没有Symbol.toPrimitiveJS 会按照 hint 走toString和valueOf的顺序hint 为 number 时先valueOf再toStringhint 为 string 时先toString再valueOf。比较时 hint 是 default数字上下文里 hint 是 number模板字符串里 hint 是 string。这个顺序经常被拿来出题比如{} []为什么等于 0就是因为两个对象都转换成了原始值再相加。我这里要特别提醒对象转原始值的细节是日常代码里“看起来根本不相关却出 bug”的重灾区。比如你 service 层返回的对象里有个[Symbol.toPrimitive]前端拿它做字符串拼接结果拼出来的内容完全不是预期排查半天才想起这个隐式转换入口。5.3 propertyIsEnumerable 的边界object.propertyIsEnumerable(prop)用来判断某个自有属性是否可枚举。和Object.keys的过滤规则很像但它更偏“查询单点”const obj { a: 1 }; Object.defineProperty(obj, b, { value: 2, enumerable: false }); obj.propertyIsEnumerable(a); // true obj.propertyIsEnumerable(b); // false它不会沿原型链查找所以obj.propertyIsEnumerable(toString)返回false因为toString是Object.prototype上的方法不属于对象自身。如果你在多级继承场景里想确认某个属性到底是“对象自己可枚举的”还是“原型上顺下来的”这个方法比for...in配合hasOwn更直接。顺带补充Object.getOwnPropertySymbols它专门返回对象自身的 Symbol 键。如果对象里藏了很多 Symbol 键Object.keys是看不到的只有这个 API 能拿出来。加上Object.getOwnPropertyNames你才能拼出对象自身的全部字符串键和符号键。Reflect.ownKeys则是两者的合集开发调试时最常用。5.4 toJSON 对 JSON.stringify 的隐形影响JSON.stringify序列化对象时会先检查对象有没有toJSON方法有就直接调用并序列化返回值。这是 Date 能被序列化成字符串的原因Date 的toJSON返回toISOString()的结果。如果你自己实现一个toJSON就能控制这个对象“在 JSON 世界里长什么样”const report { name: monthly, data: [1, 2, 3], toJSON() { return { reportName: this.name, total: this.data.length }; }, }; JSON.stringify(report); // {reportName:monthly,total:3}这个机制在服务端接口适配里非常有用尤其是你想在序列化时剔除敏感字段或者把复杂的嵌套结构拍平成 DTO。但要注意toJSON返回的如果不是对象、数组、字符串等可序列化值后续还要继续走类型转换规则所以不要以为toJSON一定会输出 JSON。它只是个“转换钩子”。6. 遍历顺序、安全字典与展开拷贝的细节6.1 属性遍历顺序真的不是“插入顺序”很多人以为Object.keys的返回顺序就是插入顺序这是不对的。规范里 OrdinaryOwnPropertyKeys 规定了一套排序规则先输出所有“整数索引”属性按数字升序然后输出普通字符串属性按插入顺序最后输出 Symbol 属性按插入顺序。const order {}; order[b] 1; order[2] 2; order[a] 3; order[1] 4; Object.keys(order); // [1, 2, b, a]“1”“2”是整数索引会排到最前面并按升序排列b 和 a 才按照插入顺序跟在后面。这个规则同时影响Object.keys、Object.values、Object.entries、JSON.stringify、for...in只是for...in还会包含继承的可枚举属性。业务里做“保持 JSON 字段顺序”的接口时得先判断字段键是不是数字否则会被排序规则坑得很惨。我在一个导出报表的项目里遇到过前端生成的对象键有“2024-01”“2024-02”这种字符串顺序正常一旦出现纯数字的年份字段Object.keys的返回顺序就乱了导致导出列头错位。后来统一把 key 改成带前缀的字符串彻底绕开整数索引规则。6.2 用 Object.create(null) 做安全字典的注意点Object.create(null)的原理和价值已经在 2.4 提过这里再补两个实战相关细节。第一这种对象没有Object.prototype所以所有键都是自己的属性for...in不再会带出原型上的属性逻辑更干净。第二它可以安全承载__proto__、constructor这种人见人怕的键不用担心触发原型链上的访问器或 setter。但如果要从 JSON 字符串构造安全字典别直接JSON.parse因为JSON.parse生成的是普通对象。你可以用第二个参数 reviver 重建const raw {__proto__: {polluted: true}}; const safe JSON.parse(raw, (key, value) key __proto__ ? undefined : value );这种“重建时过滤键”的思路在解析第三方不可信 JSON 时很有效。对象本身可能没有危害但__proto__这种特殊键在合并、扩展时一旦被误当普通属性写入就可能导致原型污染。安全字典 键过滤双保险至少能把大部分原型污染攻击挡在门外。6.3 for...in 和 Object.keys 的配合for...in会遍历对象自身和继承链上的所有可枚举属性这点是新手最容易翻车的地方。即使你不是故意用继承数组原型上如果被人挂过可枚举方法for...in一个数组也会把方法名带出来导致逻辑爆炸。所以我在项目里的习惯是“只用Object.keys绝不用for...in遍历对象”。如果非要遍历继承属性至少要用Object.hasOwn过滤一遍for (const key in obj) { if (Object.hasOwn(obj, key)) { // 只处理自有属性 } }Object.entries配上for...of的写法已经非常现代还能帮你拿到 key 和 value大多数场景都用不上for...in了。老代码里如果大面积用for...in我建议把它们逐步替换成Object.keys或Object.entries少一路原型链踩坑。6.4 对象展开与 assign 的差异对象展开{ ...obj }在大多数场景和Object.assign({}, obj)等价但有细微差异。展开语法只会读取对象的可枚举自有属性而且取值时就调用 getter不会复制访问器本身Object.assign还会触发目标对象的 setter。如果目标对象不是普通{}而是带有 setter 的对象两者的最终结果可能完全不同。const target { get a() { return this._a; }, set a(v) { this._a v * 2; }, }; Object.assign(target, { a: 10 }); console.log(target.a); // 20setter 被触发 const spreadTarget { ...target, a: 10 }; console.log(spreadTarget.a); // 10新对象上的普通数据属性少量属性合并时用展开语法最简洁但想要“写入并触发目标 setter”时只能用Object.assign。这两者的区分在状态管理库的 reducer 写法里尤其重要稍不留神就把响应式对象的 setter 效果给绕过了。7. 报错现场与排查思路速查7.1 “Cannot redefine property” 的完整排查这个报错几乎都是因为属性configurable: false然后你又试图修改描述符或删除它。常见场景有三个内置对象属性被框架冻结Object.defineProperty定义属性时没写configurable: true多次调用Object.defineProperty时第二次想改enumerable或configurable。复现代码const a {}; Object.defineProperty(a, x, { value: 1, configurable: false }); Object.defineProperty(a, x, { value: 2 }); // TypeError: Cannot redefine property: x排查思路是先用Object.getOwnPropertyDescriptor确认当前 configurable 状态再决定是重新创建对象还是绕开这个属性。如果只是一次性的初始化逻辑最稳妥的出路是换一个新对象不要试图去“解冻”一个已经configurable: false的属性那个权限一旦关掉就回不去了。7.2 “Cannot assign to read only property” 与严格模式这个报错来自writable: false的属性。普通模式赋值失败会静默忽略打开严格模式或者使用 ES Module 时则直接抛错。报错信息本身已经很明确关键是你得知道“哪个属性被设置成了只读”。我在真实业务里见到最多的是第三方库对配置对象执行Object.freeze然后业务代码尝试直接改其中某个字段。此时不能硬改要么在生产环境就设计成“只读展示”要么在源头构造一个可变副本const writableConfig { ...FROZEN_CONFIG };。展开语法创建新对象后属性权限全部恢复默认赋值就正常了。7.3 Object.keys(null) 会报错Object.assign 却能容忍 null这是很多人的知识盲区。Object.keys(null)、Object.values(null)、Object.entries(null)会直接抛TypeError: Cannot convert undefined or null to object因为这几个方法要求入参先做ToObject。但Object.assign({}, null)是合法的规范明确跳过null和undefined源对象。原因在于assign的语义是“可合并多个源”null 源被视为无字段可合并而keys的语义是“枚举这个对象的键”null 连对象都不是。所以如果你的工具函数里存在“可能传入 null”的情况用keys/values/entries之前必须判空。这个排查技巧很小但每次都提醒我不要把一个 API 的容错行为推广到另一个 API 上。7.4 跨语言报错别照单全收现在很多团队会同时维护前后端你在控制台偶尔会看到object reference not set to an instance of an object这类英文报错它是 C# 空引用异常和 JavaScript 的 Object 方法没有直接关系。JavaScript 里对应的常见信息是Cannot read properties of undefined或Cannot read properties of null。看到这些报错先确认运行环境再决定用Object.hasOwn判空、可选链?.还是默认值兜底。不要一看到 object 相关报错就往Object.keys上想跨语言排查最容易在这里浪费时间。8. 一张速查表收尾用时只需要查这里你需要做的事首选 API注意事项获取对象自身可枚举键Object.keys(obj)不含 Symbol 和不含不可枚举属性获取对象自身所有字符串键Object.getOwnPropertyNames(obj)含不可枚举字符串键获取对象自身所有 Symbol 键Object.getOwnPropertySymbols(obj)只含 Symbol浅拷贝对象Object.assign({}, obj)或{ ...obj }嵌套对象是引用共享深拷贝简单数据structuredClone(obj)不支持函数循环引用可用按指定原型创建对象Object.create(proto)常用Object.create(null)做安全字典完整复制属性描述符Object.getOwnPropertyDescriptorsObject.defineProperties保留 getter/setter 和不可枚举状态禁止新增属性Object.preventExtensions(obj)已有属性仍可改密封对象Object.seal(obj)属性不可删描述符不可改冻结对象Object.freeze(obj)浅冻结嵌套对象还能改判断自有属性Object.hasOwn(obj, key)避开 hasOwnProperty 被覆盖的问题获取原型Object.getPrototypeOf(obj)不要依赖__proto__直接读修改原型Object.setPrototypeOf(obj, proto)会影响引擎优化少用属性归属判断key in obj查原型链hasOwn查自有两种语义不能混对象转原始值自定义Symbol.toPrimitive比重写 toString/valueOf 更可控我个人的习惯是凡是要判断属性归属一律Object.hasOwn凡是要拷贝对象先问自己“这里面有没有函数、Symbol、循环引用、Date”有就扔给structuredClone没有才用展开或assign凡是遇到Cannot redefine property或者Cannot assign to read only property第一件事永远是Object.getOwnPropertyDescriptors不要瞎猜。很多同学喜欢把 Object 的方法一个个背下来但真到项目里能帮你解决实际问题的是理解每个 API 背后那套权限规则而不是命令本身。把这篇文章里的分类和典型坑过一遍你已经比大多数只背Object.keys的人更接近对象的底层了。
返回列表