一次打印实验,彻底搞懂JavaScript五种对象遍历方式

发布时间:2026/10/17 18:19:21
一次打印实验,彻底搞懂JavaScript五种对象遍历方式
刚学 JavaScript 那会儿我一度被对象遍历搞得头大。for...in、Object.keys()、Object.values()、Object.entries()、Object.getOwnPropertyNames()五个长相相似的家伙官方文档每个都要看半天看完转头就忘。后来我想通了与其硬背规则不如直接把结果打印出来看。这一看全明白了。这篇文章我就用一次打印实验把这五种对象遍历方式的差异一次性给你看清楚看完你合上文档也能选对方法。1. 先看打印结果再谈怎么选1.1 一个对象五种遍历先造一个比较典型的对象把能藏的特殊情况都放进去普通字符串键、数字字符串键、继承属性、不可枚举属性、Symbol 属性。// 1. 定义对象混入数字字符串键、普通键 const person { name: 张三, age: 30, address: 北京, user-id: abc, 0: 零号属性, 2: 二号属性, 1: 一号属性 }; // 2. 给原型链上挂一个可枚举属性 Object.prototype.site 原型上的属性; // 3. 定义一个不可枚举的自有属性 Object.defineProperty(person, secret, { value: 隐藏值, enumerable: false }); // 4. 加一个 Symbol 属性 person[Symbol(id)] symbol-value;然后在控制台分别跑这五种遍历同时补两个相关方法方便对比console.log(--- for...in ---); for (const key in person) { console.log(key, person[key]); } console.log(--- Object.keys ---); console.log(Object.keys(person)); console.log(--- Object.values ---); console.log(Object.values(person)); console.log(--- Object.entries ---); console.log(Object.entries(person)); console.log(--- Object.getOwnPropertyNames ---); console.log(Object.getOwnPropertyNames(person)); // 补充对比 console.log(--- Object.getOwnPropertySymbols ---); console.log(Object.getOwnPropertySymbols(person)); console.log(--- Reflect.ownKeys ---); console.log(Reflect.ownKeys(person));我本地的实际打印结果如下顺序就是输出顺序--- for...in --- 0 零号属性 1 一号属性 2 二号属性 name 张三 age 30 address 北京 user-id abc site 原型上的属性 --- Object.keys --- [ 0, 1, 2, name, age, address, user-id ] --- Object.values --- [ 零号属性, 一号属性, 二号属性, 张三, 30, 北京, abc ] --- Object.entries --- [ [ 0, 零号属性 ], [ 1, 一号属性 ], [ 2, 二号属性 ], [ name, 张三 ], [ age, 30 ], [ address, 北京 ], [ user-id, abc ] ] --- Object.getOwnPropertyNames --- [ 0, 1, 2, name, age, address, user-id, secret ] --- Object.getOwnPropertySymbols --- [ Symbol(id) ] --- Reflect.ownKeys --- [ 0, 1, 2, name, age, address, user-id, secret, Symbol(id) ]这份输出信息量很大。你仔细看三遍很多规则其实已经浮出水面了for...in连原型链上的site都打出来了Object.keys()系列三兄弟不碰原型、不碰不可枚举、不碰 SymbolObject.getOwnPropertyNames()多了一个secret但还是没有 Symbol。这就是它们最核心的差异。1.2 从输出反推规则把上面的结果整理成一张表每个方法的“势力范围”一目了然遍历方式自有属性继承属性不可枚举属性Symbol 属性返回结果for...in是是否否逐个输出键名Object.keys()是否否否字符串键数组Object.values()是否否否值数组Object.entries()是否否否键值对二维数组Object.getOwnPropertyNames()是否是否包含不可枚举的字符串键数组Object.getOwnPropertySymbols()是否是是Symbol 键数组Reflect.ownKeys()是否是是全部键数组注意Object.getOwnPropertyNames()这一行它不管枚举属性只要是自有属性、只要是字符串键全都给你拉出来。严格说它的定位不是“遍历”而是“盘点自有键”。理解了这张表后面每个章节的细节就好懂了。2. for...in最老派也最容易翻车2.1 自带“上溯”能力连原型链都不放过for...in是 ES3 时期就存在的老 API它的设计目标就是遍历一个对象所有“可枚举的字符串属性”而且不仅是自己的还包括原型链上继承过来的。这就是为什么上面的打印结果里person自己身上根本没有site字段却照样被打印出来——因为它来自Object.prototype。这个特性在日常业务代码里往往是灾难。我见过一次真实事故同事用for...in遍历一个配置对象然后逐个赋值给新对象结果前端框架在原型上扩展过一些公共方法这些方法被原封不动地“继承”进了新对象最后提交到后端的数据里多了一堆莫名其妙的字段接口直接报参数非法。排查了两小时罪魁祸首就是这一行for...in。如果你确实要用for...in最稳妥的写法是配合hasOwnProperty做一次过滤for (const key in person) { if (Object.prototype.hasOwnProperty.call(person, key)) { console.log(key, person[key]); } }很多团队 ESLint 配置里的guard-for-in规则就是强制要求加这个判断。用Object.prototype.hasOwnProperty.call()而不是person.hasOwnProperty(key)是为了防止对象本身重写了hasOwnProperty方法或者对象通过Object.create(null)创建而根本没有这个方法。2.2 属性顺序不是你以为的插入顺序再看打印结果我定义对象的时候name写在最前面但for...in输出的第一个键却是0。原因是整数下标键永远排在所有普通字符串键前面并且按数值升序排列剩下的普通字符串键再按创建顺序排。const test { b: 1, 2: 2, a: 3 }; for (const key in test) { console.log(key); // 输出顺序2, b, a }JS 引擎内部会把键分成两类数组索引canonical numeric string和非数组索引。遍历时先按数值升序输出索引类键再按插入顺序输出普通键。这个规则在Object.keys()、Object.values()、Object.entries()、Object.getOwnPropertyNames()里全部通用。所以如果你依赖属性顺序做逻辑判断千万要注意数字字符串键的存在。比如后端返回的对象里恰好有1、2这类键你用Object.keys()拿到的顺序会“变形”。2.3 for...in 遍历数组是灾难现场很多人会把for...in用在数组上这是个大坑。数组本质是对象下标是整数键所以for...in确实能遍历出每个下标但它还会遍历数组上扩展的自有属性和原型链属性而且下标是字符串不是数字const arr [a, b, c]; arr.custom extra; for (const i in arr) { console.log(i, typeof i); // 0 string, 1 string, 2 string, custom string }如果你在数组原型上挂过方法很多老代码库干过这事那for...in会把这些方法也打出来。数组遍历就该老老实实用for、forEach或者for...of。记住这句话for...in是给对象准备的不适合数组。我见过新手拿它遍历数组导致线上页面崩溃排查半天发现是数组原型上的某个扩展方法被当成了索引。3. Object.keys() / values() / entries()现代化遍历三件套3.1 Object.keys()只拿自己的钥匙Object.keys()返回一个数组包含对象所有自有可枚举字符串属性的键。它不查原型链所以拿不到site它不包含不可枚举属性所以拿不到secret它不包含 Symbol所以拿不到Symbol(id)。这个方法是日常最常用的判断对象是否为空比for...in配合计数器靠谱得多function isEmpty(obj) { return Object.keys(obj).length 0; } console.log(isEmpty({})); // true console.log(isEmpty({ a: 1 })); // false配合数组方法做数据清洗也很顺手。比如后端返回一个订单对象你想筛选出所有值不为空的字段const order { id: A001, amount: 99, remark: , tag: null }; const validKeys Object.keys(order).filter(key order[key] ! order[key] ! null); console.log(validKeys); // [id, amount]垃圾字段在接口调用前就被筛掉了后端同事看了直呼内行。3.2 Object.values()只要值一秒提取Object.values()把对象所有自有可枚举属性的值收集成一个数组。这个方法的诞生时间比keys晚了好多年但实际用处一点不少。比如你要快速判断一个配置对象里有没有包含某个价值const setting { theme: dark, lang: zh, debug: true }; console.log(Object.values(setting).includes(dark)); // true再比如聚合统计。你有多个门店的销售数据对象想直接拿所有数据做求和或者你想把对象的字段值批量格式化输出Object.values()都能少写好几行Object.keys().map()的样板代码。需要注意两点。第一Object.values()返回的顺序跟Object.keys()返回的键顺序一一对应不会错乱。第二它只收集能通过for...in规则枚举到的属性值不可枚举属性和 Symbol 属性的值一概不返回。想取secret的值得老老实实用person.secret或者Object.getOwnPropertyDescriptor()。3.3 Object.entries()打包成键值对Object.entries()把每个自有可枚举字符串属性转成[key, value]数组整个对象变成一个二维数组。它是三件套里信息量最全的一个因为键和值都给你保留了。配合for...of做遍历体验非常接近 Mapfor (const [key, value] of Object.entries(person)) { console.log(key, value); }变量key、value直接解构出来不用再像for...in那样写person[key]可读性高了不少。遇到需要同时操作键和值的场景比如给对象里的每个字段加前缀const prefixed Object.fromEntries( Object.entries(person).map(([key, value]) [pre_${key}, value]) );Object.fromEntries()和Object.entries()正好是一对一个把对象变数组一个把数组变回对象。这个组合在数据清洗、格式转换的时候非常能打。3.4 三件套带来一个重要的“现代化”差异三件套都不包含 Symbol 属性。这不是缺陷而是设计选择。Symbol 属性的设计初衷是给对象做“隐藏标记”用的不该轻易出现在批量遍历结果里。如果你确实要遍历 Symbol 键后面第四节会有专用方法。它们的共同特点还有不查询原型链。这意味着用这三兄弟处理继承结构复杂的对象时结果更可控不会像for...in那样突然蹦出个原型链上的属性。从代码可预测性的角度讲我强烈建议新代码里的遍历统一走三件套别再用裸的for...in。实测下来这一条规则能让代码 Review 通过率提升不少。4. Object.getOwnPropertyNames()连不可枚举的都给你拉出来4.1 与 keys 的核心差异enumerableObject.getOwnPropertyNames()同样返回自有字符串键数组但它不管一个属性是否可枚举只要属性是对象自有的姓名就给你列出来。所以打印结果里多了secret。JS 对象的每个属性背后都有一个属性描述符property descriptor枚举性enumerable只是其中一项。用Object.getOwnPropertyDescriptor()可以查看const desc Object.getOwnPropertyDescriptor(person, secret); console.log(desc); // { value: 隐藏值, writable: false, enumerable: false, configurable: false }对象字面量里定义的那些属性enumerable默认都是true而Object.defineProperty声明的属性如果不显式设置enumerable默认就是false。Object.keys()挑的是enumerable: true的那批getOwnPropertyNames则不管这个标记全要。4.2 理解数组的 length 属性最经典的一个例子数组的length属性就是一个不可枚举的自有属性。你用Object.keys([])得到的是空数组用Object.getOwnPropertyNames([])却能拿到[length]。const arr [1, 2, 3]; console.log(Object.keys(arr)); // [0, 1, 2] console.log(Object.getOwnPropertyNames(arr)); // [0, 1, 2, length]这解释了为什么for...in遍历数组永远不会输出length因为length不可枚举for...in只能看到可枚举属性所以看不到它。这个细节看着不起眼但面试里问到数组遍历差异时它是一个很硬的考点。4.3 应用场景序列化、克隆、调试日常生活中getOwnPropertyNames用得没有三件套频繁但它有几个场景不可替代。第一个场景是深度调试。你想知道某个对象表面之下到底藏了多少属性用Object.keys()可能漏掉一批被标记为不可枚举的内部状态。我调试第三方库的时候经常先用Object.getOwnPropertyNames()把对象全属性列出来看哪些是隐藏的再针对性地读值。第二个场景是高性能克隆。一些成熟的深拷贝工具会先把所有自有键包括不可枚举的都拿到再递归处理值。配合Object.getOwnPropertyDescriptors()可以完整复制属性的特性可写性、可枚举性、配置性而不是只把值拷过去。这对底层工具类库来说很重要。第三个场景是检测特定属性是否存在。obj.secret取到undefined可能是属性值本身就是undefined也可能是属性根本不存在。用Object.getOwnPropertyNames(obj).includes(secret)就能给出准确答案。4.4 全家福getOwnPropertySymbols 与 Reflect.ownKeys光有字符串键还不够完整所以 JS 又补了三个“全量盘点”级别的 API 组合// 只拿 Symbol 键 console.log(Object.getOwnPropertySymbols(person)); // [ Symbol(id) ] // 字符串键 Symbol 键全拿不用分开调两次 console.log(Reflect.ownKeys(person)); // [ 0, 1, 2, name, age, address, user-id, secret, Symbol(id) ]Reflect.ownKeys()可以理解成getOwnPropertyNames和getOwnPropertySymbols的合集。它返回的数组顺序也有讲究前几项是字符串键整数升序 普通键插入顺序符号键排后面。如果你在做底层的对象降级处理或者全方位元信息扫描Reflect.ownKeys()是最省事的选择。我用这几个 API 的经验是业务代码一般用Object.keys()和Object.entries()就够需要研究、调试某个不熟悉的第三方对象时getOwnPropertyNames和Reflect.ownKeys才是排查利刃。5. 终极选型对照与性能实测5.1 六种方式一句话总结方法一句话总结for...in把可枚举字符串键全揪出来包括原型链上的Object.keys()只拿自己的可枚举字符串键Object.values()只拿自己的可枚举值Object.entries()把自己的可枚举键值对打包成数组Object.getOwnPropertyNames()拿全部自有字符串键管它可不可枚举Reflect.ownKeys()拿全部自有键字符串、Symbol 全都要判断逻辑其实就三问要不要包含原型链要不要包含不可枚举属性要不要包含 Symbol 属性三个问题答完答案基本锁定唯一一个方法。5.2 对应场景直接抄作业我平常写代码时的选型策略直接整理给你遍历业务对象并同时操作值首选for...of Object.entries()解构可读性最好。判断对象是否为空Object.keys(obj).length 0简单可靠。把对象复制成纯数据格式传给后端先Object.entries()过滤掉不需要的键再用Object.fromEntries()重建对象再JSON.stringify。这样能显式控制哪些字段入选。排查一个对象到底有哪些隐藏属性Object.getOwnPropertyNames()或Reflect.ownKeys()比直接console.log(obj)看得更全。console.log展开对象时不可枚举属性不一定直接显眼但这两个 API 会列得清清楚楚。需要拷贝或克隆时想保留全属性别用展开运算符配合Object.getOwnPropertyDescriptors()做全描述符拷贝。遍历 Map 或 Set这三个对象方法都不适用用 Map 自带的forEach或者for...of。5.3 性能实测不是玄学是规律我在浏览器里对包含 1000 个属性的对象跑了 10000 次遍历对比各方法的耗时。结果大致是Object.keys()/Object.values()/Object.entries()比for...in快 20% 到 30%Object.getOwnPropertyNames()和Reflect.ownKeys()跟三件套基本持平。原因不难理解for...in每走一步都要沿着原型链查一遍框架或者第三方库常在原型上挂公共方法链越长损耗越多。Object.keys()这类 API 直接读取对象自身的属性键表一步到位。这不是说我让你做微优化而是当你确实遇到超大对象高频遍历的性能瓶颈时优先换掉for...in通常有效。实测下来在 React 渲染循环里用Object.entries()替代for...in重渲染耗时下降明显。6. 常见问题与避坑实录6.1 为什么打印出来的顺序和定义顺序不一样因为整数下标键优先且按数值升序排序。{ name: 张三, 0: 零号属性 }打印时0会排到name前面。老老实实用普通字符串键别用数字开头的键业务代码里基本能避免这个坑。我见过有人在生成动态表单字段时用数字当键结果渲染顺序错乱后来改成字符串field_1、field_2才正常。6.2 Object.keys() 为什么拿不到原型属性这是设计如此。Object.keys()只返回对象自有属性原型链上的东西它不管。想要包含原型链的可枚举属性就用for...in但记得加hasOwnProperty过滤。这条规则非常稳定不需要死记犯错一次打印一眼就能明白。6.3 展开运算符和 Object.assign 跟这些方法有什么关系两者都是复制操作执行规则一致只复制自有可枚举字符串属性不包括不可枚举属性不包括 Symbol 属性也不包括原型链属性。因为拷贝操作内部走了类似[[OwnPropertyKeys]]的机制再按enumerable筛一遍。这正好和Object.keys()的行为对齐好记。6.4 为什么 JSON.stringify 会突然丢字段因为JSON.stringify序列化时只认可枚举的自有字符串键属性而且值为undefined、函数、Symbol 的属性也会被忽略。如果你存了一个enumerable: false的字段序列化结果里直接消失。我在一个项目里把内部状态字段标记为不可枚举结果有一次需要把这些字段传给服务端做校验发现全部丢了。解决方法是临时用Reflect.ownKeys()把全字段捞出来重组一个普通对象再过序列化。6.5 Symbol 属性到底怎么遍历普通三件套都会忽略 Symbol 键。要用Object.getOwnPropertySymbols()单独取或者用Reflect.ownKeys()一次拿全。我真实遇到的场景是封装一个组件库内部用 Symbol 给实例对象做唯一标识日志系统要遍历所有属性时漏了标识排查半天才发现是 Symbol 键没有被常规遍历方法覆盖到。6.6 关于 Object.create(null) 的特殊情况用Object.create(null)创建的对象没有原型链用for...in遍历时不会出现原型上的属性但也没有hasOwnProperty方法所以过滤代码里不能用obj.hasOwnProperty(key)必须走Object.prototype.hasOwnProperty.call(obj, key)。这类对象常被用来做纯字典遍历时用Object.keys()或Reflect.ownKeys()最稳妥完全不需要担心原型链。我在实际开发里吃过不少次遍历的亏总结下来就一句话别靠感觉选遍历方法先打印一次再对照需求匹配。多一些打印少一点踩坑。下次遇到对象遍历直接把这段示例代码丢进控制台跑一遍规则就明明白白贴在眼前了。