ARTICLE DETAIL

资讯详情

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

JavaScript Object 方法全解析:遍历、拷贝、冻结与序列化实战

JavaScript Object 方法全解析:遍历、拷贝、冻结与序列化实战 每次面试候选人我基本都会从一个看似简单的问题开始你平时怎么遍历一个对象Object.keys、for...in还能想到什么十个里有七个能顺利说出这两样提到Object.entries、Object.values的就算有底子要是有人能主动讲到Object.getOwnPropertyDescriptor、Reflect.ownKeys、Object.fromEntries我会立刻提起兴趣。但真正写业务代码的时候又是另一番光景——清洗接口数据、缓存映射、表单校验、状态管理几乎每行代码都在和Object打交道很多人却只用了对象字面量和点号取值。Object是JavaScript里最容易被低估的内置对象。它不是一个普通的对象而是一整套属性操作、遍历机制、原型链控制、不可变性策略、序列化约定以及和Proxy、Reflect、JSON协作的完整体系。这篇内容不打算按文档顺序背一遍方法列表而是按实际开发中最常遇到的场景把Object的所有方法、特性、坑位一次讲透。无论你是刚写完第一个项目的初级前端还是被生产bug折腾到深夜的工程师都能从中补充一些平时用得上但没被系统整理过的知识。1. 先分清Object方法的两条线静态与原型1.1 为什么Object会有静态方法这一说很多人第一次看到Object.keys这种写法会困惑Object本身不是构造函数吗它上面的方法怎么和实例方法不一样这里要分清楚两条线。第一条线是构造器本身挂载的方法叫静态方法直接通过Object.xxx调用。例如Object.keys(obj)、Object.assign(target, source)接收的参数通常是对象或者目标对象加源对象。第二条线挂在Object.prototype上所有普通对象都能通过原型链继承拿到所以实例可以直接obj.toString()、obj.hasOwnProperty(name)这样调用。理解这两条线的区别非常关键。静态方法是站在Object这个工具人视角去操作对象实例方法是站在对象自己身上去查询或修改自己的信息。面试里经常问的hasOwnProperty和Object.hasOwn的区别本质上就是这两条线在同一个功能上的两种实现。早期没有Object.hasOwn的时候大家只能用obj.hasOwnProperty()但这个方法有个隐患——如果对象自身覆盖了hasOwnProperty字段比如后端返回了一个{ hasOwnProperty: hack }直接调用就会爆炸。后来ES2022推出Object.hasOwn这个静态方法就是专门用来规避这个问题的。1.2 Object方法全量清单先混个脸熟以下清单几乎覆盖了标准内置Object的全部方法。分类方法名引入版本一句话功能静态方法Object.keysES2017返回自身可枚举字符串属性名数组静态方法Object.valuesES2017返回自身可枚举属性值数组静态方法Object.entriesES2017返回自身可枚举[key, value]数组静态方法Object.fromEntriesES2019把键值对列表反转为对象静态方法Object.assignES2015合并可枚举属性到目标对象静态方法Object.createES5指定原型创建新对象静态方法Object.definePropertyES5定义单个属性的描述符静态方法Object.definePropertiesES5定义多个属性的描述符静态方法Object.getOwnPropertyDescriptorES5读取单个属性描述符静态方法Object.getOwnPropertyDescriptorsES2017读取全部属性描述符静态方法Object.getOwnPropertyNamesES5获取所有非Symbol键名静态方法Object.getOwnPropertySymbolsES2015获取所有Symbol键名静态方法Object.getPrototypeOfES5获取对象的原型静态方法Object.setPrototypeOfES2015设置对象的原型静态方法Object.isES2015严格相等加强版处理NaN和±0静态方法Object.isExtensible / isSealed / isFrozenES2015三种锁定状态检查静态方法Object.preventExtensionsES2015禁止扩展属性静态方法Object.sealES2015封印对象不能增删属性静态方法Object.freezeES2015冻结对象属性和描述符全部锁死静态方法Object.hasOwnES2022判断自有属性比实例方法安全静态方法Object.groupByES2024按回调分组生成对象实例方法obj.hasOwnPropertyES3判断自有属性实例方法obj.isPrototypeOfES3判断是否在原型链上实例方法obj.propertyIsEnumerableES5判断属性是否可枚举实例方法obj.toStringES1转为字符串描述实例方法obj.valueOfES1转为原始值实例方法obj.toLocaleStringES1转为本地化字符串看到这个清单你大概明白了为什么总有人说Object方法太多记不住——确实多。但方法论上不用死记按查增删改、锁原型、转格式几类去理解自然就串起来了。其中的__proto__、__defineGetter__这类废弃实例方法我就不列了知道它们存在且不要在新代码里碰就好。2. 遍历三兄弟与对象转换链路keys / values / entries / fromEntries2.1 三兄弟的边界差异符号键和不可枚举属性去哪了先做一个最基础但容易翻车的实验。const person { name: 张三, age: 30, [Symbol(id)]: 100 }; Object.defineProperty(person, hidden, { value: true, enumerable: false }); console.log(Object.keys(person)); // [name, age] console.log(Object.values(person)); // [张三, 30] console.log(Object.entries(person)); // [[name, 张三], [age, 30]]这里有几个关键点三兄弟都只返回自身的可枚举属性不包含原型链上的三兄弟都不含Symbol键想要Symbol键得用Object.getOwnPropertySymbols想一次性拿全用Reflect.ownKeys用Object.defineProperty定义属性时如果不显式设置enumerable默认就是false所以hidden进不了遍历。这个坑相当常见——某些库封装对象属性后你Object.keys看到的键变少了其实只是描述符问题。2.2 fromEntries把键值对列表翻回对象Object.fromEntries是entries的逆向操作入参是可迭代的键值对列表最常见的就是entries输出的数组或者Map实例。const params [[name, 张三], [age, 30]]; const obj Object.fromEntries(params); // { name: 张三, age: 30 } const map new Map([[a, 1], [b, 2]]); Object.fromEntries(map); // { a: 1, b: 2 }实际工作中常见场景是URLSearchParams转对象或者过滤对象的部分字段后重组。比如后端返回一个很大的对象你只想要其中几个字段并改名const resp { id: 1, userName: zhangsan, email: ab.com }; const picked Object.fromEntries( Object.entries(resp).filter(([key]) [userName, email].includes(key)) );注意fromEntries只能处理二维键值对如果value本身是对象它不会递归转换原样保留即可。2.3 为什么对象没有contains方法很多从C#转过来的同事会问JS对象为什么没有contains方法字符串、数组都有includes对象怎么判断里面有没有某个值原因其实很朴素对象是键值对结构判断里面有没有这个键走的是哈希查找有原生优化但判断里面有没有这个值本质上是O(n)扫描没必要内置一个API。所以推荐组合方式判断键是否存在key in obj或Object.hasOwn(obj, key)判断值是否存在Object.values(obj).includes(targetValue)判断键值对是否存在Object.entries(obj).some(([k, v]) k key v value)2.4 用数组方法处理对象entries的降维打击既然对象本身没有map、filter最常见的做法就是把对象转成数组再用数组方法。这也是数组方法和Object方法协同最舒适的区域。const users { alice: 20, bob: 30, carol: 40 }; const adults Object.entries(users) .filter(([, age]) age 30) .map(([name, age]) ({ name, age })); // [{ name: bob, age: 30 }, { name: carol, age: 40 }]这种写法比for...in加hasOwnProperty加临时数组干净得多而且一眼能看懂数据流的形状。遇到对象里挑几个字段对象转列表这类需求优先想想能否用entries把对象降维成数组。3. Object.assign与复制底层逻辑浅拷贝、Symbol与descriptor3.1 assign不是深拷贝这是老生常谈但必须再谈Object.assign(target, ...sources)的行为相当于把每个源对象上可枚举的自有属性用[[Set]]语义拷贝到target。当源属性的值是个对象或数组时拷贝的是引用这就是浅拷贝的定义。项目里最常见的错误就是拿Object.assign当深拷贝用const a { user: { name: 张三 } }; const b Object.assign({}, a); b.user.name 李四; console.log(a.user.name); // 李四a被改了要深拷贝现在最稳妥的姿势是structuredClone后面第7章细说。Object.assign适合的场景是合并配置、给state做部分更新、把多个对象合到一个新对象里。3.2 assign会漏掉什么不可枚举属性和Symbolassign只处理可枚举属性但Symbol键如果可枚举也会拷贝很多人不知道这一点const sym Symbol(id); const src { [sym]: 1, normal: 2 }; Object.defineProperty(src, secret, { value: 3, enumerable: false }); const target Object.assign({}, src); // target { normal: 2, [sym]: 1 }secret没有过来所以如果要实现完全拷贝连不可枚举属性和所有Symbol键都带上就得手动组合function copyObjectCompletely(obj) { return Object.defineProperties( {}, Object.getOwnPropertyDescriptors(obj) ); }这里Object.getOwnPropertyDescriptors返回的是所有属性的完整描述符再用defineProperties挂到新对象上信息几乎无损。这个方法在实际做对象快照时很好用。3.3 defineProperty与descriptor体系对象属性的底层控制Object.defineProperty(obj, key, descriptor)是对象底层属性定义的标准姿势。descriptor有6个字段value、writable、get、set、configurable、enumerable。其中configurable: false意味着之后不能改描述符、不能删除属性是一次性锁死。getter/setter不能和value/writable同时设置否则直接抛TypeError。这个方法的实际使用价值不只是面试题。Vue 2的响应式原理就是通过defineProperty把data里的每个属性改造成getter/setter这也是为什么Vue 2文档强调对象新增属性不是响应式的——因为新增属性没有走defineProperty这一步。很多人用Vue 2改了对象某个新字段没反应真相就在这里。用defineProperty写一个只读属性不靠freeze是常见操作const config {}; Object.defineProperty(config, apiUrl, { value: https://api.example.com, writable: false, configurable: false }); config.apiUrl https://evil.com; // 非严格模式静默失败严格模式抛错3.4 create指定原型创建对象的隐藏价值Object.create(proto, propertiesObject)用来创建一个以proto为原型的新对象。它和{}的区别在于{}等价于Object.create(Object.prototype)而Object.create(null)能创建一个完全没有原型的干净对象适合当字典用不会受到原型链污染。const pure Object.create(null); pure.name pure; console.log(pure.toString); // undefined因为没有原型toString这种干净对象在做哈希表、解析不可信JSON字段时很安全。普通JSON对象如果__proto__被污染在某些赋值场景下可能引发原型链问题而Object.create(null)天然隔离了原型的干扰。4. 冻结封印三兄弟freeze / seal / preventExtensions的边界4.1 三兄弟的差别必须背清楚操作新增属性删除属性修改属性值修改属性描述符恢复扩展preventExtensions禁止可以可以可以不可seal禁止禁止可以不可以不可freeze禁止禁止禁止不可以不可三个都是不可逆操作调用前务必要确认这份对象后续不会再被动态扩展。seal相当于preventExtensions再叠加现有属性全部设成configurable: falsefreeze又在seal基础上叠了一层writable: false。但要注意如果属性本身是getter没有writable概念freeze后getter函数引用不会变但返回值随时可能变。4.2 freeze只冻一层嵌套对象的经典误判这是生产环境最大的坑。Object.freeze是浅冻结嵌套对象不会被冻结const state Object.freeze({ cart: { items: [] } }); state.cart.items.push(1); // 完全能push因为cart的引用没变items数组本身没冻结想深冻结要么自己递归要么干脆依赖不可变更新模式。Redux这类前端状态方案之所以强调不可变更新本质上就是配合冻结结构来保证可预测性。如果你只是想把配置快照防篡改浅冻结已经够用但如果要防的是一整个状态树就得深度处理。4.3 误用freezeVue 2里的静默事故Vue 2的响应式系统会遍历data对象每个属性做defineProperty如果对象被Object.freeze这个操作会静默失败导致视图不更新。我做过一次线上事故复盘登录后把用户信息Object.freeze了准备缓存结果页面上的用户名一直不刷新排查了两小时才发现是freeze在作祟。结论是如果你确认一个对象不会再变化freeze能带来性能和安全性双收益Vue会跳过对冻结对象的响应式处理但一旦后续代码试图改它等待你的就是静默失败。比较好的实践是在功能测试阶段用console.assert(Object.isFrozen(obj))检查关键路径确认没有地方在偷偷改冻结对象。5. 实例方法里的隐藏角色toString、hasOwnProperty、propertyIsEnumerable5.1 toString的意外能力比typeof和instanceof更可靠的类型判断几乎每个对象调用toString默认返回的是[object Object]这其实是Object.prototype.toString的执行结果。它真正强大之处在于能通过call识别内置类型Object.prototype.toString.call([]); // [object Array] Object.prototype.toString.call(null); // [object Null] Object.prototype.toString.call(undefined); // [object Undefined] Object.prototype.toString.call(new Date()); // [object Date]这个方法比typeof和instanceof都可靠。尤其在前端跨iframe场景下instanceof会因为不同window的Array构造函数不同而失效toString却不受影响。这也是很多成熟库内部判断类型时的标准姿势。5.2 hasOwnProperty与in查自己的还是查整个原型链hasOwnProperty只查自己的属性不管原型链in操作符会一直往原型链上找。const obj { a: 1 }; console.log(obj.hasOwnProperty(a)); // true console.log(hasOwnProperty in obj); // true因为原型链上有这个方法区分这两者对写健壮代码非常重要。用for...in遍历时它会把原型链上可枚举属性也带出来所以早期很多人习惯在for...in里再套hasOwnProperty过滤这个习惯现在可以用Object.keys完全替代——keys只返回自身属性。5.3 propertyIsEnumerable判断属性是否可枚举的自有属性propertyIsEnumerable在通用遍历工具里很实用。比如封装一个序列化函数想排除不可枚举属性可以先hasOwnProperty再加propertyIsEnumerable双重过滤等价于Object.keys的行为。不过日常业务中这个API用得确实不多了解即可。5.4 健壮写法用Object.hasOwn替代hasOwnProperty前面提到Object.hasOwn是ES2022新增的静态方法。强烈建议今后统一用Object.hasOwn(obj, key)不要再用obj.hasOwnProperty(key)。原因就是避免对象自身覆盖原型方法。在解析外部接口数据时这个选择能避免一类隐蔽的运行时异常。6. Proxy与Object的组合拦截、Reflect与响应式实现6.1 Proxy到底做了什么很多人在网上搜proxy(object)转换object就会搜到Proxy。它本质是在目标对象和访问者之间加了一道代理层所有对目标对象的读取、写入、删除、遍历等操作都会先经过handler里的钩子函数。最常用的钩子是get、set、has、deleteProperty、ownKeys。对于普通业务get和set已经覆盖九成需求。const handler { get(target, key, receiver) { console.log(你正在读取 ${String(key)}); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { console.log(你正在设置 ${String(key)} ${value}); return Reflect.set(target, key, value, receiver); } }; const obj new Proxy({ count: 1 }, handler); obj.count; // 你正在读取 count // 你正在设置 count 2这已经是一个最小的响应式雏形。Vue 3、Mobx这类库的核心机制本质就是这套代理加订阅的模型。6.2 为什么总是配ReflectReflect是ES2015出的全局对象它把定义在Object上的反射式操作统一语义化。在Proxy的get/set里规范做法是先调Reflect.get(target, key, receiver)原因有三个Reflect.get返回结果而不是隐式执行可以传receiver确保getter里的this指向代理对象本身Reflect和Proxy的钩子是镜像接口写起来不容易漏参数。如果不传receiver一个典型Bug是原对象里嵌套的getter访问的是target而不是proxy内部状态更新就绕过了代理层。6.3 深层代理的正确姿势Proxy默认只监听一层嵌套对象的属性变更不会触发set。网上很多递归代理的实现要么在get里懒代理要么直接把整个对象深拷贝一份都各有毛病。我在项目里用的是懒代理方案只有真正访问到嵌套对象时才创建代理function deepProxy(target, handler) { const memo new WeakMap(); const wrap (obj) { if (memo.has(obj)) return memo.get(obj); const proxy new Proxy(obj, { ...handler, get(target, key, receiver) { const val Reflect.get(target, key, receiver); if (typeof val object val ! null) { return wrap(val); } if (handler.get) return handler.get(target, key, receiver); return val; } }); memo.set(obj, proxy); return proxy; }; return wrap(target); }这里有个必须注意的点memo用WeakMap缓存避免同一个对象反复创建多个代理否则对象相等性判断会出问题。响应式系统里出现过同一个state两套proxy互相不同步的诡异bug多半就是没做缓存。6.4 与Object方法的协作代理对象上的ownKeys陷阱对代理对象调用Object.keys依然有效但返回结果取决于handler是否定义了ownKeys钩子。如果定义了ownKeys却没有配合getOwnPropertyDescriptor钩子很可能出现keys返回了键但属性描述符不一致的异常。实践建议是能不覆盖ownKeys就不覆盖让Reflect的默认行为接管。7. JSON序列化与跨语言对象stringify、toJSON、structuredClone7.1 JSON.stringify的三个硬限制JSON.stringify序列化Object时有三个容易踩的硬限制只处理可枚举的自有属性所以class实例的方法、非枚举属性都会丢。值为undefined、函数、Symbol的属性会被整个跳过数组里遇到这些值会变成null。对象出现循环引用会直接抛TypeError。这就是为什么很多人拿JSON.parse(JSON.stringify(obj))做深拷贝时Date变成字符串、RegExp变成{}、undefined字段被丢弃。它从来不是万能的副本生成器。7.2 自定义toJSON序列化前的手动干预一个对象如果定义了toJSON方法JSON.stringify会优先调用它把返回值作为序列化结果。这让跨系统字段映射变得异常方便。class Money { constructor(amount, currency) { this.amount amount; this.currency currency; } toJSON() { return { value: this.amount, currency: this.currency }; } } JSON.stringify(new Money(10, CNY)); // {value:10,currency:CNY}这种设计对接口层尤其好用。后端要的字段名和前端领域对象里的属性名不一致时不用写一堆映射函数直接在实体类里放个toJSON就行。7.3 跨语言视角C#的JsonElement和JS这边怎么对应有个热搜词是c#将object序列号化为jsonelement这是C#那边把object转成System.Text.Json里的JsonElement类型。JS这边的对应物就是JSON.parse之后的对象结构以及JSON.stringify之前的原始对象。跨语言传对象时共同原则是不要直接传语言特有的类型Date、BigInt、class实例要传可互操作的结构化数据string、number、boolean、plain object、Array、null。前端传给C#时一个Date对象如果不处理会变成ISO字符串对方再反序列化就可能解析成字符串而不是DateTimeBigInt在JSON.stringify里直接抛错。这些都属于对象方法之外更底层的序列化契约。7.4 structuredClone深拷贝的正解现代浏览器和Node 17都有structuredClone能正确处理Date、Map、Set、ArrayBuffer、Blob等类型还支持循环引用。它比JSON方案强太多const original { date: new Date(), map: new Map([[a, 1]]) }; const clone structuredClone(original); clone.date.setTime(0); console.log(original.date.getTime()); // 原始对象不受影响代价是它的克隆是结构化克隆算法自定义class实例会退化成普通对象原型丢失。如果需要保留类实例的方法还是要自己写深拷贝或序列化还原。8. 异步世界里的对象Promise、async状态与并发控制8.1 Promise本质就是一个Object很多人学async/await时忽略了Promise本身是个对象。它有statuspending/fulfilled/rejected和value/reason内部槽外部能通过then、catch、finally观测状态变化。真正理解这一点对调试很有帮助const p Promise.resolve({ user: 张三 }); console.log(Object.keys(p)); // [] console.log(Object.getOwnPropertyNames(Promise.prototype)); // [then, catch, finally]Promise实例内部状态是藏起来的这是为了保证可靠性——状态只能由executor改变外界无法手动篡改。这种内部槽不可外部访问的设计和Object.freeze在另一个方向上保护了数据完整性。8.2 async函数返回的永远是Promise对象async函数的外部调用方拿到的永远是Promise即使async函数内部直接return一个普通对象外部拿到的也是被Promise包裹的对象。async function getUser() { return { name: 张三 }; } const result await getUser(); console.log(result instanceof Object); // true console.log(Object.keys(result)); // [name]这种包裹带来的坑是如果异步函数里return了一个Promise但没有await会出现双重包裹外层对象变成Promise套Promise某些自动化场景里会被当成普通对象处理导致Object判断失败。规范姿势是统一让内部函数返回plain object由调用方决定是否await。8.3 用一个Object管理异步任务状态一个非常实用的pattern是用几个字段管理请求状态const state { loading: false, error: null, data: null }; async function fetchData() { state.loading true; state.error null; try { const resp await fetch(/api/data); state.data await resp.json(); } catch (e) { state.error e.message; } finally { state.loading false; } }这种状态对象的好处是能被Object.assign批量更新或者配合计算属性映射成UI状态。但要注意并发场景如果同时发起多个请求共享同一个state对象后完成的请求可能覆盖先完成的。标准的并发控制要设计成每个请求一个独立key的子对象或者用Map、Promise.all管理聚合状态。8.4 用Object做并发结果收集器写一个并发拉取多个接口汇总到一个对象的工具async function fetchAll(uris) { const results {}; await Promise.all( uris.map(async ({ key, url }) { results[key] await fetch(url).then(r r.json()); }) ); return results; }注意这里每个key是独立赋值的所以回调顺序不会影响最终结果。如果你依赖回调顺序去处理反而容易出问题。这种对象作为哈希收敛器是异步场景里Object方法最实际的用法之一。9. 综合实战收尾一个带校验的配置中心9.1 为什么围绕Object做一个配置中心前面的知识是零散的最好的收尾是串成一个能落地的工具一个支持默认值、只读保护、变更订阅、类型校验的小型配置中心。需求很小但正好用到Object.assign、Object.defineProperty、Object.freeze这几个核心方法。9.2 代码实现function createConfig(defaults, validators {}) { const internal {}; Object.keys(defaults).forEach(key { const fieldKey __value_${key}; internal[fieldKey] defaults[key]; Object.defineProperty(internal, key, { enumerable: true, get() { return internal[fieldKey]; }, set(value) { const validator validators[key]; if (validator !validator(value)) { throw new Error(配置项 ${key} 校验失败); } internal[fieldKey] value; if (typeof internal.notify function) { internal.notify(key, value); } } }); }); internal.notify null; internal.subscribe (fn) { internal.notify fn; }; return Object.freeze(internal); } const config createConfig( { retry: 3, timeout: 1000 }, { retry: v v 0 v 10 } ); config.subscribe((key, value) console.log(${key} 已更新为 ${value})); config.retry 5; console.log(config.retry); // 5这里有个理解重点Object.freeze只是冻结了internal上每个属性的描述符但getter函数本身还是能读取底层变化的。所以保护在这里的含义是不能新增属性、不能删除属性、不能改setter逻辑而不是值不可变。9.3 这个工具里踩过的三个坑第一defineProperty里的set访问器不能直接写internal[key] value因为会再次触发set导致无限递归。标准做法是存到一个隐藏字段我用的是__value_前缀如果担心字段名冲突可以换成WeakMap存值。第二Object.freeze的顺序要在subscribe设置之后。如果先把对象freeze了再设置notify因为notify字段本来不存在设置会静默失败。这个顺序问题可以帮人加深记忆freeze是最后一道锁锁之前一定要把运行时需要的挂载全做完。第三校验器抛错的设计如果有人误配置宁可抛错让启动流程立刻失败也不要静默降级否则线上问题定位会非常痛苦。配置类对象出事要响亮不能藏着掖着。9.4 扩展方向与经验沉淀这套思路可以延伸出很多方向给配置中心加一层Proxy就能自动记录谁修改了什么字段形成审计日志配合Object.entries做配置项自动生成文档配合structuredClone把配置快照持久化到localStorage。Object方法从来不是孤立的它们是一套可以组合的积木理解了每块积木的边界写出来的代码自然更稳。最后分享一个排查对象相关bug的小技巧我习惯先在控制台跑一遍Object.getOwnPropertyDescriptor(obj, key)把每个属性的writable、enumerable、configurable打出来看很多时候莫名改不动遍历不到的根源都在描述符这一层而不是逻辑层。搞清楚这一层Object相关的坑基本就避掉一大半了。
返回列表