我现在还记得第一次在同事代码里看到Generator函数时的那种错愕感:函数还能“暂停”?JS的函数不是天生就该一口气跑到底、要么return要么throw吗?怎么还能像调试器一样停在半路,下次接着跑?后来真正用上它,才发现这玩意看起来冷门,实际上是把“执行过程”本身拆成了可控制的资源——你要它走一步它就走一步,要它歇着它就歇着,连异步代码都能被它“伪装”成同步的样子来写。
这篇就围绕Generator函数展开,聊清楚三个问题:它凭什么能暂停,暂停到底有什么用,以及哪些场景下你用了它会真香。适合已经写过一阵JS、被async/await惯坏了、但想知道底层发生了什么的人;也适合面试前想搞懂“Generator到底会不会被问、问的话该怎么说”的人。我用实际场景和代码说话,争取让你看完之后不光会背定义,还能在自己的项目里找到用它的一席之地。
1. Generator函数为什么会“暂停”——把执行过程切成一段一段的
1.1 函数体变成了“待消费的菜谱”,而不是“做好的菜”
Generator函数和普通函数最本质的区别,在于它压根不是一个“做完再返回”的函数。普通函数调用时,函数体从第一行一路执行到return,然后整个调用栈销毁,中间过程全部消失。Generator函数调用时,函数体基本没执行,它只是返回了一个迭代器对象,函数体变成了一份“菜谱”——每一步怎么做都在里面存着,但你不动它,它就永远不开始做菜。
这个特性叫惰性执行。你可以把Generator函数理解成一个带暂停按钮的流水线:yield就是流水线上的暂停点,每次调用next(),流水线往前走一段,直到碰到下一个yield或者函数结束。暂停的时候,函数内部的局部变量、作用域链、执行位置全都保留着,下次next()接着上次停下的地方继续跑,不会从头再来。
看个最基础的例子:
function* countdown() { console.log('开始倒计时'); yield 3; console.log('中间停顿了一下'); yield 2; yield 1; console.log('倒计时结束'); } const it = countdown(); console.log('函数调用完毕,但函数体都还没开始跑'); console.log(it.next()); // 输出:开始倒计时 // 输出:{ value: 3, done: false }注意这里的关键点:你调用countdown()时,控制台里"开始倒计时"根本不会打印,函数体根本没跑。第一次next()才执行到第一个yield,并且把yield后面的值返回出来。这就是“暂停”的实质:执行权被掰开了,每次next()交给你一小块。
为了更直观地理解,我把yield和return放在一起对比一下:
| 操作 | 值怎么给外面 | 执行状态 | 能执行几次 |
|---|---|---|---|
return value | 返回值 | 函数直接结束 | 只能一次 |
yield value | 返回值 | 函数暂停,等下一次next | 可以无数次 |
yield可以出现在循环里、条件判断里,甚至可以放在表达式的任意位置。它不只是“返回一个值”,还是个“双向通道”——外面不仅能从它这里拿值,还能往里传值,这部分后面讲。
1.2 next、yield、return、throw四件套,缺一不可
Generator能玩出花来,靠的不只是yield,而是迭代器协议加三个配套方法的组合。每个迭代器对象上都有next()、return()、throw()三个方法,行为完全不同,我一个一个说清楚。
next()是主力。每次调用,函数从上一个暂停点继续执行,直到下一个yield。next()的返回值固定在{ value, done }这个结构上,value是yield后面跟的值,done表示函数是否执行完毕。你写for...of循环遍历Generator的时候,其实是JS引擎在背后反复调用next(),直到done变成true。
return(value)是主动终止。调用它之后,Generator内部会走到finally(如果有的话),然后整个函数结束,之后不管你再调用多少次next(),返回值都是{ value: undefined, done: true }。return()一般在外部不需要继续遍历时用来释放资源。
throw(error)是从外部往函数内部“扔一个异常”。这个异常会在上一次暂停的yield那一行被抛出,函数内部的try/catch可以捕获它。这意味着,函数执行过程中的错误,可以由外部代码在任意时刻触发,这种反向控制能力在异步流程里非常有用。
还有一个小细节很少被人提起:next(arg)可以往函数内部传参。传入的参数会成为上一个yield表达式的值。
function* ask() { const name = yield '你叫什么名字?'; const age = yield '你多大了?'; return `${name}今年${age}岁`; } const it = ask(); it.next(); // 第一个next,跑到第一个yield停下 console.log(it.next('张三')); // 张三被赋给name,继续跑到第二个yield // { value: '你多大了?', done: false } console.log(it.next(18)); // { value: '张三今年18岁', done: true }第一次调用next()时不需要传参,因为此时还没有“上一个yield”。这个传参机制让Generator变成了一种双向通信的通道,外部可以影响内部流程。
2. Generator能解决什么实际问题——五个我真实用过的场景
2.1 惰性生成与无限序列:再也不怕“算不完的数组”了
常规做法是把数据一次性全算出来放到数组里,但如果这个序列是无限的、或者计算代价很高、或者数据量远超内存,这个做法直接失效。Generator的惰性特性在这里是降维打击。
比如经典的斐波那契数列:
function* fibonacci() { let [prev, curr] = [0, 1]; while (true) { yield curr; [prev, curr] = [curr, prev + curr]; } } 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注意while (true),如果是普通函数这么写,直接栈溢出或者浏览器卡死。但Generator里它就是个无限序列,需要几个算几个。这个模式在分页加载的场景更实用——每次next()拉一页数据,而不是一次性把所有页都加载到内存。
我之前做数据导出功能时就用过这种思路:数据库里有几十万条记录,不能一次性SELECT出来,我就封装了一个按游标分页的Generator,每次next()拉一批数据出来处理。代码长这样:
async function* fetchPages(cursor = null) { let hasMore = true; while (hasMore) { const { rows, nextCursor } = await db.query({ limit: 1000, cursor, }); if (!nextCursor) hasMore = false; cursor = nextCursor; yield rows; } } for await (const page of fetchPages()) { // 每页1000条,处理完自动拉下一页 await processPage(page); }for await...of是专门用来消费异步Generator的语法,每次迭代会等Promise resolve之后再进入循环体。这种“按需生产”的模式,让我彻底告别了“先全量查出来后内存爆掉”的尴尬。
2.2 状态机:用Generator写流程,代码自己会说话
状态机在业务代码里很常见:订单有草稿、待审核、审核通过、已发货、已完成;审批有提交、一级审批、二级审批、打款。常规写法用状态变量加switch,或者维护一张状态转移表,代码一旦复杂起来,阅读成本特别高。
Generator写状态机,思路完全不同。每个暂停点天然就是一个状态节点,yield一个状态值出去,外部根据这个值决定下一步做什么,然后通过next(result)把结果传回来,驱动状态迁移。整段流程读起来就像一条流水线,逻辑顺序就是代码顺序,可读性爆炸性提高:
function* approvalFlow() { const submitted = yield '待提交'; if (!submitted) return '流程取消'; const reviewed = yield '待一级审批'; if (!reviewed) return '一级审批驳回'; const finalReviewed = yield '待二级审批'; if (!finalReviewed) return '二级审批驳回'; yield '打款中'; return '已完成'; }外部驱动代码只管根据状态发指令:
async function runApproval() { const it = approvalFlow(); let result = it.next(); while (!result.done) { const action = await askUser(`当前状态:${result.value},通过吗?`); result = it.next(action); } console.log('最终结果:', result.value); }这套写法的本质是把状态机的状态转移过程嵌到了函数执行流程里,而不是把状态变量放在外部到处传。我做过一次重构,把一个200多行的switch-case状态机换成Generator版,代码量降了大概一半,关键是新增状态时不需要再去改状态转移表,直接在函数里加一段就行。
2.3 自定义数据结构遍历:让对象也能被for...of消费
自定义对象如果实现了Symbol.iterator方法,就能被for...of、数组解构、展开运算符这些语法直接消费。用Generator来实现这个接口,比手写迭代器简洁得多。
比如你有一个树状结构,想把它拍平成先序遍历:
const tree = { value: 'root', children: [ { value: 'a', children: [{ value: 'a1', children: [] }] }, { value: 'b', children: [] }, ], }; function* preorder(node) { yield node.value; for (const child of node.children) { yield* preorder(child); } } console.log([...preorder(tree)]); // ['root', 'a', 'a1', 'b']yield*是“委托给另一个Generator”的语法,它会展开另一个Generator的所有产出。这种递归加委托的组合,让“自定义遍历”从一件重活变成了一件写配置的事。还有嵌套的扁平化,一样可以优雅实现。
2.4 异步流程控制:Promise自执行器,await的“前传”
在async/await普及之前,Generator是社区里解决“回调地狱”的主流方案之一。思路是:Generator函数内部用yield暂停,yield后面挂一个Promise,然后用一个自执行器(runner)递归调用next(),等Promise resolve后再把结果传回yield表达式所在的位置。代码写起来像同步一样:
function run(gen) { const it = gen(); function step(arg) { const result = it.next(arg); if (result.done) return result.value; // yield出来的东西可能不是Promise,统一包一层 return Promise.resolve(result.value).then( (val) => step(val), (err) => it.throw(err) // 把异步错误抛回Generator内部 ); } return Promise.resolve(step()); } function* main() { const user = yield fetch('/api/user'); const orders = yield fetch(`/api/orders?uid=${user.id}`); console.log(orders); } run(main());这段代码其实就是co库和后来async/await标准化的核心原型。你看async版本的写法:
async function main() { const user = await fetch('/api/user'); const orders = await fetch(`/api/orders?uid=${user.id}`); }async/await就是语言层面帮你把自执行器写好了,和Generator+runner的方案在语义上几乎一一对应。所以你现在学Generator,完全不是为了替代await——而是为了有一天当你需要“自定义执行节奏”的时候,知道底层那块砖是长什么样的。
2.5 可暂停可恢复的任务队列:断点续跑的实现利器
有些任务执行时间长,中途可能被用户取消,或者系统需要让它暂停再恢复。这些需求用普通函数很难做,因为你没法把一个正在执行的函数“挂着”不动。Generator却天然支持这种场景。
举个例子,度假山庄的爬虫任务要抓1000个页面,用户点了暂停,系统需要保存当前进度,过几分钟再继续。用Generator可以这样写:
const tasks = [...Array(1000).keys()]; function* taskQueue() { for (let i = 0; i < tasks.length; i++) { yield fetch(`/page/${tasks[i]}`).then(res => res.text()); } } const queue = taskQueue(); let current; let isPaused = false; async function runQueue() { while (!current?.done && !isPaused) { current = queue.next(); if (!current.done) { await saveToDisk(current.value); } } } // 暂停时 isPaused = true,函数退出循环,但queue对象还在 // 恢复时 isPaused = false,再次runQueue(),从上次暂停的位置继续这里的关键在于:Generator迭代器对象只要不被垃圾回收,它的内部状态就一直保留着,你随时可以接着调用next()继续推进。任务队列的进度本质上就是“上一次执行到了哪个yield”,这个信息由运行时天然保存了,不用你自己手动记录。这种能力在批量导入、分片上传、长列表渲染等场景都很好使。
3. 避坑指南与性能细节——那些文档里不会写的教训
3.1 不要用Generator做高频小循环
Generator虽然方便,但不是免费的。每次next()调用都有函数调用开销,还有迭代器对象的创建和状态维护。你要是拿它做for (let i = 0; i < 1000000; i++)替代方案,性能会明显拉胯。
我用一个简单的基准测过一次:直接for循环遍历1e6个数,耗时大约是个位数毫秒;用Generator一个个next()拉1e6个数,耗时是几十毫秒级别。看起来“也就几十毫秒”,但如果在渲染循环或者高频请求里放大,差距就不可忽视了。结论是:Generator适合中低频、逻辑复杂、需要控制的场景,不适合做细粒度的计算核心。
3.2 无限循环没有终止条件,内存会静默泄漏
Generator惰性执行是优点,也是隐患。如果一个Generator函数内部有while(true),而外部只消费了一部分就再也不调next()了,那这个迭代器对象会一直持有闭包里的变量,GC无法回收。
我在分页加载的代码里踩过这个坑:封装了一个无限分页的Generator,某次边界条件没处理对,导致游标一直停留在某个状态,接口永远返回同一页数据,循环体里明明已经不需要新数据了,但迭代器还在被引用,内存一直在涨。排查了半天,最后发现不是后端的问题,而是我没在大循环结束时正确调用return()。
正确做法是在确认不再需要时主动终止:
const it = fetchPages(); for (const page of it) { if (needEarlyStop) { it.return(); // 主动结束,释放内部状态 break; } }如果Generator内部注册了finally,return()还会触发清理逻辑,比直接不管好得多。
3.3 throw和try/finally连用,资源才能稳妥释放
Generator内部的try/finally配合外部return()或throw(),是一对黄金搭档。比如你需要在任务结束时释放数据库连接、清空定时器,代码可以这样写:
function* task() { const conn = await getDbConnection(); try { yield runTask(conn); } finally { await conn.close(); } }外部无论正常遍历完,还是中途调it.return(),或者调it.throw(new Error(...)),finally里的conn.close()都会执行。这个特性保证了资源在多条退出路径下都不会泄漏,是写健壮代码的硬核基础。
3.4 next传参时,第一个next传了等于白传
新手最容易犯的错就是第一次调用next(arg)传参数,以为arg会变成第一个yield表达式的值。但正如前面说的,第一次next()只是把函数体从开始跑到第一个yield,此时根本不存在“上一个yield”,所以传入的参数直接被忽略了。要传参,从第二次next()开始才有意义。
我见过有人调试半天,以为Generator坏了,其实是把这茬忘了。这个细节在面试里也常被拿来考人,记住就不会翻车。
3.5 不能对Generator使用new调用
Generator函数是一个特殊的函数对象,你不能用new GeneratorFunction()的方式创建实例。如果你尝试new countdown(),会直接抛TypeError: countdown is not a constructor。这个跟箭头函数有点像——它的内部实现了[[Construct]]逻辑,但显示说“不是构造器”,其实是因为Generator函数的构造语义不一样,你只需要记住它不是一个常规构造函数就够了。
4. 异步场景详解:从Generator到async/await,就差一个执行器
4.1 异步Generator与for await...of:异步数据流也能按需生产
前面提到了async function*,它和普通Generator的区别是yield后面跟的是一个Promise(或者任意值),每次next()返回的value是一个Promise。消费它要配套for await...of。
这个模式在流式处理里相当实用,比如接口是分批返回数据的,或者你要处理一个大文件的内容:
async function* readLines(file) { const stream = file.createReadStream(); let buffer = ''; for await (const chunk of stream) { buffer += chunk; let lineIndex; while ((lineIndex = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, lineIndex); buffer = buffer.slice(lineIndex + 1); yield line; } } if (buffer.length) { yield buffer; } } for await (const line of readLines(largeFile)) { console.log(line); }这段代码把“逐行读取”封装成了一个异步迭代器,调用方不需要关心文件流、缓冲、边界处理这些细节。整个文件从头到尾只占用一行的内存,而不是一次性全读进内存。
4.2 手写一个精简版co,读懂自执行器的灵魂
既然前面讲了异步自执行器的雏形,这里我把代码再做一个增强版本,让它支持yield一个包含多个Promise的数组,模拟Promise.all的效果:
function run(gen) { const it = gen(); function handle(result) { if (result.done) return Promise.resolve(result.value); const value = result.value; // 如果是数组,并发处理再一起传回去 if (Array.isArray(value)) { return Promise.all(value).then( (results) => handle(it.next(results)), (err) => handle(it.throw(err)) ); } return Promise.resolve(value).then( (res) => handle(it.next(res)), (err) => handle(it.throw(err)) ); } return handle(it.next()); } function* demo() { const [a, b] = yield [ Promise.resolve(1), Promise.resolve(2), ]; const c = yield Promise.resolve(a + b + 1); return c; } run(demo()).then(console.log); // 4这个handle函数是整个自执行器的核心,它做三件事:把next()的结果拿出来,如果done就直接返回;把yield出来的值包成Promise;等Promise结果出来后再调用it.next(res),把值传回Generator内部,循环往复。异常路径用it.throw(err)把错误抛回给Generator内部的try/catch,保证错误处理逻辑能写在同步代码里。
4.3 Generator和async/await,各自适合什么时候用
async/await本质上就是Generator+自执行器的语法糖——它的执行器被内置在引擎里,省去了你手动写runner的麻烦。既然有await了,为什么还要学Generator?我的答案很实际:
- 你需要“暂停但不需要立刻继续”,执行权完全掌握在自己手里
- 你需要手动控制“走几步、停多久、往函数里喂什么值”
- 你需要保存一个长任务的执行进度,跨函数、跨事件循环恢复它
这些场景下,await会直接帮你把流程执行完,根本不给你“中途退出”的机会。而Generator把执行权交给了你,这是它相对await的独特价值。
5. 实际调试经验与常见问题速查表
5.1 调试技巧:把Generator“拆开”看它每一步
调试Generator最直观的方式,就是不要用for...of一把梭,而是手动调用next()一点点执行,观察每一次返回的value和done。我先开浏览器控制台,直接实例化一个Generator对象,手动敲两三次next(),逐步确认状态流转是否符合预期。
日常开发还可以在yield前后加日志,打印当前执行到的位置。不过程序化Too much时,我一般用断点调试:在返回的迭代器对象上直接断点,观察next()的返回值,效果也很好。因为Generator的执行位置是保存在迭代器内部的,所以调试时你会看到调用栈中多了一个generator的执行帧,确认它真的停在那里了。
5.2 六个高频问题,一次说清
| 问题 | 可能原因 | 排查思路 | 解决办法 |
|---|---|---|---|
| 函数体没有执行,控制台没有日志 | 忘了调用next() | 确认是否已经调用第一个next() | 每调用一次next()函数体才向前走一步 |
第一个next()传了参数但拿不到 | 参数被忽略 | 了解next传参规则 | 第一次传参无效,第二次开始才有效 |
| 无限循环导致页面卡死 | while(true)被for...of或展开运算符直接消费 | 是否用[...gen()]、Array.from(gen()) | 用next()手动控制终止条件,不要直接全量消费无限序列 |
| 异步错误没有捕获 | 自执行器没处理throw路径 | 检查step函数是否有reject分支 | 使用it.throw(err)把错误抛回内部,配合try/catch |
| 内存持续增长 | 迭代器对象被外部长期引用,内部状态无法释放 | 检查是否调用了return() | 提前return(),或者用finally清理资源 |
yield*嵌套太深,可读性变差 | 递归委托过多 | 查看代码结构是否清晰 | 考虑拆分成多个小型Generator组合使用 |
5.3 组合多个Generator:yield*的正确打开方式
yield*在组合多个Generator时非常顺手,它让流程像搭积木一样组合。但它也容易让人滥用,嵌套太深后代码就变难看。我的原则是:单个Generator尽量控制在十几个yield以内,如果超过,就拆成两三个小Generator,再用yield*组合。每个Generator只负责一段清晰的流程,组合出来的代码反而更好读。
function* stepA() { yield 'A1'; yield 'A2'; } function* stepB() { yield 'B1'; } function* pipeline() { yield* stepA(); yield* stepB(); } console.log([...pipeline()]); // ['A1', 'A2', 'B1']这种组合方式在流程编排类业务里特别适合,每个子流程还能单独测试,比一个大流程好维护得多。
6. 我对Generator的最终体会
真要评价Generator这个特性,我觉得它最大的价值不是让你少写几行代码,而是改变了你组织代码的思维方式:把“执行过程”当成一种可以随时暂停、恢复、传递的值。平时确实大部分场景用async/await就够了,但遇到任务调度、状态机、按需生产数据、断点续跑这些需求时,Generator是唯一顺手的选择。
最后分享一个小技巧:如果你发现自己在一个项目里频繁写“暂停-等信号-继续”这种逻辑,不妨试着抽象一个任务调度器,把Generator作为任务本体,用一个统一的runner来管理它的启停。这套模式我复用了好多次,每次都是又稳又省心。
如果你此前一直没用过Generator,建议从今天这段代码开始动手:
function* idGenerator() { let id = 0; while (true) { yield ++id; if (id >= Number.MAX_SAFE_INTEGER) break; } } const gen = idGenerator(); console.log(gen.next().value); // 1跑通这一个,然后想想你的业务里哪个流程天然适合“走一步歇一步”,那里就是Generator的用武之地。