刚学 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()系列三兄弟不碰原型、不碰不可枚举、不碰 Symbol;Object.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做遍历,体验非常接近 Map:
for (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 的核心差异:enumerable
Object.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()最稳妥,完全不需要担心原型链。
我在实际开发里吃过不少次遍历的亏,总结下来就一句话:别靠感觉选遍历方法,先打印一次,再对照需求匹配。多一些打印,少一点踩坑。下次遇到对象遍历,直接把这段示例代码丢进控制台跑一遍,规则就明明白白贴在眼前了。