2026/10/14 19:13:54

ES6迭代器与生成器:从for...of到异步分页原理

ES6迭代器与生成器:从for...of到异步分页原理 最近整理代码时我发现团队里不少新人看到for...of就当成“数组的另一种循环方式”一旦遇到Map、Set、字符串或者自定义数据结构就开始翻文档看到Symbol.iterator这个写法更是直接懵掉。其实 ES6 引入的迭代器不是某个需要死记硬背的 API它是 JavaScript 对“遍历”这件事做的一次底层抽象。理解它你就理解了for...of、展开运算符、解构赋值、生成器函数这些看似无关的特性为什么能走到一起。这篇文章会从最朴素的遍历代码讲起一步步拆解迭代器协议、可迭代协议、手写迭代器、生成器函数一直讲到无限序列和异步分页。中间穿插我在真实项目中踩过的坑和排查思路尽量让原理和实操都落地。适合刚接触 ES6、想系统搞懂迭代器的人也适合写了好几年代码但一直没弄明白for...of工作原理的同学。1. 迭代器为什么能统一所有遍历场景1.1 从一段最朴素的遍历代码说起我们每天都在写遍历但很少停下来想一个问题为什么数组可以被for循环遍历答案很简单因为数组有下标arr.length和arr[i]是语言直接支持的。但一旦脱离数组这个逻辑就失效了。function sumArray(arr) { let total 0; for (let i 0; i arr.length; i) { total arr[i]; } return total; }这段代码能跑但它的缺点是显而易见的循环逻辑和“数组的下标规则”绑死了。如果有一天数据结构换了变成一个链表、一棵树、一个Set这套遍历代码就得推翻重写。更麻烦的是如果数据结构内部存储是私有的外部根本没有办法通过下标去访问元素。后来 ES5 提供了forEach算是给数组遍历开了个小口子但它有三个让人头疼的局限不能break、不能return、只能作用于数组。遇到Set和MapforEach又是另外一套签名换汤不换药。迭代器解决的正是这个问题。它定义了一个统一的“取下一个值”的接口不管数据存的是数组、集合、树节点还是数据库分页结果只要实现了这个接口调用方就能用同一种方式遍历。1.2 迭代器协议和可迭代协议怎么区分这里要把两个概念分开否则后面读报错信息会一头雾水。可迭代协议说的是对象本身迭代器协议说的是遍历动作本身。可迭代协议规定对象必须有一个名为Symbol.iterator的方法这个方法不接收参数返回一个符合迭代器协议的对象。迭代器协议规定返回的这个对象必须有一个next()方法每次调用next()都会返回一个形如{ value: any, done: boolean }的对象done表示序列是否已经结束。打个比方可迭代对象像一家餐厅餐厅门口挂了“可用餐券付款”的牌子这块牌子就是Symbol.iterator而迭代器则是从点餐到上菜的完整流程。你不需要关心后厨里食材怎么切、灶火怎么开你只需要知道“下一步会上哪道菜”就够了。数组、字符串、Set、Map都是可迭代对象因为它们的原型上都挂着一个Symbol.iterator方法。const arr [1, 2, 3]; console.log(typeof arr[Symbol.iterator]); // function const iterator arr[Symbol.iterator](); console.log(iterator.next()); // { value: 1, done: false } console.log(iterator.next()); // { value: 2, done: false } console.log(iterator.next()); // { value: 3, done: false } console.log(iterator.next()); // { value: undefined, done: true }看见没有数组本身不直接提供下标过滤但通过Symbol.iterator拿到一个迭代器之后就可以一步步把元素取出来。done: true出现时序列就结束了。1.3 为什么要把“可遍历”设计成 Symbol.iterator很多人会问为什么不直接用iterator()这种普通方法名非要搞一个Symbol.iterator答案很简单避免命名冲突。Symbol是 ES6 新增的原始类型每次调用Symbol()生成的值都是绝对唯一的。用Symbol.iterator作为协议名可以最大程度避免和用户自定义的属性名撞车。你想想如果某个第三方库在对象上恰好定义了一个iterator()方法那么它的语义到底是什么是返回迭代器还是返回一个普通函数谁也说不清楚。用Symbol等于在语言层面加了一把锁任何用户代码都不可能在不刻意仿造的情况下覆盖这个协议。这个设计思路也提醒了我们当自己设计一个需要被for...of消费的库或数据结构时第一选择应该是在对象上通过计算属性名[Symbol.iterator]来实现而不是随便起一个字符串方法名。协议名就是协议名不要复用普通方法名。2. 手写一个迭代器next、done、value 三件套全解析2.1 最简迭代器next 方法的闭包状态理解迭代器最好的方式就是亲手写一个。假设我们要做一个数字范围迭代器从start遍历到end每次next()返回一个数字。function createRangeIterable(start, end) { return { [Symbol.iterator]() { let current start; return { next() { if (current end) { return { value: current, done: false }; } return { value: undefined, done: true }; }, }; }, }; } const range createRangeIterable(1, 3); for (const num of range) { console.log(num); // 依次输出 1, 2, 3 }这段代码的关键在于闭包里的current变量。每次调用next()current都会更新并作为下一次调用的起点。换句话说迭代器必须是有状态的它内部要记住“现在遍历到哪了”。为什么next()返回值要包装成{ value, done }这种结构而不是直接返回value呢因为调用方必须能区分“取到了一个值”和“序列已经结束”这两种情况。如果直接返回值当值本身可能是undefined时你根本没法判断是遍历完了还是真的遇到了一个undefined元素。包装成对象之后done字段承担了“是否结束”的职责value只负责承载当前值。这个设计在第一次写迭代器时我会觉得很啰嗦但用熟了之后会发现它很严谨把“是否存在下一个”和“当前值是什么”彻底拆开语义非常清晰。2.2 for...of 到底是怎么消费迭代器的有了上面的迭代器for...of的工作过程就很好还原了。它在内部其实做了这几件事先读取遍历对象的[Symbol.iterator]方法调用这个方法得到一个迭代器对象反复调用迭代器对象的next()方法每一次拿到{ value, done }后检查done字段如果done为false把value赋值给循环变量执行循环体如果done为true停止循环如果循环体因为break、return、throw而提前退出且迭代器有return()方法则调用它做资源清理。用伪代码表示就是这样function forOf(iterable, callback) { const iterator iterable[Symbol.iterator](); let result iterator.next(); while (!result.done) { callback(result.value); result iterator.next(); } }这个过程中for...of完全不关心数据到底是数组还是别的什么它只需要遍历对象身上有[Symbol.iterator]这个协议方法。这就是“统一接口”的意义调用方不需要提前知道结构内部怎么存数据只要知道“它能被遍历”就够了。2.3 内置可迭代对象与它们的迭代结果JavaScript 里内置的可迭代对象比很多人想象的多我整理了一张表对象默认迭代结果备注Array数组中的每一个元素解构赋值let [a, b] arr依赖该结果String每一个 Unicode 字符按码点迭代不会拆开代理对字符Set集合中的每一个值按插入顺序输出Map每一项返回一个[key, value]数组展开运算符展开成二维数组arguments函数调用时的每一个实参类数组对象也有该能力NodeListDOM 元素集合中的每一个节点前端直接for...of很方便TypedArray类型数组的每一个元素处理二进制数据时可用这里有一个值得留意的点字符串迭代器是按 Unicode 码点遍历的而不是按 UTF-16 单元。比如.length是 2但如果用for...of遍历你会得到完整的一个表情符号。这在处理多语言文本、表情符号时非常实用也是迭代器在细节上优于传统下标循环的地方。3. 用生成器函数创建迭代器yield 的暂停魔法3.1 function* 与 yield 的暂停机制手写迭代器虽然逻辑清楚但代码一多就会遇到一个尴尬的问题状态管理太繁琐。比如要遍历一棵二叉树你得自己维护一个栈每次next()都要判断从哪个节点继续。这时生成器函数就该出场了。function* demo() { yield 1; yield 2; return 3; } const it demo(); console.log(it.next()); // { value: 1, done: false } console.log(it.next()); // { value: 2, done: false } console.log(it.next()); // { value: 3, done: true }调用demo()时函数体并不会立即执行。只有第一次调用next()时代码才会运行到第一个yield处把一个值抛出来然后暂停。第二次调用next()时从上次暂停的位置继续往下执行一直到下一个yield或者return。这个暂停机制可以类比成录像机的暂停按钮你不需要手动记录“我看到了哪一帧”暂停时会话、局部变量、执行位置全部被记住了按一下播放就能接着往下走。这正是生成器函数最核心的价值它把“迭代到哪了”这件事从手写状态变量里解放了出来。3.2 用 yield* 做迭代器组合实际写代码时遍历逻辑往往是嵌套的。比如一个数组里的元素可能也是数组或者一个数据源内部包含另一个数据源。手动拼接迭代器非常痛苦但yield*可以很优雅地解决。function* inner() { yield a; yield b; } function* outer() { yield 1; yield* inner(); yield 2; } for (const value of outer()) { console.log(value); // 输出 1, a, b, 2 }yield*的作用是把后面这个可迭代对象产生的所有值像管道一样逐个转发到外层生成器里。注意是“逐个转发”不是一次打包。这意味着内层迭代器是惰性的外层每消费一个值内层才计算一个值。这个特性在组合遍历逻辑时特别好用。我做过一个业务需要遍历一棵组织架构树把每个节点的姓名和其下所有子节点的姓名拼成一张扁平列表。用递归函数加yield*写起来极其顺手换作手写迭代器实现光状态管理就够头疼了。3.3 无限斐波那契序列惰性求值的实战初学生成器时很多人看到while (true)会害怕因为普通函数里写死循环就等于页面卡死。但生成器函数天然是惰性的函数体不会一次全跑完而是每次next()只算到下一个yield就停下来。function* fibonacci() { let [prev, curr] [0, 1]; while (true) { yield curr; [prev, curr] [curr, prev curr]; } }这段代码里的while (true)理论上永远跑不完但因为每次计算完立即暂停它每次都只会多算一个数字。你可以手动控制取几个const fib fibonacci(); console.log(fib.next().value); // 1 console.log(fib.next().value); // 1 console.log(fib.next().value); // 2 console.log(fib.next().value); // 3 console.log(fib.next().value); // 5或者写一个take辅助函数限制只取前 N 个function* take(iterable, n) { let count 0; for (const item of iterable) { if (count n) return; yield item; count; } } for (const num of take(fibonacci(), 10)) { console.log(num); }无限序列只有在被消费时才有意义这就是惰性求值的魅力。数组做不到这件事因为你必须先把所有元素算出来存到内存里生成器不需要。4. 迭代器实战踩坑一次性耗尽与资源清理4.1 迭代器“一次性”问题迭代器是一次性消费的遍历到底之后它的done永远是true。这不是 bug而是设计如此。const arr [1, 2, 3]; const iterator arr[Symbol.iterator](); console.log(iterator.next()); // { value: 1, done: false } console.log(iterator.next()); // { value: 2, done: false } console.log(iterator.next()); // { value: 3, done: false } console.log(iterator.next()); // { value: undefined, done: true } console.log(iterator.next()); // { value: undefined, done: true }我看到过一个报错场景某位同学把同一个迭代器对象传给了两个地方使用第一处把它遍历完了第二处再遍历时发现一个元素都拿不到排查了半天才发现是迭代器共用惹的祸。解决办法是每次需要遍历时都通过obj[Symbol.iterator]()重新获取一个全新的迭代器而不是把这个迭代器对象到处传。这里要区分两个概念可迭代对象是“能产生迭代器的对象”迭代器是“正在遍历的过程对象”。数组是可迭代对象arr[Symbol.iterator]()返回的是迭代器。只有前者可以重复遍历后者不行。4.2 提前退出时迭代器资源的关闭for...of循环里如果写了break或return且迭代器对象带有return()方法这个方法会被自动调用。生成器函数天然支持这一点比如下面这段代码function* withResource() { try { yield 1; yield 2; } finally { console.log(资源已释放); } } for (const value of withResource()) { break; } // 输出资源已释放但是如果你不是在for...of里消费而是手动调用next()遍历那么提前停止时没有任何机制会帮你调用return()你需要自己补上const it withResource(); console.log(it.next()); // { value: 1, done: false } it.return(); // 手动关闭触发 finally 里的清理逻辑这个细节在写文件读取、网络请求、数据库游标这类需要释放资源的迭代器时至关重要。如果迭代器内部占用了某个客户端连接光靠break离开循环但没调用return()资源可能一直在后台挂着。4.3 普通对象为什么不能被 for...of 遍历这个问题被问过无数次对象{ name: x, age: 18 }为什么不能直接for...of因为普通对象没有实现可迭代协议也就是没有Symbol.iterator方法。原因可以理解对象的属性层次和语义太灵活了到底遍历自身属性还是包括继承属性遍历key、value还是[key, value]数组不同场景想要的结果完全不一样语言层面没法强制给一个统一答案。所以 ES6 只要求内置数据结构实现迭代协议普通对象则留给了开发者自己决定。实际开发中如果非要遍历普通对象可以用Object.keys、Object.values或Object.entries它们的返回结果都是数组天然可迭代。或者你也可以给对象手动添加Symbol.iterator方法自定义遍历行为不过这种情况真的很少见。4.4 手写迭代器 vs 生成器怎么选对比维度手写迭代器生成器函数代码量需要维护状态变量代码较冗长只需用yield表达暂停点非常简洁可读性逻辑分散在next()中执行顺序一目了然自控能力可以完全控制每次next()的行为由生成器内部自动管理暂停恢复适用场景需要自定义next()、return()行为的底层库绝大多数业务遍历、数据处理场景我的建议是能用生成器解决的问题就别手写迭代器。除非你可能需要在一次遍历中同时控制多个迭代器的推进节奏或者需要精确拦截对next()的调用否则生成器函数在可读性和维护性上几乎是全面获胜的。5. 三个真实场景异步分页、数据管道与大文件逐行读取5.1 分页数据用异步生成器逐页拉取前端项目里最常遇到的分页场景是后端返回{ list, hasMore, nextPage }业务代码需要一个一个地把所有页的数据拿回来处理。传统写法是在函数里循环请求但如果你想用for...of一样的方式消费分页结果就需要异步生成器。async function* createPageLoader(fetchPage) { let page 1; while (true) { const result await fetchPage(page); yield result; if (!result.hasMore) break; page 1; } }消费方可以这样写for await (const page of createPageLoader(loadPageData)) { console.log(第 ${page.page} 页共 ${page.list.length} 条); }for await...of是异步迭代器的配套语法它会把异步生成器产生的Promise依次解包并且同样支持break提前终止。这种封装最大的好处是业务代码不再关心“当前页码是多少”“还有没有下一页”它只需要关心“下一个结果是什么”。我用这种方式重构过一个后台报表导出功能。原先的代码写满了requestPage、offset pageSize这类变量操作改成异步生成器之后逻辑一下子清楚了很多后续调整每页大小也只改一个参数。5.2 用生成器搭建流式数据处理管道生成器之间可以像 Unix 管道一样串联。比如用map和filter生成器对一组数据做惰性变换function* map(iterable, fn) { for (const item of iterable) { yield fn(item); } } function* filter(iterable, predicate) { for (const item of iterable) { if (predicate(item)) yield item; } }配合前面写的take和无限斐波那契就能写出一段“前 10 个大于 10 的偶数斐波那契数乘以 2”的处理管道const isEven (n) n % 2 0; const filtered filter(fibonacci(), (n) n 10 isEven(n)); const doubled map(filtered, (n) n * 2); for (const value of take(doubled, 10)) { console.log(value); }这里每一步都是惰性的没有真正生成一个装满所有结果的数组而是每消费一个值才计算一个值。对超大列表或无限序列来说这种方法在内存使用上完胜数组链式调用。Array.prototype.map和filter会创建中间数组数据量一上来就可能把内存吃穿而生成器管道始终只保留当前正在处理的那一个值。5.3 大文件按行读取的迭代器实现Node.js 里的readline模块本身就是异步可迭代对象所以我们可以直接把它包成一个异步生成器实现逐行读取大文件而不是一次性把整个文件丢进内存。const fs require(fs); const readline require(readline); async function* readLines(filePath) { const stream fs.createReadStream(filePath); const rl readline.createInterface({ input: stream, crlfDelay: Infinity, }); for await (const line of rl) { yield line; } } for await (const line of readLines(./big.log)) { if (line.includes(ERROR)) { console.log(line); } }这个场景非常适合用来直观感受迭代器和异步迭代器的组合价值文件流本身不是数组但在生成器包装之后它的消费方式和遍历数组几乎没有区别。crlfDelay: Infinity这个配置建议保留它能正确处理混合换行符的文本文件避免某些行被错误拆开。我在处理几 GB 的日志文件时就是这么干的内存占用稳定在一个很小的范围内性能表现比按行拆分成数组的方案好很多。最后再分享一点个人体会迭代器这个特性表面上看只是给for...of提供了底层支撑但对代码设计的影响远不止于此。它把“数据怎么存”和“数据怎么取”彻底解耦了调用方只需要面对一个统一的消费接口。我现在的习惯是只要遇到“需要按顺序取一堆东西”的需求先想一下能不能用一个生成器来表达而不是直接开数组往里塞。调试迭代器时还有个实用小技巧打印iterator.next()的结果看一眼done字段是true还是false就能快速判断迭代器是不是已经耗尽。如果你发现自己写的数据结构被for...of消费一次之后第二次完全没数据十有八九是迭代器没有重新获取把可迭代对象和迭代器本身混为一谈了。这两个东西确实是新手最容易搞混的点希望这篇内容能帮你少踩几个坑。