JavaScript对象遍历全解析:for...in、Object.keys与Reflect.ownKeys的选型与避坑
把对象从头到尾扫一遍可能是前端需求里出现频率最高的基础操作之一。前阵子我重构一个权限配置模块要遍历一个三层嵌套的菜单配置对象顺手写了 for...in结果遍历结果里混进来几个莫名其妙的字段排查了半天才发现是原型链上的历史遗留方法。从那以后我专门把 JS遍历对象 这件事认真捋了一遍发现大多数人包括以前的我对遍历的理解都停留在“能跑就行”对 for...in、Object.keys、Object.entries、Reflect.ownKeys 到底各自看到什么属性按什么顺序输出嵌套对象怎么安全地深度遍历其实并不清楚。下面一次性讲透给出能直接落地的代码和选型建议。刚接触前端的新手可以拿它补全基础写了几年业务代码的老手也可以对照检查自己有没有踩过类似的坑。1. 遍历对象的五种主流姿势从for...in到Reflect.ownKeys1.1 for...in最老牌的遍历方式也是坑最多的一种for...in 大概是很多人学会的第一种对象遍历方式。它的语法很直白const user { nickname: demo-user, age: 28, role: admin }; for (const key in user) { console.log(key, user[key]); } // nickname demo-user // age 28 // role adminfor...in 会把对象里所有可枚举的、以字符串为键的属性名依次取出来中间也包含从原型链上继承下来的可枚举属性。这一点新手很容易忽略也是它后来被很多团队写进代码规范“慎用”的根本原因。比如项目里如果有人给 Object.prototype 挂过一个工具方法或者用第三方库往原型上打过补丁for...in 的结果里就会莫名多出这些字段Object.prototype.debugTag 我不该出现; for (const key in user) { console.log(key); } // nickname // age // role // debugTag所以现在如果你在代码里看到 for...in几乎一定会配套一个 hasOwnProperty 检查。这不是多此一举而是为了把遍历范围收紧到对象自身的属性上。老实说我现在项目里基本不用 for...in 遍历普通对象它更适合的场景是遍历那些“需要连原型一起看”的调试代码业务代码里用它风险大于收益。1.2 Object.keys与Object.values最常用的组合拳Object.keys 不玩虚的直接返回一个数组里面是对象自身的、可枚举的、字符串类型的键名const keys Object.keys(user); // [nickname, age, role] const values Object.values(user); // [demo-user, 28, admin]这两个方法的特性是“干净”不涉及原型链不会冒出继承属性也不包含不可枚举属性和 Symbol 键。只要你确认自己只关心对象自己身上的可枚举字段用它们就是最稳的选择。一个容易忽略的点Object.values 的值顺序和 Object.keys 的键顺序完全一致都是按照对象属性枚举顺序来的。如果你需要同时拿键和值又不想用下面要讲的 Object.entries那就分别调一次 keys 和 values然后按下标对齐。不过既然有 Object.entries我一般不会这么干除非赋值逻辑里确实只需要一半数据。1.3 Object.entries for...of键值对批量处理的舒适区Object.entries 返回的是 [key, value] 组成的二维数组配合 for...of 解构是批量处理键值对最舒适的姿势for (const [key, value] of Object.entries(user)) { console.log(${key}: ${value}); } // nickname: demo-user // age: 28 // role: admin这段代码的意图很明确每个属性都要同时用到键名和值。跟 for...in 比起来它不需要 hasOwnProperty 检查因为 Object.entries 本身就只包含自有可枚举属性跟 Object.keys 加下标取值相比它少了一次从对象里重新读取值的操作语义也更完整。另一个实用技巧把 Object.entries 的结果直接丢进 Map 构造函数对象秒变 Mapconst map new Map(Object.entries(user)); map.get(role); // admin在需要频繁判断属性是否存在、又不想被原型链干扰的场景里这种转换比原对象更好用。1.4 Reflect.ownKeys一键拿全量属性名单前三种方式默认都忽略两类东西不可枚举属性和 Symbol 键。如果有一天你需要拿到对象含 Symbol 在内的全部自有键可以选择 Reflect.ownKeysconst symbolKey Symbol(id); const obj { name: x, [symbolKey]: 1 }; Object.defineProperty(obj, hidden, { value: 2, enumerable: false }); Object.keys(obj); // [name] Object.getOwnPropertyNames(obj); // [name, hidden] Object.getOwnPropertySymbols(obj); // [Symbol(id)] Reflect.ownKeys(obj); // [name, hidden, Symbol(id)]Reflect.ownKeys 相当于 Object.getOwnPropertyNames 和 Object.getOwnPropertySymbols 两个方法结果的合集一次调用就能拿到所有自有键。不过正因为“全”它通常不是默认选项而是在写底层工具比如深拷贝、数据校验器、对象差异对比时才会用到。日常业务遍历前面三个方法就够了。2. 可枚举性、原型链与Symbol键决定“能看到什么”的隐藏规则前面代码跑通了但光会用还不够。真正决定 for...in 和 Object.keys 看到什么内容的是三条底层规则。不了解它们遇到诡异结果就只能靠瞎猜。2.1 可枚举性属性描述符里的enumerable开关每个对象的属性背后都有一份属性描述符Property Descriptor里面有个 enumerable 开关。用对象字面量或普通赋值创建的属性enumerable 默认为 true用 Object.defineProperty 定义的属性enumerable 默认是 falseconst obj {}; Object.defineProperty(obj, hidden, { value: 42 }); Object.keys(obj); // []这里的 hidden 属性真实存在你直接 obj.hidden 也能读到 42但 Object.keys、Object.values、Object.entries、for...in 全部看不见它。这就是很多“属性明明在遍历却找不到”问题的根源。想把它暴露出来要么用 Object.getOwnPropertyNames要么在 defineProperty 时显式声明 enumerable: true。这个设计其实挺合理像数组的 length、函数的 name 这类“元属性”并不希望被一个无脑 for...in 扫出来。2.2 原型链for...in 为什么会“越权”for...in 的特殊之处在于它会沿着原型链向上查找可枚举属性。而 Object.keys、Object.entries 这些 API 只认“自有属性”。理解了这个差异你就能解释很多让人抓狂的现象。设想一个场景某后端同学在 Object.prototype 上挂了一个 formatUser 方法不要问为什么问就是历史遗留于是整个项目里所有对象的 for...in 遍历结果都会多出 formatUser。业务代码要避免这种问题习惯上会加一层自有关卡for (const key in obj) { if (Object.hasOwn(obj, key)) { // 这里才是可靠的自有属性处理逻辑 } }Object.hasOwn 是 ES2022 提供的原生方法比过去 Object.prototype.hasOwnProperty.call(obj, key) 的写法简洁得多。如果你的运行环境不支持就用后者效果一样。2.3 Symbol键与不可枚举键官方三兄弟的盲区Object.keys、Object.values、Object.entries 三个方法都只看“自有 可枚举 字符串键”对 Symbol 键完全无视对不可枚举字符串键也视而不见。所以当你处理的不是普通业务对象而是包含 Symbol 标识符或隐藏元数据的对象时这三兄弟就不够用了。实际项目里遇到 Symbol 键的情况其实不少比如全局状态管理里用 Symbol 做私有状态标识比如迭代器协议要求的 Symbol.iterator又比如某些框架在对象上打的各种符号标记。你遍历一个对象想把它完整复制出来结果 Symbol 键全部丢失深拷贝出来的对象行为就对不上。遍历方式自有属性继承属性不可枚举字符串键Symbol键for...in有有无无Object.keys有无无无Object.values有无无无Object.entries有无无无Object.getOwnPropertyNames有无有无Object.getOwnPropertySymbols有无不适用有Reflect.ownKeys有无有有小结一下for...in 管得宽连原型链都管Object 三兄弟管得严只看自有可枚举字符串键Reflect.ownKeys 管得全一切自有键。选哪个取决于你要的是“宽、严、还是全”。3. 属性遍历顺序整数键、字符串键与Symbol键的排序真相如果问你 Object.keys 返回的数组顺序是什么很多人会脱口而出“按插入顺序”。这句话对了一半但有个非常重要的例外项目里真正被坑过的同志应该都懂。3.1 整数键永远排在前面连字面量书写顺序都得让路JavaScript 对象在枚举属性时会先把那些“长得像数组索引”的键挑出来按数值大小升序排列然后再处理其他字符串键。这个“长得像数组索引”指的是规范的规范字符串数字形式0、1、10 这种01、1.5、-1 都不算。const obj { b: 1, 2: 2, a: 3, 1: 4 }; console.log(Object.keys(obj)); // [1, 2, b, a]眼尖的读者已经发现了明明先写的是 b再写 2再写 a最后写 1结果输出时1和2被拎到了最前面还按数字排了序b 和 a 才回到插入顺序。这就是很多人以为的“插入顺序”在整数键上失效了。3.2 字符串键按插入顺序Symbol键最后登场排除掉整数键之后剩余的普通字符串键严格按照属性创建顺序排列如果有 Symbol 键它们排在所有字符串键之后同样按创建顺序排列。这个顺序不仅存在于 Object.keys 里也存在于 Object.values、Object.entries、for...in、JSON.stringify 的序列化结果中。3.3 顺序规则的实际影响序列化结果也可能变化这条规则最大的实际影响出现在前后端数据交互和文件输出里。我之前在生成一份配置文件的 JSON 时配置文件里有 version 字符串和一堆数字编号的节点 key结果输出的 JSON 里数字 key 全被排到最前面跟源文件的阅读顺序完全不一样。虽然 JSON 规范本身不保证键顺序但读配置的人会懵。所以我的经验是如果某个对象的键顺序对下游有实际意义比如生成可读配置文件、做 diff、拼签名字符串尽量避免使用数字形态的字符串键或者在输出前用 Map 或数组形式的键值对列表来保持顺序。4. 嵌套对象的深度遍历递归、环检测与安全边界前面聊的都是单层对象实际业务里更多是“菜单树、权限配置、表单结构”这种多层嵌套对象。名字、用法、API 讲完了现在说最常用的深度遍历实现。4.1 一个能直接用的最小递归遍历函数深度遍历最直观的思路是递归遍历当前层的每个键遇到值还是对象就继续往下走。下面这个函数可以收集到每个叶子节点的完整路径和值function deepTraverse(node, callback, path []) { if (node null || typeof node ! object) { return; } for (const key of Object.keys(node)) { const value node[key]; const currentPath [...path, key]; callback(key, value, currentPath); if (value ! null typeof value object) { deepTraverse(value, callback, currentPath); } } }用起来长这样const menuConfig { title: 首页, children: [ { title: 仪表盘, disabled: true }, { title: 用户管理, children: [ { title: 用户列表 }, { title: 角色配置 } ] } ] }; deepTraverse(menuConfig, (key, value, path) { if (path[path.length - 1] title) { console.log(path.join(.), , value); } });这里故意用 Object.keys 而不是 for...in就是为了绕开原型链干扰。如果你在数组上遍历Object.keys 返回的是字符串形式的索引0、1callback 拿到的 value 就是对应元素逻辑同样成立。4.2 环形引用用WeakSet堵住递归的无底洞上面的函数有个致命问题遇到环形引用会无限递归直接爆栈。如果你的数据来自后端接口理论上不会出现环但如果数据来自用户配置、拖拽画布、或者经历了合并操作环是真实存在的。解决办法是在递归时维护一个“已经访问过”的集合function safeDeepTraverse(root, callback) { const visited new WeakSet(); function walk(node, path []) { if (node null || typeof node ! object) { return; } if (visited.has(node)) { console.warn(检测到环形引用路径终止于, path.join(.)); return; } visited.add(node); for (const key of Object.keys(node)) { const value node[key]; const currentPath [...path, key]; callback(key, value, currentPath); walk(value, currentPath); } } walk(root); }这里用 WeakSet 而不是 Set原因有两点一是 WeakSet 持有的是弱引用遍历结束后不会妨碍对象被垃圾回收二是即便留在遍历函数内部也不会造成内存泄漏。环形结构在配置类数据里虽然少见但加上这个保护的成本极低我通常写深度遍历都会顺手带上。4.3 深度遍历时的数据类型判断和分支处理深度遍历还有一个容易翻车的点你以为的“对象”可能根本不是普通对象。Date、RegExp、Map、Set 都是对象typeof 的结果都是 object可它们的内部结构不适合用 Object.keys 去拆。比如一个 Date 实例Object.keys 返回空数组但你真正想要的可能是把它当成一个不可再拆的叶子节点保留下来。一个 Map 的数据保存在内部插槽里Object.keys 根本拿不到。在实际项目里我会在递归函数开头加上类型判断分支function safeDeepTraverse(root, callback) { const visited new WeakSet(); function walk(node, path []) { if (node null || typeof node ! object) { return; } if (node instanceof Date || node instanceof RegExp || node instanceof Map || node instanceof Set) { callback(__value__, node, path); return; } if (visited.has(node)) return; visited.add(node); for (const key of Object.keys(node)) { const value node[key]; const currentPath [...path, key]; callback(key, value, currentPath); walk(value, currentPath); } } walk(root); }把特殊类型当成叶子节点处理遍历才不会钻进一堆内部属性里出不来。如果你的遍历目标是做深拷贝或序列化这里其实已经带出了很清晰的思路遇到 Date 复制时间戳遇到 RegExp 复制源码和 flags遇到 Map 和 Set 先转数组再递归。5. 性能差异与实战选型别在不需要优化的地方过度优化遍历对象的性能问题很多人要么完全不关心要么走火入魔。我的看法是方向比微优化重要。5.1 各遍历方式的大致性能画像从引擎实现的角度讲Object.keys、Object.values、Object.entries 这组 API 只处理自有可枚举属性内部可以直接走属性列表通常比 for...in 要快。for...in 得一边枚举本地属性一边沿原型链查询引擎还很难对它做激进的优化。但这不意味着 for...in 就该被钉在耻辱柱上。真正影响性能的是目标对象的大小、遍历频率、以及回调里做的事情。遍历一个几十个字段的配置对象用哪种方式差异都在微秒级别根本感知不到。只有在首屏热路径、大数据列表处理、每秒触发很多次的函数里才值得把遍历方式当作优化项。Object.entries 唯一明显的额外开销是它要生成一个二维数组每个键值对都变成一个数组。数据量大时多一次分配和 GC 压力。所以如果你只需要键就用 Object.keys只需要值就用 Object.values键值都要才考虑 Object.entries。5.2 按场景选型一张表说清楚我的习惯差不多可以总结成一张选型表需求推荐方案理由只要键名列表Object.keys干净、只含自有可枚举字符串键只要值列表Object.values同样干净且顺序与 keys 一致键值都要并逐个处理Object.entries for...of语义完整少一次对象取值需要连原型链一起调试for...in仅限调试场景注意加过滤需要包含不可枚举自有键Object.getOwnPropertyNames绕过 enumerable 限制需要包含 Symbol 键Reflect.ownKeys自有键全量名单多层嵌套配置对象自写深度遍历加环检测特殊类型当叶子老实说日常业务代码里 90% 的遍历用 Object.entries 就行剩下 10% 用 Object.keys 或 Object.values 能解决。真正需要 Reflect.ownKeys 的场景很少但你需要知道它存在等遇到“属性就是遍历不到”的怪问题时它就是那个兜底方案。6. 实战踩坑清单那些让人排查到深夜的边界问题最后分享几个我在实际项目里踩过、或者帮别人排查过的坑。每一个都真实发生过没有一个是理论问题。6.1 JSON.stringify的静默丢失看起来遍历成功结果缺胳膊少腿很多人会把 JSON.stringify 当作“遍历并序列化对象”的偷懒工具但它会静默丢弃三种东西值为 undefined 的属性、值为函数的属性、以及所有 Symbol 键。数组里如果有 undefined 还会变成 null行为跟对象还不一样。const obj { name: demo, fn: () {}, undef: undefined, [Symbol(s)]: 1 }; JSON.stringify(obj); // {name:demo}如果你是先 JSON.stringify 再 JSON.parse 做深拷贝然后拿拷贝结果去遍历丢失的字段不会报错只会让你在业务逻辑里读到 undefined。这种“静默”问题比直接报错更难排查。我的建议是能明确知道对象结构的场景别依赖 JSON 大法要做完整深拷贝用结构化克隆或者带 Symbol 处理的递归方案。6.2 hasOwnProperty被覆盖经典老坑值得再讲一遍如果对象自身刚好有一个名叫 hasOwnProperty 的字段直接调用 obj.hasOwnProperty(key) 会直接报错或者返回错值const obj { hasOwnProperty: 不是函数, name: demo }; // obj.hasOwnProperty(name); // TypeError: obj.hasOwnProperty is not a function Object.prototype.hasOwnProperty.call(obj, name); // true Object.hasOwn(obj, name); // true这个坑在遍历场景里特别容易出现因为你的遍历对象可能是从后端接口拿来的字段名完全不受控。所以不管是用 for...in 加过滤还是单独判断属性存在性我都建议直接用 Object.hasOwn省心。6.3 遍历期间增删属性行为不可预期的雷区JavaScript 规范对遍历行为有规定如果被遍历对象在过程中新增了还没被访问到的属性引擎可以自由决定要不要把这个新属性纳入遍历如果删除了还没访问到的属性那这个属性大概率不会被遍历到。也就是说一边遍历一边改对象结果取决于引擎心情。实际操作中我遇到过在 for...of 遍历 Object.entries 时往原对象里塞字段导致 entries 和原对象状态对不上的情况。正确的姿势是先收集再修改把要处理的键值对先转成数组遍历这个数组修改动作放到遍历结束之后统一做。6.4 Getter被触发遍历不等于安全读取每次用 obj[key] 读取属性值如果这个属性是访问器属性会触发 getter。getter 里如果抛了异常遍历会直接中断如果 getter 有副作用遍历一次就等于执行了一次副作用。这不一定是 bug但如果你只想“看看对象里有什么”这种行为可能出乎意料。需要规避时可以用 Object.getOwnPropertyDescriptor 先检查属性是数据属性还是访问器属性再决定要不要直接取值。在深度遍历内部做这个判断能让工具函数更健壮。6.5 空对象与Object.create(null)特殊对象的特殊处理最后一个边角情况用 Object.create(null) 创建的对象没有原型Object.keys 遍历起来没问题但 for...in 在里面遍历不到任何原型属性这算是优点。不过这种对象没有继承 Object.prototype 的方法直接 obj.hasOwnProperty 会报不存在。同样的药方还是那句用 Object.hasOwn。另外空对象本身也值得留神。Object.keys({}) 返回空数组是常识但如果你在遍历时用“判断 keys 长度”来决定对象是否为空注意它只能判断自有可枚举字符串键——一个只有 Symbol 键的对象Object.keys 长度是 0但它并不是空对象。真要判断是否为空配合 Reflect.ownKeys 才靠得住。这些坑单独看都不难难的是它们在真实代码里同时出现时一起把人绕晕。我自己的习惯是写遍历前先问自己三个问题——我要遍历的是单层还是嵌套要不要包含继承属性和 Symbol 键会不会改到原对象三个问题想清楚选型基本不会错。如果你在项目里也遇到过类似的遍历怪事欢迎对照上面的清单找找原因很多时候答案就藏在这些不起眼的边界规则里。