news 2026/9/26 23:36:15

深入理解JavaScript迭代器与生成器:从原理到实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解JavaScript迭代器与生成器:从原理到实战
  • 为什么你的代码里 Data 列表越写越乱?为什么 for 循环里套着各种 index 判断?
  • 生成器、迭代器到底解决了什么问题?看完这些案例直接给你答案。
  • 从手写 iterator 到 generator 封装,再到异步流程控制,一篇讲透。

如果有人问我 ES6 里最值得反复琢磨的两个特性,我大概率会把**迭代器(Iterator)和生成器(Generator)**放在最前面。很多人学了数组的 map、filter、reduce 之后,觉得遍历问题已经彻底解决了,直到有一天要处理一个自定义数据结构、要写一个无限序列、要实现分页加载全部数据但内存不能爆,才发现 for 循环和数组方法根本搞不定。迭代器和生成器就是为这种“怎么遍历、什么时候取值、取多少值”的问题提供的一套底层方案。

这篇内容适合谁看?只要你写过 JavaScript,不管是前端处理接口返回的列表数据,还是 Node 后端做大批量数据的流式读取,都值得仔细看完。我会从最底层的迭代器协议讲起,再讲生成器的执行机制,最后给出几个生产环境里可以直接抄的案例:无限斐波那契、分页加载器、深拷贝里的 DFS 遍历,以及 generator 封装迭代函数的典型写法。读完之后你会对 for...of、展开运算符、解构赋值背后到底发生了什么有全新的认识。

1. 迭代器:解锁 for...of 背后的真相

1.1 为什么需要迭代器:从数组到任意数据结构的统一遍历

我们先想一个问题:你写 for (let i = 0; i < arr.length; i++) 的时候,其实是在“手动管下标”,你得知道数组有 length 属性,得知道下标从 0 开始,得写对 i++ 的位置。这套逻辑在数组上没问题,一旦数据源换成 Set、Map、某个自定义链表、甚至是一个无限生成的数列,这套“下标思维”就崩了。

迭代器解决的核心问题是:把“怎么取下一个值”这件事封装起来,对外只暴露一个 next() 方法。你不需要关心数据是什么结构,不需要关心内部有没有下标、有没有 length,你只需要重复调用 next(),拿到 value 和 done 两个字段,直到 done 变为 true。

ES6 里统一了这套规则,任何对象只要实现了[Symbol.iterator]方法,并且这个方法返回一个符合迭代器协议的对象,它就能被 for...of、展开运算符、解构赋值、Array.from 等语法消费。数组、字符串、Set、Map、arguments 这些内置对象都实现了这个接口,所以你能直接 for...of 遍历它们。

这背后的设计思想值得多说一句:它把“数据结构”和“遍历方式”彻底解耦了。以前数组只能用下标思想遍历,链表只能用指针思想遍历,现在只要你愿意实现迭代器协议,穿什么“衣服”都可以被同一套 for...of 语法接待。

1.2 手写一个迭代器:从零实现可迭代对象

光说不练没意思,我们手写一个可迭代的 range 对象,模拟 Python 里 range 的区间生成。这一步能帮你彻底看清迭代器协议的细节。

function createRangeIterator(start, end) { let current = start; return { next() { if (current <= end) { return { value: current++, done: false }; } return { value: undefined, done: true }; } }; } const range = { from: 1, to: 5, [Symbol.iterator]() { return createRangeIterator(this.from, this.to); } }; for (const num of range) { console.log(num); // 依次输出 1 2 3 4 5 }

注意几个细节。第一,next()方法返回的对象必须包含value和done两个字段,done为 true 时value可以省略,但规范建议显式写成 undefined。第二,[Symbol.iterator]和next()是分开的:外层对象负责“提供迭代器”,迭代器对象负责“记录当前遍历位置”。第三,如果你让迭代器对象自己挂上[Symbol.iterator]并返回 this,它就成了“既是可迭代对象又是迭代器”的双重角色。

实际项目里,最常见的使用场景是实现“自定义结构的统一遍历”。比如后端返回的是一个类数组对象,里面有{ 0: 'a', 1: 'b', length: 2 },你当然可以用 Array.from 处理,但如果你想让它支持 for...of,给这个对象添加 Symbol.iterator 是更根本的解决方案。还有像 DOM 的 NodeList、URLSearchParams 的键值对,源码里都是靠迭代器协议支撑 for...of 的。

2. 生成器:让函数学会“暂停”和“续传”

2.1 function* 与 yield:一个例子看懂执行流程

迭代器最大的痛点是手写太啰嗦:你要维护内部状态(比如 current 变量),要自己写 next() 的结构,稍微复杂一点的状态管理就非常容易出错。生成器就是用来解决这个痛点的——它让你用写普通函数的方式,产出符合迭代器协议的对象。

看这个经典例子:

function* numberGenerator() { console.log('开始执行'); yield 1; console.log('第一次恢复'); yield 2; console.log('第二次恢复'); yield 3; console.log('结束'); } const gen = numberGenerator(); console.log(gen.next()); // 开始执行, { value: 1, done: false } console.log(gen.next()); // 第一次恢复, { value: 2, done: false } console.log(gen.next()); // 第二次恢复, { value: 3, done: false } console.log(gen.next()); // 结束, { value: undefined, done: true }

很多人第一次看这段代码会懵:为什么 console.log('开始执行') 不是在函数调用时就打印,而是在第一次 next() 时才打印?这就是生成器的核心机制——惰性执行。调用numberGenerator()并没有真正运行函数体,它只是创建了一个生成器对象。函数体真正开始跑,是从你第一次调用next()开始的,然后跑到第一个yield语句时暂停,把后面的值抛出来。

yield相当于是函数的“暂停按钮”和“出口”,next()相当于是“继续按钮”。每次调用 next(),函数从上次暂停的位置继续执行,直到下一个 yield 或者函数结束。整个过程中,函数内部的所有局部变量状态都会被保留,不需要像手写迭代器那样手动维护 current 变量。

2.2 生成器的双向通信:next 传参与控制权

生成器不只是“单向地产出一串值”,它还能接收外部传入的数据,实现双向通信。你可能在别人的代码里见过gen.next(参数)这种写法,但未必理解这参数到底传给谁了。

function* echoGenerator() { const a = yield '请输入a'; console.log('收到a:', a); const b = yield '请输入b'; console.log('收到b:', b); return a + b; } const gen = echoGenerator(); console.log(gen.next()); // { value: '请输入a', done: false } console.log(gen.next(10)); // 收到a: 10, { value: '请输入b', done: false } console.log(gen.next(20)); // 收到b: 20, { value: 30, done: true }

这里的关键点:第一次调用next()时,函数从开头执行到第一个yield,此时yield表达式的值是 undefined,因为你还没有给它传参数。第二次调用next(10)时,这个 10 会成为第一个yield表达式的结果,赋值给变量 a,然后函数继续走到第二个yield。第三次的 20 同理。

有个特别容易踩的坑:第一次 next() 传的参数会被丢弃。因为第一次 next() 只是为了启动生成器,此时还没有 yield 在等待接收值。如果你想给生成器传初始参数,应该直接写在调用函数时:echoGenerator(初始值),而不是写在第一次 next() 里。

双向通信意味着生成器不只是“生产者”,还能作为“协程”来使用——你可以暂停一个任务,等外部条件满足后再继续,这为后面的异步流程控制和状态机打下了基础。

3. 生成器的典型应用场景与案例实操

3.1 无限序列与惰性求值:斐波那契这样写

想要一个无限长度的斐波那契数列、一个无限递增的 ID 生成器、一个永不停止的轮询序列,用数组做会直接把内存干爆。生成器的惰性求值特性让这种需求变成小菜一碟——生成器并不会一次性生成所有值,而是你每次 next() 它才计算下一个。

function* fibonacci() { let [prev, curr] = [0, 1]; while (true) { yield curr; [prev, curr] = [curr, prev + curr]; } } const fib = fibonacci(); for (let i = 0; i < 10; i++) { console.log(fib.next().value); // 1 1 2 3 5 8 13 21 34 55 }

while (true)在普通函数里就是死循环的灾难,但在生成器里完全没问题,因为它每次执行到 yield 就暂停了,永远不会真的“无限跑下去”。这种写法比你用数组先算好前 N 项再遍历要优雅得多,尤其是在你不知道需要取多少项的场景下。

同样思路,做一个无限 ID 生成器:

function* idGenerator(prefix = 'ID') { let count = 1; while (true) { yield `${prefix}_${count++}`; } }

配合解构赋值,你甚至可以一次取多个值:const [a, b, c] = idGenerator();,生成器会被自动迭代三次,取到前三个 ID。这种写法在模拟数据、批量生成测试数据时特别顺手。

3.2 分页加载器:读取全部数据却只占一份内存

你在 Node 后端处理数据库里几十万条记录,或者在前端拿到一个流式接口要分批拉取数据,一次性把所有数据 load 进内存基本就是找死。迭代器和生成器配合,能实现“按需读取、用完即丢”的分页加载器。

假设有个接口,每次传入 pageSize 和 pageNum 返回一页数据:

async function* paginateData(fetchPage, pageSize = 100) { let page = 1; let hasMore = true; while (hasMore) { const { data, total } = await fetchPage(page, pageSize); if (!data || data.length === 0) { hasMore = false; } else { yield data; // 每次产出当前这一页数据 page++; if (page * pageSize >= total) { hasMore = false; } } } } // 使用方式 for await (const pageData of paginateData(myFetchPage, 50)) { await processPage(pageData); // 处理完这一页,就可以丢弃释放了 }

这个案例有几点值得拆解。第一,我用的是async function*,这是生成器和异步函数的结合,产出的是 Promise,配合for await...of消费。第二,每次产出当前页,处理完一页才去请求下一页,内存里永远只有一页的数据量。第三,生成器天然地管理了page这个状态变量,你不需要在外部手动维护页码。

同样的思路,也可以用来做“分批读取文件流”“分批处理 CSV”“游标式遍历数据库查询结果”。如果你用过 Python,会发现这就是 Python 里“生成器批量加载数据”的标准套路,JavaScript 这边完全可以对标着用。

3.3 深拷贝与树的深度优先遍历:递归不用栈管理状态

树的深度优先遍历(DFS)通常有两个写法:递归,自己维护栈。递归的问题在于大深度下可能爆栈,自己维护栈的问题在于代码难读、状态变量多。用生成器来解决是一个性价比很高的方案——递归的骨架不变,但不再需要把结果收集到数组里,而是 yield 出去。

function* dfs(node) { if (!node) return; yield node.value; for (const child of node.children || []) { yield* dfs(child); } } // 使用 const tree = { value: 1, children: [ { value: 2, children: [{ value: 4, children: [] }] }, { value: 3, children: [] } ] }; for (const val of dfs(tree)) { console.log(val); // 1 2 4 3 }

注意这里用了yield*,它的作用是把另一个生成器(或可迭代对象)的值“逐个转发”出去,而不是把整个生成器作为一个值 yield。这个语法后面我会专门展开讲,这里先说结论:yield*是组合生成器最核心的工具。

这个模式写到深拷贝里也很自然。比如你要深度遍历一个对象的所有 key,或者遍历一个嵌套的 JSON 结构,用生成器 DFS 可以做到“边遍历边处理”,不漏掉任何节点,而且代码结构清晰。有人会问这跟普通递归收集数组有什么区别?区别在于内存:收集数组得把所有节点都存在内存里,生成器方式只有一个当前的节点状态,对于超大对象、深层树结构更友好。

3.4 Map/Set 与其他结构:配合迭代器的实用模式

ES6 里 map 和 set 天生就是可迭代的,但很多人没注意到它们和生成器组合的威力。比如你想把Map里的键值对做过滤、转换后再遍历,用生成器包装一层比先转数组再处理更节省内存、也更直观。

function* mapEntries(map, predicate) { for (const [key, value] of map) { if (predicate(key, value)) { yield [key, value]; } } } const myMap = new Map([['a', 1], ['b', 2], ['c', 3]]); for (const [key, value] of mapEntries(myMap, (k, v) => v > 1)) { console.log(key, value); // b 2 / c 3 }

类似地,你可以给 Set 写一个“取子集”的生成器、给字符串写一个“按分隔符拆成块”的生成器。思路都是一样的:把遍历逻辑封装在生成器里,外面用 for...of 保持极简。有一位朋友说过一句话我非常认同:生成器是把“遍历什么”“怎么遍历”这两个问题彻底模块化的工具,任何结构只要实现了 iterator 协议,就能被生成器编排。

4. 生成器进阶:委托、异步与“迭代器封装函数”

4.1 yield* 委托:组合多个生成器

前面提到yield*,这可能是生成器里最容易被忽略却最实用的语法。它把执行权“委托”给另一个可迭代对象,让当前生成器无缝衔接对方的输出,当前生成器自己不感知细节。

function* genA() { yield 1; yield 2; } function* genB() { yield 3; yield 4; } function* genCombined() { yield* genA(); yield* genB(); yield 5; } console.log([...genCombined()]); // [1, 2, 3, 4, 5]

这比手写 for 循环去 yield 每个值清晰得多。yield*的右侧可以是任何可迭代对象,数组、字符串、Set、Map、另一个生成器都行。所以在组合多个数据源时,yield*天然就成了“管道拼接”。

值得注意的一个坑:yield*会把右侧迭代器的return()返回值作为整个yield*表达式的结果。意思是如果被委托的生成器里有return 值,这个值可以在当前生成器中捕获:

function* inner() { yield 1; return 'inner done'; } function* outer() { const result = yield* inner(); console.log('捕获到:', result); // 捕获到: inner done yield 2; }

这个特性在实际业务中不算高频,但理解了它能帮你读懂不少框架源码。

4.2 用生成器包装异步流程或数据流

看到这里可能有人会问:异步场景不是有 async/await 了吗?还需要生成器做什么?答案是:两者解决的问题不同。async/await 是“等待一个 Promise 完成”,而生成器可以做到“暂停一段执行流程,等外部通知了再继续”。后者在复杂的流程编排、可中断任务、交互式控制场景里更灵活。

经典的场景是把生成器当作“可中断的任务队列”。比如你要处理一批任务,但希望每处理完一个就检查一下用户是否取消了,取消就提前终止,恢复时还能从上次位置继续:

function* taskRunner(tasks) { for (let i = 0; i < tasks.length; i++) { const task = tasks[i]; const shouldContinue = yield task; if (shouldContinue === false) { console.log('任务被外部终止'); return; } } } const runner = taskRunner([task1, task2, task3]); let result = runner.next(); while (!result.done && !userCancelled) { // 执行任务,然后根据外部状态决定是否继续 result = runner.next(shouldContinue); }

这其实就是协程思想的雏形,也是早期 Redux Saga 这类库的核心机制。虽然大多数普通业务用 async/await 就够,但如果你要写一个可暂停、可恢复、可手动控制的长流程任务,生成器是不可替代的工具。

4.3 generator 封装的迭代函数:一种更干净的迭代逻辑模块化方式

热搜词里有一条“generator 迭代器封装函数”,这其实说出了生成器在工程上最重要的价值:把一段复杂的迭代逻辑封装成一个函数,调用方完全感受不到内部的繁琐。这跟 React 的生成器组件思想、Python 的 itertool 工具族,都是同一个思路。

举个例子,要封装一个“从一个字符串中匹配所有满足正则的子串”的函数,传统写法你要维护 lastIndex,写起来又丑又容易错。用生成器封装就清爽得多:

function* matchAll(str, regex) { let match; const re = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : regex.flags + 'g'); while ((match = re.exec(str)) !== null) { yield { value: match[0], index: match.index, groups: match.groups }; } } for (const m of matchAll('hello world hello js', /hello/g)) { console.log(m.index, m.value); // 0 hello / 12 hello }

封装的意义在于:调用方不需要关心正则的 lastIndex 怎么维护、exec 循环怎么写、空匹配怎么防死循环,这些细节全部收敛到函数内部。以后想改实现方式、加缓存、加过滤条件,都不影响调用方。这就是“迭代器封装函数”的真正价值——接口稳定,内部自由。

再比如封装一个“读取文件按行处理”的函数、封装一个“游标分页查询”的函数,都是同样的模式:函数内部用生成器管理状态,外部用 for...of 拿到数据流。写业务代码时,你可以在工具函数库里沉淀一批这样的迭代器封装,团队其他人用起来非常爽。

5. 常见问题与排查技巧实录

5.1 问题速查表

我把自己实际开发中遇到的生成器和迭代器相关的坑整理成了表格,方便你排查时快速定位。

现象可能原因解决方案
for...of 遍历自定义对象直接报 “not iterable”对象没实现[Symbol.iterator]方法给对象添加 Symbol.iterator 并返回迭代器
生成器第一次 next() 传参没生效参数被丢弃,因为首个 yield 之前没有接收者初始参数直接写在生成器函数调用时
生成器“只执行了一次就不动了”for...of 或解构只迭代了部分值,生成器没有“到终点”检查是否提前 break 或被展开运算符截断
yield* 右侧是 undefined 报错右侧必须是可迭代对象确认对象实现了 Symbol.iterator 或本身是生成器
async 函数里用普通 for 循环费了半天劲没拿到生成器的值普通 for 不会自动 await 生成器产出的 Promise用for await...of消费 async generator
生成器生成了一半,想清理资源却没触发 finally提前 return 时,如果没调用生成器的return()方法,finally 不会自动执行用 try/finally 包裹,并调用gen.return()释放资源
手写迭代器时 done 为 true 了还在 next()迭代器协议要求 done 之后 value 可以为 undefined调用 next() 前判 done,或者直接交给 for...of 处理
无限生成器配合展开运算符[...gen]导致卡死展开运算符会一直迭代到 done,无限序列永远不会 done只使用有限次数的 next() 或配合 take 函数
浏览器环境兼容报错旧浏览器对 Symbol.iterator 支持不完整使用 Babel 或 TypeScript 降级编译、polyfill

5.2 避坑经验和实用心得

先说无限生成器配合展开运算符那个坑,我见过不少人犯。你写了一个function* infinite() { let i = 1; while(true) yield i++; },然后想[...infinite()]取前几个值,结果页面直接卡死。因为展开运算符和目标解构赋值都会“迭代到 done”,而无限序列永远不会 done。正确的取前 N 个值的姿势是自己写一个 take 工具:

function take(iterable, n) { const iterator = iterable[Symbol.iterator](); const result = []; for (let i = 0; i < n; i++) { const { value, done } = iterator.next(); if (!done) result.push(value); } return result; } console.log(take(infiniteCounter(), 5)); // [1, 2, 3, 4, 5]

第二个经验是关于生成器的“复用性”。生成器对象是一次性的:一个生成器迭代完,状态就结束了,你想重新遍历它,必须重新调用生成器函数生成新对象。很多人没意识到这一点,把生成器存到变量里想复用,结果第二次 for...of 什么都没有。需要重复迭代的时候,请每次重新调用生成器函数;或者让生成器函数具备“幂等性”,每次调用都返回全新的独立迭代器。

第三是资源清理。如果生成器内部有对流、文件、连接的占用,建议在生成器函数体里用 try/finally 包住核心逻辑:

function* processStream(stream) { try { while (stream.hasNext()) { yield stream.next(); } } finally { stream.close(); // 无论正常结束还是生成器被 return(),都会执行这里 } } const gen = processStream(dbStream); for (const data of gen) { // ... } // 如果中途 break,finally 依然会执行,资源不会泄漏

第四要提一嘴性能。生成器确实比纯数组遍历慢一点,因为每一次 next() 都有函数调用和状态切换开销。如果你只是遍历一个几千项的普通数组,老老实实用 for...of 或数组方法就行,没必要套生成器。但如果你处理的是大流、大数据集、或者需要惰性计算的场景,生成器带来的内存红利远远超过那一点调用开销。一句话总结我的经验:小数据用数组,大数据用生成器,需要暂停恢复时用生成器。

我个人在实际项目里使用生成器最多的场景,其实是写数据处理流水线:从接口拿一批原始数据,先过滤掉非法字段,再转换格式,最后按批次输出。以前用数组链式调用,每一步都会生成中间数组,数据量大的时候内存压力明显;改成生成器流水线之后,每一步只处理当前值,链路上永远只有一个数据在流动。这个改造带来的收益,比代码量减少更实在——节点的内存峰值直接降了一个数量级。

最后分享一个小技巧:当你调试生成器代码时,别直接用 console.log 打印生成器对象——你只会看到一堆内部方法,看不到执行进度。你在next()的结果上打点,或者把value和done打印出来,才能清楚地看到迭代到哪一步了。多打几次next()的返回值,你对“暂停、恢复、结束”这三态的理解会比读十篇文章都深刻。

迭代器和生成器是那种“平时用不着,一旦需要就是救命的特性”。它们不像 React Hook 或 Promise 那样天天出现在面试题里,但真正理解之后,你会发现很多“遍历”和“流程控制”的问题都有了全新的解题思路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 23:35:57

SpringBoot+Vue学生考勤管理系统:从零到部署的实战指南

简介&#xff1a;基于SpringBootVue开发的学生考勤管理系统完整毕业设计项目&#xff0c;面向计算机专业正在准备毕设的学生及需要项目实战经验的Java学习者&#xff0c;同样适用于课程设计、期末大作业等场景。系统采用B/S架构&#xff0c;以Java为核心技术、MySQL为后台数据库…

作者头像 李华
网站建设 2026/9/26 23:34:01

从零搭建常驻型AI智能体:Grok Bot架构、核心循环与避坑指南

1. 从一条曝光消息说起&#xff1a;Grok Bot 到底是个什么东西前几天社区里流传出一份据称是 ChatGPT 版 Grok Bot 的代码片段&#xff0c;配合 OpenAI 官方在智能体方向上一连串的动作&#xff0c;圈子里讨论得挺热。我第一时间把能拿到的信息捋了一遍&#xff0c;也顺手在自己…

作者头像 李华
网站建设 2026/9/26 23:33:28

HFSS 2021天线辐射效率曲线输出教程:从公式构造到工程解读

1. 天线辐射效率曲线到底在解决什么问题做天线设计的人都有一个共识&#xff1a;仿真能跑通不代表天线能用。回波损耗S11低于-10dB只说明端口匹配做好了&#xff0c;但能量到底是被天线辐射出去了&#xff0c;还是被介质和导体吃掉了&#xff0c;S11是看不出来的。这时候就需要…

作者头像 李华
网站建设 2026/9/26 23:32:04

多层BOM在易特ERP中的实战解析:从结构设计到实施避坑

1. 多层BOM到底难在哪&#xff1a;我见过的那些"一改全崩"现场先说一个我自己的经历。早年在给一家做非标自动化设备的客户上ERP时&#xff0c;对方工艺主管拿着一个半成品物料找到我&#xff0c;说这个件从今年3月以后&#xff0c;成本核算就没对过&#xff0c;每一…

作者头像 李华
网站建设 2026/9/26 23:30:16

DeepResearch代码实现详解:多智能体工作流与状态流设计

DeepResearch这个词前阵子突然就热起来了。用户给一句研究指令&#xff0c;比如“帮忙调研一下2024年主流向量数据库的选型差异”&#xff0c;它能在后台自动拆题、跑几十次搜索、读几十个网页&#xff0c;最后交出一份带引用来源、有条理的完整报告。说实话&#xff0c;第一次…

作者头像 李华
网站建设 2026/9/26 23:24:23

微信API限流与指数退避:从429到稳定重试的完整指南

如果你做过微信公众号、小程序或者企业微信服务端的接口对接&#xff0c;大概率见过这样的场景&#xff1a;凌晨的定时任务批量推送模板消息&#xff0c;跑到一半忽然整屏都是45009&#xff0c;或者更直接的HTTP 429 Too Many Requests。刚开始以为代码写错了&#xff0c;排查半…

作者头像 李华