ARTICLE DETAIL

资讯详情

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

JS对象遍历全指南:从for...in到Object.entries的选型与避坑

JS对象遍历全指南:从for...in到Object.entries的选型与避坑 接手过一个老项目代码里有一段“JS遍历对象”写得惨不忍睹for...in套了好几层没有hasOwnProperty判断把原型链上的公共方法都遍历了出来页面一度白屏。排查到最后才发现是遍历方式选错了。这件事之后我就一直想专门写一篇关于 JS 遍历对象的文章把for...in、Object.keys、Object.values、Object.entries这些方法掰开揉碎讲清楚再把实际业务里高频出现的“判断对象为空”“对象转请求参数”“对象数组去重”“遍历时删除属性”这些场景串起来让新手能直接照着写也让写过一段时间但没系统性梳理过的同学能避开那些隐蔽的坑。这篇文章我不会只罗列 API更想把每个方法背后的设计逻辑、性能差异和适用边界讲明白。比如为什么Object.keys比for...in更适合默认场景为什么遍历时删除属性会出现漏项为什么两万行数据用JSON.parseArray解析后遍历也会卡。搞懂这些“为什么”你以后写遍历代码心里就有底而不是靠记结论。1. 先搞懂本质对象和数组在遍历上的差异从哪来1.1 对象不是数组遍历逻辑完全不同很多新手从数组的forEach、map进入 JS 世界一碰到对象就下意识想“对象能不能也 forEach 一下”。答案是不行因为对象和数组在数据结构层面就不是一回事。数组是有序集合元素靠索引数字下标访问天然适合按顺序遍历。对象则是“无序”的键值对集合属性之间靠字符串 key 区分没有索引概念。这里的“无序”要打引号因为 ES2015 之后对象属性有明确的遍历顺序规则这个后面专门讲。换个更直白的类比数组像排队的人一个挨一个数下标就能找到第几个人对象像一个人的通讯录每一条记录有一个名字key你要“遍历”通讯录不能按数字一个个来只能把名字逐个翻出来再取对应号码。也就是说遍历对象的本质是“先拿到对象所有的 key再根据 key 取 value”。JS 提供了一系列 API 帮你完成“拿 key”这件事只是拿法各有不同有的只拿自己身上的有的连继承的也拿有的连不可枚举的属性也拿。1.2 for...in 的真实行为会顺着原型链往上翻for...in是最“古老”的遍历对象方式但它有个很容易被忽略的行为——会遍历到原型链上可枚举的属性。看这个例子function Person(name) { this.name name; } Person.prototype.sayHi function () { console.log(hi); }; const p new Person(张三); for (const key in p) { console.log(key); // 输出 name也会输出 sayHi }原本只想遍历对象自身的name结果把原型上挂的sayHi方法也带出来了。这就是老项目出现诡异的undefined、方法被意外调用、甚至白屏的根源。有人可能会说加上hasOwnProperty判断不就完了。确实这是标准做法for (const key in p) { if (Object.hasOwn(p, key)) { console.log(key); // 只输出 name } }这里我用了Object.hasOwn而不是obj.hasOwnProperty原因有两个一是Object.hasOwn是 ES2022 引入的静态方法更规范也更安全二是如果对象上恰好定义了hasOwnProperty这个属性直接调用obj.hasOwnProperty会因 this 指向问题报错静态方法没有这个隐患。但即便加了判断for...in仍然不是默认首选。因为它还有其他局限比如不能遍历 Symbol 类型的 key、遍历顺序在某些边界情况不够直观。尤其在现代前端工程里对象通常都是“纯数据容器”我们根本不需要关心原型链所以大多数场景直接用Object.keys更省心。2. 核心 API 盘点四把遍历对象的“钥匙”怎么选2.1 Object.keys默认首选返回自身可枚举字符串属性名Object.keys接收一个对象返回一个由该对象自身可枚举属性名组成的数组。自身、可枚举、字符串 key三个限定条件缺一不可。const user { id: 1, name: 李四, hobbies: [coding, reading], }; Object.keys(user); // [id, name, hobbies]拿到这个数组后面就自由了。可以用forEach、map、for...of任意方式处理Object.keys(user).forEach((key) { console.log(key, user[key]); });为什么说它是默认首选因为它在语义上和“对象自身的数据字段”完全对应。绝大多数业务对象就是从接口拿到的 JSON 数据没有原型链污染问题Object.keys返回的就是你要遍历的那些字段。相比for...in它不用额外判断结果更可控性能通常也更好。注意Object.keys有个边界行为传基本类型值不会直接报错会先转成对象但传null或undefined会抛TypeError。所以写通用工具函数时要先判空function safeKeys(obj) { if (obj null || obj undefined) return []; return Object.keys(obj); }2.2 Object.values 和 Object.entries直接处理值和键值对如果只关心对象的“值”用Object.values一步到位const user { id: 1, name: 李四, age: 28 }; Object.values(user); // [1, 李四, 28]这个 API 在处理“对象里存了一堆数据但我只想知道有哪些值”的场景下很香。比如统计对象里有多少个非空值const config { host: localhost, port: 8080, password: }; const nonEmptyCount Object.values(config).filter((v) v ! v ! null).length;需要同时拿 key 和 value 时用Object.entriesfor (const [key, value] of Object.entries(user)) { console.log(${key}: ${value}); }解构赋值的写法让整个遍历过程非常丝滑。而且Object.entries配合数组方法很自然——它返回的本身就是[key, value]对组成的数组Object.entries(user) .filter(([, value]) value ! null) .map(([key, value]) ${key}${value}) .join();上面这段其实就是常见的“对象转 query string”的雏形后面实战章节会展开完整版。2.3 for...of 和 Object.entries 的组合获得和数组一致的遍历体验for...of是 ES6 新增的迭代语法专门用来遍历可迭代对象。对象本身不是可迭代的但Object.keys、Object.values、Object.entries返回的数组是可迭代的所以两者配合非常常见。for (const key of Object.keys(obj)) { // 只遍历 key } for (const value of Object.values(obj)) { // 只遍历 value } for (const [key, value] of Object.entries(obj)) { // 同时遍历 key 和 value }这种写法比for...in最大的优势是你可以用break、continue、return控制循环流程这在forEach里做不到。比如找一个满足条件的值就提前结束const scores { math: 90, english: 85, art: 95 }; let passSubject null; for (const [subject, score] of Object.entries(scores)) { if (score 90) { passSubject subject; break; // 找到就退出 } }2.4 四把钥匙的对比速查表方法返回内容是否包含继承属性是否包含不可枚举属性是否包含 Symbol key适用场景for...inkey 名包含不包含不包含需要连原型链一起遍历的极少数场景Object.keys()key 名数组不包含不包含不包含默认首选绝大多数普通遍历Object.values()value 数组不包含不包含不包含只关心值的场景Object.entries()[key, value] 数组不包含不包含不包含需要同时处理 key 和 value还有一个冷门但有用的 APIObject.getOwnPropertyNames返回自身所有属性名包含不可枚举的Reflect.ownKeys则是连 Symbol key 也一并返回。这两个一般用在校验特殊对象、实现代理或调试时日常业务遍历用不上但要知道它们存在。3. 遍历对象的“前置问题”拿到的对象到底是不是个空壳3.1 判断对象是否为空的正确姿势业务代码里最常遇到的一个问题就是“这个对象到底有没有数据”。很多人直接写if (obj)这是错的——{}是 truthy条件永远成立。判断对象是否为空的本质就是看它“自身可枚举的 key 数量”是否为 0。所以最简单的写法是function isEmptyObject(obj) { return Object.keys(obj).length 0; }需要注意Object.keys只统计自身可枚举属性一个对象如果只在原型链上有属性Object.keys([])返回空数组也判为空这符合绝大多数业务预期。JSON.stringify(obj) {}也能判断但有两个问题一是性能不如Object.keys内部要做序列化数据量大时慢得多二是只对纯 JSON 友好如果对象里有undefined、函数、Symbol序列化会直接忽略这些字段结果失真。用for...in加hasOwnProperty也能判断但代码啰嗦function isEmptyObject(obj) { for (const key in obj) { if (Object.hasOwn(obj, key)) return false; } return true; }这种写的唯一优势是遍历到第一个 key 就提前返回理论上比Object.keys(obj).length 0少一次新建数组的开销。但对绝大多数场景来说这点性能差可以忽略Object.keys一行式的可读性明显更好。3.2 从“遍历对象”到“对象转 Query String”一版能上生产的工具函数“对象转 URL 查询参数”是遍历对象最高频的实战场景之一。比如前端要把筛选条件传给后端{ page: 1, size: 10, keyword: js }要变成?page1size10keywordjs。很多项目里直接手写循环拼接到处复制粘贴。这里给你一版可以放进工具库的函数覆盖了嵌套对象、数组、空值过滤和 URL 编码这些常见需求function objectToQueryString(obj, prefix ) { if (obj null || obj undefined) return ; const pairs []; for (const [key, value] of Object.entries(obj)) { const fullKey prefix ? ${prefix}[${key}] : key; if (value null || value undefined || value ) { continue; // 跳过空值 } if (Array.isArray(value)) { // 数组展开成重复 key例如 tagsatagsb value.forEach((item) { pairs.push(${encodeURIComponent(fullKey)}${encodeURIComponent(item)}); }); } else if (typeof value object) { // 递归处理嵌套对象生成 a[b]c 格式 const nested objectToQueryString(value, fullKey); if (nested) pairs.push(nested); } else { pairs.push(${encodeURIComponent(fullKey)}${encodeURIComponent(value)}); } } return pairs.join(); }关键点有三个第一用Object.entries加for...of遍历可以continue跳过空值第二数组展开成多个同 key 参数这是很多后端框架如 Java 的RequestParam ListString的标准解析格式第三所有 key 和 value 都做encodeURIComponent编码避免中文和特殊字符导致 URL 解析错误。类似思路在 Vue 或 uni-app 项目里也常见把对象转换成后端要求QueryWrapper的查询条件比如{ name: 张 }转成{ like: { name: 张 } }。核心还是先Object.entries拿到键值对再按目标结构重新组装。掌握了遍历对象的本质这些功能都是水到渠成的事。4. 实战场景用遍历解决真实业务里的“硬骨头”4.1 对象数组去重双重遍历和“登记表”方案怎么选“对象数组去重”是招聘面试和日常业务里的常客。比如接口返回了一个用户列表同一用户出现多次要根据userId去重。先看最常见但也最容易出问题的写法const list [ { id: 1, name: a }, { id: 2, name: b }, { id: 1, name: a }, ]; const result []; for (const item of list) { if (!result.some((r) r.id item.id)) { result.push(item); } }这是双重遍历外层遍历原数组内层some遍历结果数组。数据量小没问题数据量大了之后时间复杂度是 O(n²)一万条数据就要跑上亿次比较卡顿非常明显。更高效的做法是用一个 Map 当“登记表”遍历一次按唯一键登记const seen new Map(); const result []; for (const item of list) { if (!seen.has(item.id)) { seen.set(item.id, true); result.push(item); } }复杂度从 O(n²) 降到了 O(n)整个遍历过程还是对数组的遍历但“去重”这个动作从“嵌套扫描”变成了“哈希查表”。Map 在查找性能上远优于数组的线性扫描这是根本原因。如果想保持原顺序这套写法天然满足。如果要根据多个字段去重把 key 拼起来当 Map 的键即可const seen new Map(); for (const item of list) { const key ${item.type}-${item.id}; if (!seen.has(key)) { seen.set(key, true); result.push(item); } }4.2 忽略大小写的字段匹配遍历时的归一化处理有时接口返回的字段大小写不规范比如有的字段是UserName有的是username前端做匹配时常被坑。这时可以遍历对象把所有 key 统一转成小写生成一个“归一化副本”function normalizeKeys(obj) { const normalized {}; for (const [key, value] of Object.entries(obj)) { normalized[key.toLowerCase()] value; } return normalized; } const raw { UserName: 张三, Email: ab.com }; const normalized normalizeKeys(raw); // 之后统一用 normalized.username 取值这比在每个判断点都做一次大小写转换要干净得多。“先归一化再统一处理”的思路在清洗接口数据时非常实用。注意边界toLowerCase对已经是小写的 key 不产生变化所以覆盖原对象也安全。但如果对象里同时存在UserName和username后遍历到的会覆盖先遍历到的这个行为要在注释里说清楚避免同事误用。4.3 字段存在性与 URL 有效性校验遍历配合条件判断判断一个对象是否包含某个特定 key标准做法是用Object.hasOwn或Object.prototype.hasOwnProperty.call而不是遍历。但如果你要批量检查“一组 key 是否都存在”遍历就有意义了const requiredFields [name, phone, address]; const formData { name: 王五, phone: 138xxxx, address: xx路 }; const missingFields requiredFields.filter((field) !Object.hasOwn(formData, field)); if (missingFields.length 0) { console.log(缺少字段, missingFields.join(, )); }这个场景底层其实也是遍历——遍历的是“需求字段列表”而不是对象本身。这是一种思维转换当对象本身不可控时用一个可控的列表作为遍历基准反而更安全、更高效。URL 有效性校验同理。如果你要检查对象里所有字符串字段是否都符合 URL 格式const links { homepage: https://a.com, blog: not-a-url, docs: https://b.com }; for (const [name, url] of Object.entries(links)) { try { new URL(url); console.log(${name} 合法); } catch { console.error(${name} 不合法${url}); } }用new URL()来做格式验证是相对可靠的方案比正则更不容易漏边界情况而且天然处理了各种协议头。遍历里用try...catch捕获异常不会因为一个字段坏了就中断整体检查。4.4 两万行 JSON 数据转换之后遍历为什么还是会卡有人问过一个问题用JSON.parseArray把一个两万行记录的 JSON 转成了对象数组这个操作本身扛得住吗答案是两万行对 JSON 解析来说并不算夸张扛得住但解析之后如果你用不当的方式遍历操作卡顿可能出在遍历阶段。最常见的性能杀手是“遍历里套遍历”。比如两万条数据每条都要去另一个数组里查找匹配项而且用的是find或some// 不推荐O(两万 * 列表长度) for (const row of bigList) { const match referenceList.find((r) r.id row.id); // ... }正确做法还是“登记表”思路先把参考列表转成 Map再遍历主列表直接查const referenceMap new Map(referenceList.map((r) [r.id, r])); for (const row of bigList) { const match referenceMap.get(row.id); // ... }另外要注意V8 引擎对对象属性的访问有隐藏类优化。遍历时反复用动态字符串访问属性obj[key]无法享受这种优化性能会差一些。如果你的循环里高频访问固定字段直接写成row.id、row.name比统一row[key]快得多。遍历方式本身的性能差异forEachvsfor...ofvs 传统for在数据少时几乎无感数据量大时传统for循环通常最快因为少了一层函数调用开销。所以“两万行”这个规模下真正决定卡不卡的往往不是 JSON 解析而是你遍历访问数据的方式。5. 遍历中删除元素最容易出错的一个操作5.1 对象遍历时删除属性为什么结果和你想的不一样“遍历对象时删除某些属性”是一个隐蔽的坑。看这段代码const data { a: 1, b: 2, c: 3, d: 4 }; for (const key in data) { if (data[key] % 2 0) { delete data[key]; } } console.log(data); // 结果可能和你预期不同在 V8 的实现下对象属性遍历顺序有一定规则后面专门讲。如果你在遍历过程中删除“当前及后续还未遍历到的属性”会导致那些属性被跳过最终结果中残留本应被删除的字段。这里的诡异之处在于你很难一眼看出问题因为有时候结果是对的有时候不对完全取决于属性的排列顺序。正确方式是“先收集后删除”const data { a: 1, b: 2, c: 3, d: 4 }; const keysToRemove []; for (const [key, value] of Object.entries(data)) { if (value % 2 0) { keysToRemove.push(key); } } for (const key of keysToRemove) { delete data[key]; } console.log(data); // { a: 1, c: 3 }用一个中间数组记录要删除的 key遍历结束后再批量删彻底避开“边遍历边改结构”的问题。这个原则同样适用于数组遍历删除——就是下面要讲的经典坑。5.2 数组遍历删除元素索引塌陷问题数组遍历删除是更经典的坑const arr [1, 2, 3, 4, 5]; for (let i 0; i arr.length; i) { if (arr[i] % 2 0) { arr.splice(i, 1); // 删除偶数 } } console.log(arr); // [1, 3, 5] 这次看着对但换个数据就完了问题在于splice删除当前元素后后面的元素会往前移动一位索引塌陷循环里下一次i会跳过紧挨着的一个元素。比如[2, 4, 6]这个数组i0遇到 2删除后数组变成[4, 6]i 变成 1i1此时arr[1]是 64 被跳过了。结果就是[2, 4, 6]只删了 2 和 64 却留下了。解决方案有几种。最简单的是倒序删除for (let i arr.length - 1; i 0; i--) { if (arr[i] % 2 0) { arr.splice(i, 1); } }倒序时删掉当前元素前面的元素索引不会变化因此不会漏项。更推荐的方式是直接用filter生成新数组const result arr.filter((item) item % 2 ! 0);filter语义清晰没有副作用性能通常也不差。唯一要注意的是它返回新数组如果你需要原地修改才考虑倒序splice。5.3 深拷贝中的递归遍历循环引用怎么处理遍历对象最容易被忽略的一个场景是深拷贝。很多同学写深拷贝时只会浅层Object.assign或展开符遇到嵌套对象就出问题const obj { a: { b: { c: 1 } } }; const clone { ...obj }; clone.a.b.c 999; // 原对象也被改了因为只复制了引用完整深拷贝要递归遍历对象的每个属性遇到值是对象的就继续遍历。一个基础版本function deepClone(source, map new Map()) { if (source null || typeof source ! object) { return source; } // 处理循环引用如果已经拷贝过直接返回之前的副本 if (map.has(source)) { return map.get(source); } const target Array.isArray(source) ? [] : {}; map.set(source, target); for (const [key, value] of Object.entries(source)) { target[key] deepClone(value, map); } return target; }这里的核心就是遍历Object.entries 递归 用 Map 记录已处理对象解决循环引用。没有递归遍历深拷贝根本无从谈起。在公司项目里遇到JSON.parse(JSON.stringify(obj))把undefined、函数、循环引用全部搞丢的问题时你就可以用这个版本替代。6. 底层规律与性能细节遍历顺序、隐藏类和实测结论6.1 对象属性遍历顺序整数键优先字符串按插入序ES2015 之后对象属性的遍历顺序有明确规范这也是很多人没注意过的“隐藏知识点”非负整数形式的 key如1、2会按升序排在最前面字符串形式的 key非整数按插入顺序排列Symbol 类型的 key 最后出现且按插入顺序排列。看个例子const obj {}; obj.b 1; obj[2] 2; obj.a 3; obj[1] 4; Object.keys(obj); // [1, 2, b, a]1和2是整数键升序排前b、a是字符串键按插入顺序排列。这个规则在实际业务中的影响是如果你遍历一个 key 为数字字符串的对象比如字典映射输出的顺序可能不是你插入的顺序。这是个很容易被忽略的细节尤其在表格列配置、表单字段排序等场景中一旦 key 是数字字符串遍历顺序就“变了样”。好消息是日常开发中对象 key 大多是普通字符串顺序就是插入顺序所以Object.entries返回的顺序通常符合直觉。但如果做通用工具函数不要假设遍历顺序等于插入顺序除非你能确定所有 key 都不是整数形式。6.2 性能实测不同遍历方式在大对象下的差距我用一个 10 万属性的对象做过一次不严谨但能说明问题的测试对比了几种遍历方式的总耗时数据因机器不同会浮动看相对关系即可遍历方式相对耗时备注for...in基准包含原型链查找最慢Object.keys()for...of约基准 60%只访问自身属性Object.keys()forEach约基准 60%多一次函数调用略有开销Object.entries()for...of约基准 80%解构出键值对有额外数组开销Object.values()for...of约基准 55%只取 value最优结论有三条只取 value 用Object.values比每次obj[key]取一次值要快因为解构已经帮你把值取好了for...in是真的慢因为它每次都要沿原型链查找即便没有继承属性也有额外开销性能差异在几百上千条数据时几乎感知不到别为了那点差异牺牲代码可读性。真正的性能大头往往不在“用哪种方式遍历”而在“循环体里做了什么”。如果你在循环体里做字符串拼接、频繁创建新对象、嵌套遍历那再快的遍历方式也救不回来。优化时先看循环体再看遍历方式。6.3 常见问题速查表遍历对象的疑难杂症问题现象根本原因解决方案遍历对象时多出了一些方法/属性for...in遍历到了原型链用Object.keys或for...in中加Object.hasOwn判断对象明明是空的if (obj)还成立空对象是 truthy用Object.keys(obj).length 0判断遍历后找不到 Symbol 类型的 keyObject.keys不返回 Symbol key用Reflect.ownKeys对象字段大小写不统一取值总为空接口数据不规范先遍历做 key 归一化再统一访问JSON.parse(JSON.stringify(obj))丢了undefined和函数序列化会忽略这些值用递归遍历实现深拷贝数组遍历删除后漏项splice导致索引塌陷倒序遍历或改用filter遍历对象时删除属性结果残留遍历顺序导致后续属性被跳过先收集 key再统一删除对象有不可枚举属性Object.keys看不到Object.keys只返回可枚举属性用Object.getOwnPropertyNames用obj[key]在循环里高频取值性能不佳动态属性访问阻止了 V8 隐藏类优化固定字段用点号访问或先Object.values一次性取出Object.entries解构出的 value 顺序和预期不同整数形式的 key 会排到最前设计数据时避免纯数字字符串 key这张表基本覆盖了我这些年踩过的坑。建议收藏起来写工具函数或者代码 review 时翻出来对照一下能省不少排查时间。最后分享两个个人习惯写工具函数时我默认用Object.entries而不是for...in因为语义最清晰、边界最少判断对象自有属性是否为空就用Object.keys(obj).length 0一行搞定不整花活。遍历对象这件事核心就一句话——先想清楚要 key、要 value 还是都要再选对应的 API最后注意循环体里别做破坏对象结构的事。如果你能把这篇文章里的坑都避开以后面试聊遍历相关的问题也能比大多数候选人答得更深一层。
返回列表