ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

深入理解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 降级编译、polyfill5.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) { // ... } // 如果中途 breakfinally 依然会执行资源不会泄漏第四要提一嘴性能。生成器确实比纯数组遍历慢一点因为每一次 next() 都有函数调用和状态切换开销。如果你只是遍历一个几千项的普通数组老老实实用 for...of 或数组方法就行没必要套生成器。但如果你处理的是大流、大数据集、或者需要惰性计算的场景生成器带来的内存红利远远超过那一点调用开销。一句话总结我的经验小数据用数组大数据用生成器需要暂停恢复时用生成器。我个人在实际项目里使用生成器最多的场景其实是写数据处理流水线从接口拿一批原始数据先过滤掉非法字段再转换格式最后按批次输出。以前用数组链式调用每一步都会生成中间数组数据量大的时候内存压力明显改成生成器流水线之后每一步只处理当前值链路上永远只有一个数据在流动。这个改造带来的收益比代码量减少更实在——节点的内存峰值直接降了一个数量级。最后分享一个小技巧当你调试生成器代码时别直接用 console.log 打印生成器对象——你只会看到一堆内部方法看不到执行进度。你在next()的结果上打点或者把value和done打印出来才能清楚地看到迭代到哪一步了。多打几次next()的返回值你对“暂停、恢复、结束”这三态的理解会比读十篇文章都深刻。迭代器和生成器是那种“平时用不着一旦需要就是救命的特性”。它们不像 React Hook 或 Promise 那样天天出现在面试题里但真正理解之后你会发现很多“遍历”和“流程控制”的问题都有了全新的解题思路。
返回列表