不少写了好几年 JavaScript 的朋友一听到 ES6 里的迭代器和生成器第一反应往往都是“面试八股平时用不上”。但你要是真把业务代码里的数据遍历、分页加载、异步流程调度捋一遍会发现这两个东西其实就是把“怎么取数据”这件事彻底规范化了。迭代器定义了一套统一的取数规则生成器则让你能写出一段能暂停、能恢复的函数两者配合起来能解决一堆看起来很麻烦的问题。这篇文章我不打算讲太多学院派理论重点放在知识点拆解、真实场景用法和案例代码上争取让你看完就能直接拿回项目里用。1. 先把概念讲清楚迭代器到底是个什么东西1.1 没有迭代器之前遍历为什么又乱又疼在 ES6 之前JavaScript 里的遍历方式堪称“各管各的”。数组有 for 循环、forEach对象有 for...inSet 和 Map 是 ES6 才有的早期压根没得用。每个数据结构都自己定义一套遍历接口你用的时候就得记着数组怎么遍历、类数组怎么遍历、对象怎么遍历全都不一样。这就像每家超市的购物车都不一样——有的手推、有的手提、有的得自己找管理员借你每次进去都得重新学怎么用。更深层的问题是这种各自为政的遍历方式没法抽象。比如你写了一个通用的处理函数想让它既能处理数组又能处理 Set光是取数据这一层就得写好几套逻辑。等到这些数据结构之间需要互相转换的时候代码就更难看了。迭代器解决的就是这个问题大家别自己搞一套了都按同一个协议来取数。不管你是数组、字符串、Set、Map还是用户自定义的一个复杂业务对象只要实现了这个协议就能被统一的方式遍历。1.2 迭代协议一套让所有数据结构“口径统一”的规则迭代器其实由两套协议组成很多新手会混淆。第一套叫可迭代协议。一个对象要想被称为“可迭代对象”必须部署一个Symbol.iterator方法这个方法返回一个迭代器。简单说就是告诉 JavaScript 引擎“我有统一的取数入口入口长这样。”第二套叫迭代器协议。迭代器对象必须有一个next()方法每次调用next()你都会得到一个形如{ value: xxx, done: false }的对象。value是当前的值done是布尔值表示是否已经取完。取完了done就是true这时候value就没意义了。这两套协议其实是配套的可迭代对象帮你拿到迭代器迭代器帮你一次次取数。数组、字符串、Set、Map、arguments、NodeList 等内置对象默认都实现了Symbol.iterator这就是为什么for...of能直接遍历它们。需要留意的是普通的对象默认不是可迭代对象。for...in遍历的是对象的可枚举属性for...of遍历的是可迭代对象的数据这俩“八字不合”。你要是拿for...of直接遍历一个普通对象会直接报obj is not iterable。1.3 自定义迭代器让普通对象也能被 for...of 宠幸理解了协议你就能给普通对象手动安装迭代器。举个例子假设有个业务对象里面存了一组分数但分数是分散在两个数组里的const scoreBoard { math: [92, 85, 76], english: [88, 91, 79], // 想让这个对象能被 for...of 直接遍历出所有分数 }; scoreBoard[Symbol.iterator] function () { const scores [...this.math, ...this.english]; let index 0; return { next: () { if (index scores.length) { return { value: scores[index], done: false }; } else { return { value: undefined, done: true }; } }, }; }; for (const score of scoreBoard) { console.log(score); // 92 85 76 88 91 79 }这种自定义迭代器的写法最大的好处是遍历逻辑被封装到了对象内部。外部代码不需要知道对象里面有几个数组、数据怎么组合的只要朝它要数据拿到的就是统一的遍历结果。不过这么写的代码稍微有点啰嗦而且需要自己维护index状态。生成器出现之后这个痛点被彻底解决了。2. 生成器把函数变成一条可以暂停的流水线2.1 生成器的基本形态与执行机制生成器函数用function*声明函数体里用yield关键字“吐”出值。它跟普通函数的区别不在于写法多了一个星号而在于执行机制发生了本质变化。普通函数执行是一次性的你调用它它从头跑到尾return 后整段结束中间没法停下来。生成器函数则像一个可以随时暂停和恢复的流水线。调用生成器函数不会直接执行函数体而是返回一个生成器对象这个对象同时也是一个迭代器。每次调用生成器对象的next()函数体才会执行到下一个yield处然后暂停在那里把yield后面的值送回外部。function* listThree() { yield 1; yield 2; yield 3; } const gen listThree(); 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 }看到没每次next()调用函数只是“苏醒”一下从上次暂停的地方继续跑跑到下一个yield再停。这种机制就是生成器最核心的价值用同步的写法实现惰性的、可按需取数的逻辑。2.2 next、return、throw三个控制手柄的用法与区别生成器对象上有三个方法分别对应三种外部干预方式。next()是正向推进把函数从当前暂停点执行到下一个yield。它还能往函数体里传参数这个参数会作为上一个yield表达式整体的返回值出现在生成器内部。这是新手最容易懵的地方。function* feedMe() { const a yield give me a; const b yield give me b; return a b; } const gen feedMe(); console.log(gen.next()); // { value: give me a, done: false } console.log(gen.next(10)); // { value: give me b, done: false } console.log(gen.next(20)); // { value: 30, done: true }第一次next()启动执行跑到第一个yield停住。第二次next(10)把 10 传给函数体作为第一个yield表达式的值赋给a然后继续跑遇到第二个yield再停。第三次next(20)把 20 传给第二个yield赋给b最终 return 30。第一次next()的传参是无效的因为最开始函数体里还没有等待接收值的yield。return(value)表示外部不想继续取数了直接让生成器结束。调用后生成器会变成done: true状态你传的 value 会出现在返回对象的value上。throw(error)则是往生成器内部抛出一个错误。如果函数体内部没有捕获这个错误的try...catch错误会冒泡出来。这个过程的作用是让外部能优雅地终止生成器内部逻辑并让它收尾。这三者的区别我用一个表来说明方法作用返回值函数体内部感知next(value)恢复执行到下一个 yield{ value, done }value 作为上一个 yield 表达式的返回值return(value)提前终止整个生成器{ value, done: true }如果有 finally会执行 finally 后再结束throw(error)向当前暂停位置抛错抛出错误或返回 next 结构可以用 try...catch 捕获2.3 生成器与迭代器之间是什么关系一句话生成器对象就是迭代器的一种便捷实现方式同时它也是可迭代对象因为它自身实现了Symbol.iterator。手动实现迭代器协议时你要自己维护index、判断done、返回正确形状的对象很容易写错。生成器则把这些细节全部接管了——你只管用yield把值吐出来引擎自动帮你维护状态、判断结束。所以自定义迭代器首选方案就是生成器。上一节的 scoreBoard 例子用生成器重写会简洁得多const scoreBoard { math: [92, 85, 76], english: [88, 91, 79], *[Symbol.iterator]() { yield* this.math; yield* this.english; }, };yield*表示“把后面的可迭代对象代理给我把它里面的东西一个个吐出来”。这比手动写for...of再yield更简洁看到的一定要认识。3. 真正干活常见使用场景与案例拆解3.1 惰性求值与无限序列用生成器实现斐波那契数列生成器最出圈的特性就是惰性求值你不调用next()函数体就不会执行。这意味着你可以描述一个无限长的序列而不必一次性算出所有元素。数组存不下无限个生成器可以。用生成器实现斐波那契数列代码极其简洁function* fibonacci() { let [prev, curr] [0, 1]; while (true) { yield curr; [prev, curr] [curr, prev curr]; } }如果你用数组实现相同逻辑不是做不到而是做不到“按需只算前 n 个”。用生成器你可以这样消费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想取前十个也简单const firstTen [...getFibonacci(10)]; function* getFibonacci(max) { let [prev, curr] [0, 1]; while (max--) { yield curr; [prev, curr] [curr, prev curr]; } }这种“无限数据”用生成器描述是性能友好的。它不会占用内存去存一个超长数组你取一个算一个取完即弃。这一点在实际处理大量流式数据、日志数据、传感器数据的时候非常实用。3.2 自定义数据结构遍历一个真实的业务对象案例业务里经常遇到一个对象内部嵌套了好几个数组、Map、Set你想统一遍历里面的某些字段。手动写循环也不是不行但每次遍历都要重新写一遍组合逻辑可读性很差。假设有一个团队考勤对象里面两个成员数组每个人又有不同的考勤记录字段const teamAttendance { frontend: [ { name: 张三, date: 2025-01-06, status: normal }, { name: 李四, date: 2025-01-06, status: late }, ], backend: [ { name: 王五, date: 2025-01-06, status: normal }, { name: 赵六, date: 2025-01-06, status: absent }, ], *[Symbol.iterator]() { yield* this.frontend; yield* this.backend; }, }; for (const record of teamAttendance) { console.log(${record.name} - ${record.status}); }输出结果是所有人按顺序遍历出来。后续如果新增了一个design成员数组只需在生成器里加一行yield* this.design;所有消费这段数据的代码都不需要改动。这种模式在数据聚合、报表导出、批量统计场景特别有用。你把遍历规则收敛到对象内部外部代码永远用同一套for...of逻辑业务结构再怎么变外部代码都纹丝不动。3.3 嵌套数据扁平化用 yield* 递归拍平无限层级递归处理树形结构是前端老本行。但如果你要把一棵深度不定的树拍平成顺序列表普通递归写法要借助一个外部数组来收集结果。生成器写法更优雅利用yield*递归分发。function* flattenTree(nodes) { for (const node of nodes) { yield node; if (node.children node.children.length) { yield* flattenTree(node.children); } } } const menu [ { name: 首页, children: [{ name: 推荐 }, { name: 热门 }] }, { name: 分类, children: [{ name: 男装, children: [{ name: 衬衫 }] }, { name: 女装 }] }, ]; const allNames [...flattenTree(menu)].map((item) item.name); console.log(allNames); // [首页, 推荐, 热门, 分类, 男装, 衬衫, 女装]这个写法的关键就在yield* flattenTree(node.children)。它把递归结果一层层“透传”出来而不是依赖一个外部数组拼接。从代码组织上讲生成器让你可以用“平铺直叙”的方式表达递归逻辑外部拿到的是扁平的迭代结果不需要关心你里面怎么递归。3.4 流程控制与异步处理生成器当调度器ES6 时代async/await还没出现很多团队用生成器 Promise 组合拳来写异步流程。现在有了async/await这种写法不那么流行了但理解生成器做流程调度的原理仍然有价值——因为async/await本身就是生成器 Promise 的语法糖。核心思路是生成器函数内部用yield把每个异步操作“挂”出来外部调度函数负责把 Promise resolve 的结果再塞回生成器内部让它继续往前走。function* asyncFlow() { const user yield fetchUser(); const orders yield fetchOrders(user.id); const recent orders.slice(0, 3); console.log(recent orders:, recent); } function run(generator) { const iterator generator(); function handle(result) { if (result.done) return result.value; // result.value 是一个 Promise result.value .then((data) { handle(iterator.next(data)); }) .catch((err) { iterator.throw(err); }); } return handle(iterator.next()); } run(asyncFlow);这个调度器虽然简陋但原理是完整的外部调度函数在 Promise resolve 后调用next(data)数据就作为上一个yield表达式的返回值进入生成器内部函数体继续执行到下一步。错误则通过throw抛进生成器内部处理。如果你在阅读老项目或者一些非主流框架源码时看到这种模式就知道它们在干什么了。而现在你写await的时候底层走的就是这套逻辑。3.5 与主流内置 API 的配合实战生成器返回的可迭代对象天然支持所有基于可迭代协议的内置 API。这是它作为“标准公民”的好处。展开运算符可以把生成器产物直接变成数组前面已经试过[...flattenTree(...)]。Array.from同样支持传入可迭代对象function* range(start, end) { while (start end) { yield start; } } const nums Array.from(range(1, 10)); // [1,2,3,4,5,6,7,8,9,10]解构赋值也能直接用const [first, second, third] range(5, 10); // first5 second6 third7Set、Map的构造函数也能接收可迭代对象const numberSet new Set(range(2, 6)); // Set { 2,3,4,5,6 }Promise.all接收的是数组但你可以先把生成器展开成数组再传。这意味着你不用为了配合某个 API 专门把生成器“倒”成数组很多场景下原生 API 自己就会帮你迭代。只有明确需要一个完整数组的时候才用[...gen]或者Array.from(gen)做一次物化。3.6 用生成器实现可暂停/恢复的数据分批加载分页加载是非常典型的场景。前端表格要的数据可能很多一次性全拉回来既不现实也没必要。用生成器可以把“取下一页数据”这个动作封装成一个可暂停的任务每次外部调用next()它就发起一次请求并返回结果。我写过这么一个小模块核心是一个数据加载函数内部用生成器管理页码状态。function createPagedLoader(fetcher) { return function* () { let page 1; while (true) { const data yield fetcher(page); if (!data || !data.length) return; // 没有更多数据停止迭代 page; } }; } // 使用示例 async function fetchPage(page) { const res await fetch(/api/items?page${page}size10); return res.json(); } const loader createPagedLoader(fetchPage)(); // 手动控制需要下一页时调用 let result await loader.next(); // 触发第 1 页请求 console.log(result.value); result await loader.next(); // 触发第 2 页请求这里生成器内部维护着page状态外部每次next()推进一页。你甚至可以混入业务条件比如用户点击“加载更多”才调用next()完全由外部触发。这种写法比“用一个模块级变量存当前页数”要干净得多——生成器天然自带状态隔离每次调用返回一个全新的加载器实例互不干扰。4. 踩坑记录与经验总结4.1 我在实际项目里踩过的几个坑第一个坑是把生成器当普通函数调用结果函数体一直不执行。刚开始写生成器的新手很容易写出这样的代码function* hello() { console.log(hello); } hello(); // 什么都没打印生成器函数调用后返回的是生成器对象函数体根本没跑。必须调next()才会真正执行到第一个yield。这个差别导致很多人调试半天发现代码没反应。第二个坑是生成器不能被直接重用。迭代器是一次性的跑到底就完了。我试过把同一个生成器对象传给两个不同的消费逻辑结果第二个消费逻辑什么都拿不到因为第一个已经把它耗尽了。const gen listThree(); for (const v of gen) console.log(v); // 全部取走 for (const v of gen) console.log(v); // 空没有输出了如果要多次遍历同一个序列就写一个返回生成器的函数每次调用都重新创建。这个原则一定要记住。第三个坑是在for...of循环中使用break/return时没有提前处理资源的finally逻辑。生成器可以关联一些资源释放需求比如关闭文件、断开连接但普通for...of遍历到一半跳出循环时默认不会执行生成器内部的finally。需要手动调用gen.return()才会触发finally逻辑。幸好内置for...of规范里非正常退出如break、throw会自动调用迭代器的return()方法。所以如果生成器内部有try/finallyfinally是会被执行的。但如果你手动用next()遍历遍历到一半想中断就必须主动调gen.return()否则finally不会触发。4.2 常见问题速查表问题现象可能原因解决办法调用生成器函数但函数体没执行没调用next()生成器是惰性的必须主动推进for...of遍历普通对象报错普通对象没实现Symbol.iterator用for...in或给对象添加迭代器同一个迭代器第二次遍历为空迭代器已耗尽重新调用生成器函数创建新实例生成器里用了return valuevalue在done: true时才出现for...of会忽略它需要时用next().value获取yield写在不该写的地方报语法错误yield只能在生成器函数内部使用检查函数声明是否带function*嵌套数组拍平没有前后顺序递归yield*顺序写错把父节点的yield放在递归前面4.3 项目中最值得用生成器/迭代器的三个判断标准不是所有场景都适合用生成器。我在项目里总结出了三个判断标准。标准一你要描述的是“序列”而不是“集合”。如果数据是一次性全量存在的数组就够了如果你的数据是逐步产生的、可能有无限长的生成器的惰性求值才有优势。标准二遍历逻辑复杂到值得封装。比如对象内部多个字段混在一起遍历、树形结构需要递归拍平、跨多个数据源的聚合遍历。这些情况值得用自定义迭代器把规则收拢。标准三外部需要对推进过程进行控制。像异步分页、按需取数、断点恢复这种场景外部需要能控制“取到哪一步了”生成器恰好提供了这种暂停/恢复的语义。这三个标准都满足时用生成器的收益会非常明显不满足的话强行套用只会让代码更绕。5. 一点个人体会我最初觉得迭代器和生成器是“面试造火箭工作拧螺丝”的知识点后来真正在项目里用它解决了一个遍历逻辑多处重复的问题才明白这套设计有多妙。它本质上是一种控制反转你不再主动写循环去“拉”数据而是把数据源变成一个可暂停的流让调用方按自己的节奏去“取”。这种思维方式和async/await是一脉相承的。给想深入的朋友一个建议把这篇文章里的代码自己敲一遍尤其是那个异步调度器和递归扁平化的例子。代码这东西光看是学不会的你亲手把别人的代码敲出问题再来找答案记忆才会深。等你熟练了生成器的next、yield*、return、throw这几个关键动作回头看很多源码不管是老一点的 Promise 库还是各种轮子里的 lazy 求值逻辑都会有一种“原来如此”的感觉。