数组遍历有 forEach、for...of 一整套,对象就尴尬了——for...of 直接用会报错:
const obj = { a: 1, b: 2 }
for (const v of obj) {} // TypeError: obj is not iterable
对象不是可迭代对象,想遍历它得挑对方法。而坑在于:对象的键分可枚举 / 不可枚举、自有 / 继承、字符串 / Symbol,不同方法覆盖的范围完全不同。这篇先把这些维度理清,再逐个方法对比,最后给选型建议。
一、先搞清三个维度
遍历对象时,属性可以按三个维度分类:
- 自有属性(own) vs 继承属性(inherited):原型链上的算不算?
- 可枚举(enumerable) vs 不可枚举:
Object.defineProperty设为不可枚举的属性要不要? - 字符串键 vs Symbol 键:
Symbol类型的键要不要?
一个属性是不是"可枚举",决定了 for...in、Object.keys 这些方法是否能看到它。
const obj = { name: 'Tom' }
Object.defineProperty(obj, 'age', {
value: 18,
enumerable: false, // 不可枚举
})
obj[Symbol('id')] = 1 // Symbol 键
Object.keys(obj) // ['name'],看不到 age,也看不到 Symbol
下面所有方法,本质上都是在"上面这三个维度"里各取一个子集。
二、for...in:会带上继承属性
for...in 遍历对象自身及原型链上所有可枚举的属性(不含 Symbol):
const parent = { inherited: 'p' }
const obj = Object.create(parent)
obj.own = 'o'
for (const key in obj) {
console.log(key) // "own"、"inherited"
}
正因为会遍历到原型链上的东西,通常要配合 hasOwnProperty 过滤:
for (const key in obj) {
if (Object.hasOwn(obj, key)) { // 或 obj.hasOwnProperty(key)
console.log(key, obj[key])
}
}
for...in 的优点是能遍历,缺点是键不保证顺序(虽然现代引擎已有规则),且顺带遍历原型。不建议在业务代码里裸用。
三、Object.keys / values / entries:最常用
这三个返回的都是自有、可枚举的字符串键,不含 Symbol,也不含原型链:
const obj = { a: 1, b: 2 }
Object.keys(obj) // ['a', 'b']
Object.values(obj) // [1, 2]
Object.entries(obj) // [['a', 1], ['b', 2]]
因为返回数组,可以无缝接上数组方法:
Object.entries(obj).forEach(([key, value]) => {
console.log(key, value)
})
// 对象转 Map
const map = new Map(Object.entries(obj))
日常遍历对象,这三个基本够用。注意 Object.values / Object.entries 是 ES2017 才有的,老环境要做兼容。
四、拿到 Symbol 键和不可枚举属性
上面三个方法都拿不到 Symbol 键和不可枚举属性。需要更全的:
const sym = Symbol('s')
const obj = { a: 1, [sym]: 2 }
Object.getOwnPropertyNames(obj) // ['a'],自有字符串键(含不可枚举)
Object.getOwnPropertySymbols(obj) // [Symbol(s)],自有 Symbol 键
Reflect.ownKeys(obj) // ['a', Symbol(s)],两者合并,最全
对比:
| 方法 | 字符串键 | Symbol 键 | 不可枚举 | 原型链 |
|---|---|---|---|---|
for...in | ✅ | ❌ | ❌ | ✅ |
Object.keys | ✅ | ❌ | ❌ | ❌ |
Object.getOwnPropertyNames | ✅ | ❌ | ✅ | ❌ |
Object.getOwnPropertySymbols | ❌ | ✅ | ✅ | ❌ |
Reflect.ownKeys | ✅ | ✅ | ✅ | ❌ |
Reflect.ownKeys 是"自有属性"的完整集合,做深拷贝、代理拦截时常用它。
五、属性遍历顺序有讲究
现代 JS(ES2015 起)对字符串键有明确的遍历顺序规则,Object.keys、for...in 都遵守:
- 整数键(数组索引)优先,按数值升序。
- 其余字符串键,按插入顺序。
- Symbol 键,按插入顺序排在最后。
const obj = {}
obj.b = 1
obj[2] = 2
obj[1] = 3
obj.a = 4
Object.keys(obj) // ['1', '2', 'b', 'a']
理解这个顺序,能避免"为什么键的顺序和我写的不一样"的困惑。注意整数键的判定是 0 到 2^32-2 之间的非负整数。
六、转成数组再遍历(for...of)
如果就是喜欢 for...of 的写法,可以先转数组:
const obj = { a: 1, b: 2 }
for (const [key, value] of Object.entries(obj)) {
console.log(key, value)
}
// 或先拿键
for (const key of Object.keys(obj)) {
console.log(key, obj[key])
}
如果想让对象本身可迭代,可以自定义 Symbol.iterator:
const obj = {
a: 1,
b: 2,
[Symbol.iterator]() {
const entries = Object.entries(this)
let index = 0
return {
next: () => ({
value: entries[index],
done: index++ >= entries.length,
}),
}
},
}
for (const [k, v] of obj) {
console.log(k, v)
}
七、深度遍历嵌套对象
上面都是浅遍历。要递归处理嵌套对象,写个递归函数:
function deepTraverse(obj, callback) {
for (const key of Reflect.ownKeys(obj)) {
const value = obj[key]
callback(key, value, obj)
if (value && typeof value === 'object') {
deepTraverse(value, callback)
}
}
}
const data = { a: 1, b: { c: 2, d: { e: 3 } } }
deepTraverse(data, (key, value) => {
console.log(key, '=>', typeof value === 'object' ? '[object]' : value)
})
真实场景记得用 WeakSet 记录已访问对象,避免循环引用导致栈溢出。
八、选型建议
| 需求 | 推荐方法 |
|---|---|
| 遍历自身可枚举属性(最常见) | Object.keys / values / entries |
| 遍历时能接数组方法 | Object.entries(obj).forEach(...) |
| 需要包含 Symbol / 不可枚举 | Reflect.ownKeys |
| 需要继承链上的属性 | for...in + hasOwnProperty 过滤 |
| 只取值不要键 | Object.values |
| 嵌套结构 | 递归 + Reflect.ownKeys + WeakSet |
小结
- 对象不是可迭代对象,不能直接用
for...of,需先转数组。 - 遍历方法差异在于三个维度:自有 / 继承、可枚举 / 不可枚举、字符串键 / Symbol 键。
Object.keys / values / entries只涵盖自有可枚举字符串键,是日常首选。Reflect.ownKeys覆盖自有属性的全部,含 Symbol 和不可枚举。for...in会遍历原型链,务必用Object.hasOwn过滤。- 遍历顺序规则:整数键升序 → 字符串键插入序 → Symbol 插入序。
- 深遍历要处理循环引用,避免栈溢出。