
让JS对象也能for-of遍历前端老铁都懂的可迭代魔法先说个场景。你写了个接口返回的对象想遍历它的每个值去做点格式化处理顺手就用上了for...of结果控制台直接抛红TypeError: obj is not iterable。再一看旁边的数组for...of跑得欢天喜地凭啥对象不行其实答案一句话就能说清楚数组身上有迭代器普通对象没有。但这句话背后藏着一整套 JS 的迭代协议懂了它你不仅能给对象插上for...of的翅膀还能顺手搞明白展开运算符、解构赋值、generator这些看起来各自独立的东西其实全是一家人。这篇文章不扯虚的直接从报错现场出发把可迭代这块硬骨头啃明白再给你三套能直接抄作业的对象遍历方案最后聊聊实战里的坑。适合谁看刚入行没多久、写for...of报错后一脸懵的初级前端以及写了好几年业务、能用但说不清原理的中级老铁。1. 为什么用for-of遍历对象会翻车1.1 一个让新手当场懵掉的报错先复现一下现场。你写了个订单对象const order { id: A1001, goodsName: 无线鼠标, price: 129, count: 2 }; for (const key of order) { console.log(key); } // TypeError: order is not iterable原因并不玄乎for...of本质上不是遍历对象而是遍历可迭代对象。它一上来就会尝试从目标身上取一个叫Symbol.iterator的方法然后反复调用这个方法来取下一个值。普通对象身上压根没有这个Symbol.iterator所以 JS 引擎直接撂挑子不干了。这就像你拿着公交卡的刷卡器去刷一张没有芯片的普通卡片刷卡器说无法识别。不是你的姿势不对是卡片上根本没有内置那个感应芯片。1.2 对象和数组的底层差异对比一下就明白了。数组为什么能直接for...of因为它天生带Symbol.iterator。稍微在原型的视角下验证一下const arr [1, 2, 3]; console.log(typeof arr[Symbol.iterator]); // function const obj { name: 张三 }; console.log(typeof obj[Symbol.iterator]); // undefined这里要分清两个概念也是面试常掉的坑数组是对象的一种但数组多了有序索引 长度变化这套特殊行为同时它还额外内置了迭代器。普通对象在设计之初就是无序键值对容器它压根没有顺序这回事所以语言规范没有给它默认的迭代器。那 JS 提供的for...in不是也能遍历对象吗对但for...in是遍历可枚举属性名走的是属性枚举的机制跟迭代器没半毛钱关系。它拿到的是key不是value而且它会把原型链上可枚举的属性也带出来所以很多老代码里你会看到遍历对象前先来个hasOwnProperty判断。而for...of是遍历值序列完全两套东西。2. 可迭代协议与迭代器协议魔法背后的底层逻辑想掌握让对象可迭代这个能力必须先搞懂 JS 迭代机制的两条协议。这两条协议就是前面说的感应芯片和刷卡器之间的通信标准。2.1 可迭代协议Symbol.iterator就是通行证第一个协议叫可迭代协议。规则非常粗暴一个对象只要身上有Symbol.iterator这个方法它就是可迭代对象。const customIterable { [Symbol.iterator]: function() { // 这个方法必须返回一个迭代器对象 } };这个方法不需要接收参数唯一的要求是返回一个迭代器对象。for...of、展开运算符(...)、Array.from()、解构赋值这些语法遇到一个目标时做的第一件事都是去寻找这个Symbol.iterator方法。找不到直接抛错。找到了就把它当成取值的总开关。数组、字符串、Map、Set、arguments、NodeList都是原生可迭代对象因为它们各自内置了Symbol.iterator。注意Map也是可迭代的它迭代出来的是[key, value]这样的二元数组这是个非常实用的细节后面案例里会用到。2.2 迭代器协议next()是你的步进器Symbol.iterator方法要返回的迭代器对象又得满足第二条协议——迭代器协议它身上必须有一个next()方法。next()每次被调用就返回一个对象{ value: 任意值, done: true | false }value当前迭代拿到的值done布尔值表示迭代是否结束了可以这么理解for...of本质上是个自动点名的老师。它每次都喊一次next()学生迭代器就报一个名字value。当学生回答done: true的时候老师就知道人都齐了循环结束。手动模拟一下这个流程const iterator someIterable[Symbol.iterator](); let result iterator.next(); while (!result.done) { console.log(result.value); result iterator.next(); }这就是for...of展开后的穷人版实现。理解了这层你就知道为什么普通对象不能直接用for...of了——它没有next()老师喊破喉咙也没人应。2.3 生成器函数写迭代器的偷懒神器手写next()方法是有套路的但代码一多就容易烦而且手动维护一个当前走到哪了的变量容易出错。这里就轮到生成器函数出场了。function* myGenerator() { yield 1; yield 2; yield 3; } const gen myGenerator(); console.log(gen[Symbol.iterator]); // 生成器对象自带 Symbol.iterator console.log(gen.next()); // { value: 1, done: false }生成器函数定义的时候在function后面加个*内部用yield表达在这里暂停并吐出一个值。调用生成器函数不会立刻执行函数体而是返回一个生成器对象。这个对象特妙它既满足可迭代协议有 Symbol.iterator又满足迭代器协议有 next。yield之间的逻辑只在调用next()时才执行这就是后面惰性序列的基础。写迭代器的时候用生成器函数代码量能缩一半可读性还高。记住这一点等会儿让对象支持for...of的第三套方案就是基于它实现的。3. 给对象插上迭代翅膀的三种实战方案搞懂协议接下来就是实操环节。三种方案从最简单到最灵活按需取用。3.1 方案一Object.keys/values/entries 中转如果你只是想把对象的值遍历一遍最简单的方法不是改造对象本身而是借助三个静态方法Object.keys()、Object.values()、Object.entries()。这三个方法返回的都是数组而数组天然可迭代所以直接就能配for...ofconst user { name: 张三, age: 30, city: 上海 }; // 遍历键 for (const key of Object.keys(user)) { console.log(key); // name → age → city } // 遍历值 for (const value of Object.values(user)) { console.log(value); // 张三 → 30 → 上海 } // 同时拿键和值 for (const [key, value] of Object.entries(user)) { console.log(${key}: ${value}); }这个方案最大的优势是零成本、零副作用不会修改原始对象。而且Object.entries的结构跟Map的遍历结构天然一致解构[key, value]也很顺。注意一个细节这三个方法都只处理可枚举的自身属性不会把原型链上的属性带出来这一点比for...in干净很多。如果你要考虑顺序按整型键升序、字符串键按插入序排列这套规则跟for...in是基本一致的。这个方案适合我只是想遍历一下的场景比如把对象的值拼接成表格行、批量改写值等等。它不需要理解迭代器协议读完就能上手。但它的局限也很明显你没法定制遍历的顺序也做不到边遍历边生成值更没法让对象在Map、Set构造函数里直接被吃进去。接下来是终极方案。3.2 方案二给对象挂一个Symbol.iterator第二种方案是给具体对象直接定义Symbol.iterator。比如const list { items: [A, B, C], [Symbol.iterator]: function () { let index 0; const items this.items; return { next: function () { if (index items.length) { return { value: items[index], done: false }; } return { value: undefined, done: true }; } }; } }; for (const item of list) { console.log(item); // A → B → C }这套就是标准迭代器写法用一个闭包变量index记住当前遍历到了哪个位置每次调用next()判断是否越界越界就返回done: true。这里有个新手容易踩的坑index不能用this去挂否则next里面的this指向会乱掉。闭包变量最稳。你其实还可以借助Object.entries把对象属性变成迭代序列不用手写indexconst score { math: 88, english: 92, physics: 79 }; score[Symbol.iterator] function* () { const entries Object.entries(this); for (const [key, value] of entries) { yield ${key}: ${value}; } }; for (const item of score) { console.log(item); // math: 88 → english: 92 → physics: 79 }看到function*就明白了这里用生成器函数替代了手写next()的繁琐逻辑。yield一次吐一个字符串出去遍历的时候非常有输出流的感觉。这种方案适合你有控制权、想给某个特定对象定制遍历规则时用。3.3 方案三把迭代器挂到构造函数原型上如果项目中反复需要让普通对象可迭代这种需求最合理的做法不是给每个对象单独挂方法而是把它做成一个通用类或者直接挂到原型上让所有实例自动拥有迭代能力。class IterableObject { constructor(obj) { Object.assign(this, obj); } *[Symbol.iterator]() { for (const key of Object.keys(this)) { yield [key, this[key]]; } } } const iterObj new IterableObject({ a: 1, b: 2, c: 3 }); for (const [key, value] of iterObj) { console.log(key, value); }这里有一个非常隐蔽的语法点要记住在类里写生成器方法用的是*[Symbol.iterator]()生成器标记*放在方法名前面。如果你写成[Symbol.iterator]()*直接语法报错。这个星号的位置问题我见过好几个人栽过。Object.assign(this, obj)这行把传入的普通对象的所有属性拷贝到实例上所以Object.keys(this)拿到的就是原对象的键。这样每个IterableObject实例都天然支持for...of、支持展开、支持解构。还有另一种做法是修改Object.prototype本身给所有对象加迭代器但我非常不建议你这么干。全局修改原型是 JS 里的大忌不仅会影响所有对象还可能导致第三方库行为异常。真要全局统一处理用Map是更正统的解法毕竟Map天生就是键值对的可迭代容器有序、性能也好还支持任意类型做键。4. 可迭代魔法在真实业务里的四种爽点让对象可迭代不只是为了能用for...of这个能力的价值比表面看大得多。分享四个我在真实项目里觉得啊真值的典型场景。4.1 懒序列与无限序列数据量大也不慌生成器函数配合迭代协议能干一件传统数组干不了的事按需计算值。传统数组在创建时就确定了全部元素而生成器可以边要边算。做到这一步你的业务代码就能具备数据流流水线的味道。比如你有一个很大的日志对象不想全量载入内存可以按需迭代每次只取一条、只解析一条。function* generateId(start 0) { let id start; while (true) { yield id; } } // 普通对象没法表达的无限序列这里轻松实现 for (const id of generateId(1000)) { if (id 1010) break; console.log(id); }更实际一点的场景做一个分页读取的迭代器。不用先把整张表全部拉回来每调一次next()才去取一页数据。这就把懒发挥到了极致数据量再大内存也不炸。4.2 解构赋值和展开运算符也能享受这种能力一个对象一旦是可迭代的不只是for...of能用连解构赋值和展开运算符都会自动认它。这是很多人没意识到的联动效果。const [a, b, ...rest] iterObj; console.log(a, b, rest); // a 是 [ a, 1 ], b 是 [ b, 2 ], rest 是 [[ c, 3 ]] const spreadCopy [...iterObj]; console.log(spreadCopy); // [ [ a, 1 ], [ b, 2 ], [ c, 3 ] ]也就是说你给对象定义了Symbol.iterator等于同时获得了好几种语法糖的加持。以后不用再写Object.entries(obj)转来转去直接一个展开就拿到数组。这里有个Map的细节值得顺手记住因为Map本身就是可迭代的它迭代出来的就是[key, value]二元数组所以你完全可以把对象先转成Map再享受全套语法糖const user { name: 张三, age: 30 }; const userMap new Map(Object.entries(user)); // 遍历 for (const [key, value] of userMap) { /* 正常 */ } // 转回对象 const userCopy Object.fromEntries(userMap);4.3 树形数据遍历嵌套对象一把梭普通对象无法表达递归结构但树形结构的前序遍历、后序遍历用生成器实现起来非常丝滑。以前你写递归函数收集结果到一个数组现在可以直接用yield*让递归自己展开。const tree { label: 根节点, children: [ { label: 子节点1, children: [ { label: 孙节点1-1 } ] }, { label: 子节点2 } ] }; function* walkTree(node) { yield node.label; if (node.children) { for (const child of node.children) { yield* walkTree(child); } } } for (const label of walkTree(tree)) { console.log(label); // 根节点 → 子节点1 → 孙节点1-1 → 子节点2 }注意这个yield*语法它表示把后面的可迭代对象整个委托给我逐个吐出来。递归遍历树的时候用了它整个遍历逻辑就变成一条清晰的深度优先搜索流水线。如果不用yield*你只能手动再套一层循环去处理子节点的返回值代码就乱多了。我自己做权限树的回显、组织架构树的序列化时都靠这套写法把逻辑保持在读起来像散文的舒适度。4.4 数据转换流水线filter/map也能用在对象上数组有的map、filter、reduce普通对象没有。但通过迭代器你可以让对象伪装成一个数组来处理。本质是把对象变成可迭代的键值对流再利用数组的高阶函数去处理它。const inventory { apple: 10, banana: 0, orange: 5 }; // 找出库存为 0 的商品 const outOfStock [...Object.entries(inventory)] .filter(([key, value]) value 0) .map(([key, value]) key); console.log(outOfStock); // [banana]虽然严格来说这里靠的是Object.entries但精神是一致的把对象流化然后中间穿插各种处理最后落成你想要的结构。真正让这个写法更上一层楼的是你可以把Symbol.iterator和生成器结合让对象自带的迭代器本身就内置过滤/转换逻辑const inventory { apple: 10, banana: 0, orange: 5, *[Symbol.iterator]() { for (const [key, value] of Object.entries(this)) { if (value 0) yield key; } } }; for (const name of inventory) { console.log(name); // apple → orange }到这里让对象可迭代就不再是炫技了它直接参与进业务模型中对象自带一套怎么被遍历的规则外部代码只管for...of不用关心内部过滤逻辑长什么样。这就是迭代器模式相比于手工遍历最实在的好处遍历逻辑内聚调用方无感知。5. 常见问题与避坑指南最后一部分直接把我踩过、以及帮别人排查过的坑列成清单你挨个对照排查能少掉一大片头发。5.1 五个高频坑坑一Symbol.iterator 不是函数obj[Symbol.iterator] is not a function。通常是给对象定义了[Symbol.iterator]但没赋值或者赋了个非函数值。检查一下是不是把undefined或者对象直接挂上去了。坑二next 方法的 this 指向丢失手写迭代器的时候如果把迭代器对象的方法抽出来单独调用this会指向undefined严格模式下。新手容易在next里面写this.items结果全炸。最稳的办法是用闭包变量别依赖this。坑三done 永远不返回 true死循环for...of是只要done不为true就玩命调next。如果你在next里的边界判断写错比如index length而不是 length最后就会多吐一个undefined再往后就是死循环。调试的时候打印done的值非常管用。坑四Symbol.iterator 的返回值不是对象迭代器协议要求next()返回一个{ value, done }对象。你要是图省事直接返回return { value, done }但done忘了给布尔值for...of会认为迭代永远没结束。坑五修改 Object.prototype 引发灾难前面提过我不建议给Object.prototype加迭代器。哪怕你觉得一劳永逸真香也要明白它会影响所有对象、破坏第三方库对对象的默认假设还会在for...in遍历时把Symbol.iterator也枚举出来虽然现代引擎默认不枚举Symbol类型但代码洁癖上依然不建议。想统一迭代行为用类包装别污染原型。5.2 常见问题快查表报错信息原因快速修复obj is not iterable对象没定义Symbol.iterator用Object.entries(obj)转数组或给对象挂迭代器result[Symbol.iterator] is not a function调用展开/解构时最终还是拿到了非可迭代对象检查是不是解构了一个返回undefined的值next is not a functionSymbol.iterator没返回迭代器对象确认next方法存在且是函数遍历少了一个元素或者多了一个undefined边界条件写错打印index和length对比生成器函数没有输出函数体压根没执行因为生成器是懒执行手动调一次.next()确认是否能推进5.3 性能与兼容性快评性能上for...of比传统的for...in快不少因为它走的是迭代器协议而不是属性枚举少了原型链查找的开销。但生成器函数毕竟多了一层函数调用如果迭代规模上百万量测一下再说别盲猜。兼容性上Symbol.iterator是 ES6 标准主流的现代浏览器、Node.js 以及 TypeScript 的downlevelIteration和target配置都支持。唯一要留神的是老掉牙的 IE11——它不支持Symbol.iterator的完整语义。如果必须兼容用 Babel 或直接退回路方案别在迭代器上死磕。注意给对象加迭代器的时候记得用Object.defineProperty定义为非枚举属性否则这个自定义方法可能会在一些遍历工具里被意外带出来。虽然Symbol键默认跳过for...in和Object.keys但想强制保证建议还是显式设一下enumerable: false。按我个人经验最舒服的组合是业务数据用Map需要自定义遍历规则的模型对象用类 生成器方法单纯跑一遍就完事的临时对象用Object.entries中转。理解迭代器协议之后你会发现自己看很多源码都不懵了——Array.from为什么接受任何可迭代对象Promise.all的参数为什么可以是可迭代的Set和Map的构造函数为什么能直接吃数组又能吃另一个Set全是这套协议在底下支撑。最后再分享一个小技巧调试的时候在控制台输入一个可迭代对象的名字然后直接调用对象[Symbol.iterator]().next()你能把迭代过程拆开看比for...of黑盒调试清晰十倍。这个方法在我排查上面那些坑的时候给我省了太多时间了。