拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Generator函数:JavaScript执行流程的暂停与恢复术

我现在还记得第一次在同事代码里看到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就是语言层面帮你把自执行器写好了和Generatorrunner的方案在语义上几乎一一对应。所以你现在学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内部注册了finallyreturn()还会触发清理逻辑比直接不管好得多。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 组合多个Generatoryield*的正确打开方式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的用武之地。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门