
先聊一个真实的场景。上礼拜帮同事做 Code Review他看到一段三层嵌套的接口请求直接摇头说“又见回调地狱”。后来他改成 async/await 版本代码从二十多行缩到七八行逻辑一眼能看懂。但当我们讨论 async/await 为什么能“让异步变同步”时会议室里沉默了十几秒。说实话很多人会用 async/await但没搞懂它背后的 Generator 到底干了什么活。这篇文章就想把这两块内容彻底讲透从 Generator 的暂停机制到 async 函数的自动执行器再到它们之间如何配合顺便聊一聊我平时踩过的坑以及到底什么场景该用谁、不该用谁。如果你写 JS 已经有一段时间面试时被问过“Generator 和 async 有什么区别”或者你在读一些框架源码时看到function*这种写法就头皮发麻那这篇文章就是给你准备的。我会尽量用大白话拆解原理再配可运行的代码案例保证你能看懂、能复现、能举一反三。1. 从“回调地狱”到 async/await异步方案的演进之路1.1 为什么总说回调地狱在 Promise 普及之前JS 里处理异步的标准姿势是回调函数。比如要依次请求三个接口先拿用户信息再拿用户订单最后拿订单详情。代码写出来往往是这样的getUser(function(user) { getOrders(user.id, function(orders) { getOrderDetail(orders[0].id, function(detail) { console.log(订单详情, detail); }); }); });这三层嵌套看着还行但真实业务里经常是五六层起步中间还要穿插错误处理、超时判断、参数修正。我有一次维护老项目看到过一个七层嵌套的回调函数缩进直接把代码挤到了屏幕边缘最里头那行根本没法看。更麻烦的是错误处理在每个层级都要单独写一遍漏一层就可能导致异常被静默吞掉。回调地狱的核心问题不是“丑”而是它破坏了代码的线性阅读方式。人的大脑习惯从上往下逐行理解逻辑而回调式写法逼着你在大脑里维护一个“执行栈”每次看到嵌套就得切换到“等前面完了再执行这里”的思维模式认知负担特别重。这也是后来 Promise、Generator、async/await 一步步演进的根本驱动力——不是创造新功能而是把异步代码的书写方式拉回人类习惯的线性思维。1.2 Promise 带来的改进与局限Promise 的出现在当时算是巨大进步它把嵌套结构拍平成了链式调用。上面那个例子用 Promise 重写是这样getUser() .then(user getOrders(user.id)) .then(orders getOrderDetail(orders[0].id)) .then(detail { console.log(订单详情, detail); }) .catch(err { console.error(出错了, err); });这段代码比回调版本好看很多每个.then代表一个阶段错误也能统一汇聚到.catch不会再出现“错误处理散落在各个回调里”的情况。尤其配合 Promise.all、Promise.race 这类组合方法处理并发和竞态也顺手很多。但 Promise 还是有个隐性缺点它只是把回调从“横向嵌套”变成了“纵向链式”本质上你还是在一长串链上挂函数代码的组织方式依旧是在“处理回调”而不是在“写逻辑”。当业务分支变多比如“如果用户是 VIP 就跳过某一步否则走另一条链路”Promise 链很快就会长出一堆 if/else可读性又会下降。而且代码依然没有完全贴近自然语言读起来还是需要时刻记着“这是异步的”。我一直觉得Promise 是异步方案的“基础设施”它提供了可靠的底层能力但没给出最舒服的上层语法。真正让异步代码“像同步一样写”的转折点是把 Generator 的暂停能力和自动执行器结合再到后来语言层面直接支持 async/await。下面我就从 Generator 讲起因为它是理解 async/await 底层机制绕不开的一环。2. Generator一个“可以暂停”的函数2.1 迭代器与生成器的关系学习 Generator 之前先建立一个整体认知Generator 函数是 ES6 引入的特殊函数语法上在function关键字后面加一个星号函数体内部用yield来“暂停”执行。它最大的特点就是可以被手动控制执行节奏你可以让它走一步停一步想什么时候继续就什么时候继续。这里需要先提一下迭代器Iterator的概念。迭代器是一个对象它有一个next()方法每次调用next()都会返回一个{ value: 某个值, done: true/false }这样的结果对象其中done表示是否已经迭代完。数组、字符串、Map、Set 这些数据结构默认都是可迭代的因为它们内部都挂了[Symbol.iterator]工厂方法。Generator 和迭代器的关系可以这样理解Generator 是制造迭代器的工厂你调用 Generator 函数并不会立即执行函数体而是得到一个迭代器对象。然后你手动调用这个迭代器的next()方法函数体才开始执行直到遇到第一个yield暂停并把yield后面的值作为value返回。来看一个最基础的例子function* myGenerator() { yield 1; yield 2; yield 3; } const gen myGenerator(); 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 }注意执行过程myGenerator()被调用时函数体一行代码都没跑。第一次gen.next()才真正开始执行跑到第一个yield 1就停下把 1 返回出去第二次next()从上次暂停的位置继续跑到yield 2又停下以此类推。等所有yield都执行完再次调用next()时返回done: true说明迭代结束。我经常用一个比喻帮人理解Generator 就像一个带暂停键的播放器yield是暂停键next()是继续键。你按一下继续键它就播放到下一个暂停点然后等你再次按下。这种能力放在异步场景里意义重大因为你可以让函数执行到某个异步调用处“暂停”等异步结果回来后再“继续”往下走从开发者视角看代码是顺序展开的不用再嵌套回调。2.2 yield 表达式的传参机制很多初学者卡在 Generator 的一个点next()方法可以传参数这个参数会作为上一个 yield 表达式整体的结果。这句话听起来拗口我直接上例子你感受一下区别。先看不传参的情况function* demo() { const a yield 10; console.log(a 的值是, a); const b yield 20; console.log(b 的值是, b); } const it demo(); it.next(); // 第一次 next启动执行跑到 yield 10 暂停 it.next(100); // 第二次 next传入 100此时 a 的值就是 100运行结果会打印a 的值是 100。注意第二次next(100)传进去的 100 并不是给yield 10右边的东西而是作为整个yield 10表达式的返回值也就是赋给了a。第一次next()时因为没传参a的值是undefined。再来完整跑一遍function* demo() { const a yield 10; console.log(a:, a); const b yield 20; console.log(b:, b); return a b; } const it demo(); console.log(it.next()); // { value: 10, done: false } console.log(it.next(100)); // 打印 a: 100返回 { value: 20, done: false } console.log(it.next(200)); // 打印 b: 200返回 { value: 300, done: true }这里有一个非常关键的点yield 不是“单向输出”而是“双向交流”。它既能把函数内部的值抛给外部也能接收外部传入的值。这个双向机制是后面实现异步流程控制的基础——外部拿到 yield 出来的 Promise 后等它 resolve 了再把结果塞回 Generator 内部让逻辑继续走。我第一次接触这个机制时也觉得很别扭后来在纸上画了几遍执行流程才彻底明白。你可以这样记第一次next()只是“启动器和探路者”它不传参因为此时还没有上一个 yield之后的每次next(param)参数都会精准送到上一个 yield 那行替换掉整个表达式。2.3 用 Generator 手动实现异步流程控制有了双向传参机制理论上就能做一件大事让 Generator 暂停在某个异步操作上等异步完成后再由外部把结果送回来继续执行。这就是早期 co 库的核心原理也是 async/await 的雏形。我们先定义一个模拟异步请求的函数function fetchUser(id) { return new Promise((resolve, reject) { setTimeout(() { if (id 0) { reject(new Error(用户不存在)); } else { resolve({ id, name: 张三 }); } }, 300); }); }然后写一个 Generator 函数把异步流程写成“同步样子”function* loadUserFlow() { const user yield fetchUser(1); console.log(拿到用户, user); const detail yield fetchUser(user.id 1); console.log(拿到第二个用户, detail); return 流程结束; }注意这里yield后面跟的是一个 Promise 对象user变量将来会拿到 Promise 的 resolve 结果。但 Generator 本身没有能力自动等待 Promise它只会傻傻地把 Promise 抛给外部然后暂停。所以我们需要一个“外部调度器”手动不断地调用next()并且在拿到 Promise 后.then()等待它完成再把结果作为参数传回去。这个调度器也叫“自执行器”。下面写一个最精简版本的自执行器function run(generatorFn) { const it generatorFn(); function step(action, arg) { const result action(arg); if (result.done) { return Promise.resolve(result.value); } return Promise.resolve(result.value).then( value step(it.next, value), error step(it.throw, error) ); } return step(it.next); }这个调度器的逻辑不算复杂第一次调用step(it.next)启动 Generator如果返回结果没结束说明yield抛出来的是一个 Promise就用Promise.resolve(result.value).then(...)等它完成完成之后把值通过it.next(value)传回 Generator 内部让代码继续走如果 Promise 拒绝了就用it.throw(error)把异常抛回 Generator 内部这样 Generator 内就能用 try/catch 捕获错误。用刚才的loadUserFlow测试run(loadUserFlow) .then(result console.log(最终结果, result)) .catch(err console.error(出错, err.message));执行结果是先打印“拿到用户 { id: 1, name: 张三 }”再打印“拿到第二个用户 { id: 2, name: 张三 ”最后打印“最终结果 流程结束”。你看整个异步流程的代码是顺序展开的没有链式调用没有回调嵌套保存了天然的try/catch能力。这就是 Generator 解决异步问题的核心思路用暂停和恢复把异步代码改造成顺序结构。我在本地跑这个例子时还特意测过错误恢复路径。如果模拟接口返回一个 reject路由会走到it.throw(error)此时函数内部的 try/catch 就能捕获它。这个能力在当时已经算杀手级体验了co 库就是在这个自执行器基础上加了一堆并发支持和校验逻辑红极一时。后来语言层面吸收了这个思路直接内置了 async/await不用再自己写调度器了。2.4 Generator 做异步的局限那么问题来了既然 Generator 自执行器已经能实现“看上去同步”的异步代码为什么还要 async/await答案是Generator 本身是通用的暂停机制它并没有绑定“异步”这个语义所以每次都要你手动写那个 run 自执行器或者引入第三方库用起来不够“开箱即用”。而且你必须在yield后面严格跟上可被识别的值比如 Promise 或 thunk否则调度器无法正确处理这就让使用姿势变得很苛刻。另外如果你是团队里唯一一个熟悉这个写法的人后期维护的人大概率会看得一头雾水因为他们需要先理解“yield 和 next 的双向传参”才能读懂代码。还有一个体验问题Generator 的函数名以*标识函数体里的yield看起来更像“产出值”而不是“等待异步完成”语义不够明确。你写yield fetchUser(1)时不熟悉这套机制的人会以为只是简单把 Promise 抛出根本想不到它暗示着“外部要等待这个 Promise”。相较之下await这个词天生就带着“等待”的含义理解成本低很多。所以语言规范委员会做的下一步就是把“Generator 自动执行器”这个组合吸收为语言原生支持并命名为 async/await。下面这一部分咱们好好拆一拆这个“语法糖”到底有多甜以及它和 Generator 之间到底差在哪。3. async/awaitGenerator 的“语法糖”升级版3.1 async 函数与 await 的本质先说结论async/await 本质上就是 Generator 自动执行器只是把function*换成了async function把yield换成了await并且语言底层内置了执行器不再需要你自研 run 函数。你可能觉得这个说法有点抽象但理解之后你能更从容地处理很多边界情况。先看基础用法。给函数前面加上async关键字这个函数就变成了 async 函数它有以下几个重要特性async 函数总是返回一个 Promise 对象。哪怕你写async function foo() { return 1; }函数实际返回的是一个已经 resolve 为 1 的 Promise而不是数字 1。async 函数内部可以使用await关键字。await后面通常跟一个 Promise代码会暂停执行等 Promise settle 之后再继续往下走。await后面跟普通值时会隐式把它包装成 Promise.resolve(普通值)所以写await 3也能正常工作只是没必要。拿一个最简单的对比// 之前写 Promise 链 function load() { return fetchUser(1).then(user { return fetchUser(user.id); }); } // 用 async/await async function load() { const user await fetchUser(1); const detail await fetchUser(user.id); return detail; }第二种写法的阅读体验非常接近同步代码先拿用户再拿用户的下一个用户最后返回结果。没有.then链不用手动 return Promise代码执行顺序和书写顺序完全一致。这个“一致性”就是我反复强调的价值点编码时的思维负担轻很多。这里顺便提一下async 函数在面试里经常有个追问async 函数内部如果 throw 了一个错误那函数返回的 Promise 会是什么状态答案是 rejected。因为 async 函数会自动把函数体的异常捕获并转化为 Promise 的拒绝状态所以你在调用 async 函数时一定要用 try/catch 或.catch()接收错误否则容易漏掉异常导致 Unhandled Promise Rejection。3.2 await 到底在等什么很多人对await的理解是“等 Promise 解析完”这个说法基本对但不够精确。我习惯把await拆成两层作用第一层暂停当前 async 函数。遇到await时函数会让出执行权调用方会立即拿到一个 Promise即 async 函数本身返回的那个 Promise状态为 pending。这个过程不会阻塞主线程因为它在等待的是异步操作。第二层等待右侧表达式的结果。如果右侧是 Promise就等到它 resolve 或 reject如果右侧是普通值就直接返回这个值。这个过程由 JS 引擎的事件循环机制驱动await 后面的代码会被注册为微任务等 Promise settle 后推入微任务队列执行。讲到这里肯定要谈一谈事件循环。因为“async 函数到底是先执行哪部分后执行哪部分”这类问题本质上就是在问微任务和宏任务的排队规则。我举个经典例子console.log(start); async function test() { console.log(async start); await Promise.resolve(); console.log(async end); } test(); console.log(end);输出顺序是start、async start、end、async end。为什么async end在end后面因为在执行await时后面的代码被放进了微任务队列而同步代码比如console.log(end)会先执行完然后事件循环才处理微任务队列然后才打印async end。这个机制理解清楚之后你就能明白 async/await 并不是“瞬间等完”而是“让出控制权再恢复”。还有一点值得注意await只能用在 async 函数里除非你的环境支持顶层 await比如某些现代浏览器和 Node.js ESM 模块。如果你在普通函数里写await立刻会报语法错误。原因也很简单普通函数没有自动执行器的支持引擎不知道如何帮你做暂停恢复。3.3 错误处理与并发选择async/await 让代码变“同步”了但也容易带来两个新问题错误处理漏掉和串行化过度。先讲错误处理。因为 async 函数内部可以用 try/catch 捕获同步异常和 await 的 Promise 拒绝所以一个相对稳妥的写法是async function fetchData() { try { const user await fetchUser(1); const detail await fetchUser(user.id); return detail; } catch (err) { console.error(流程失败, err); throw err; // 需要继续向上抛就抛 } }但如果你在业务代码里到处都是 try/catch代码会显得很冗余。这个时候可以结合.catch()方法既然是 Promise就允许用链式语法。不过我个人建议关键流程尽量用 try/catch边界情况优先在 async 函数内处理不要依赖外部调用者一定能接住错误。再讲并发选择。很多人第一次从 Promise 链改成 async/await 后容易犯一个错误把本来可以并发的请求写成了串行。看下面的例子async function loadAll() { const user await fetchUser(1); const posts await fetchPosts(1); // user 和 posts 没有相互依赖但被串行等待了 }这里fetchUser和fetchPosts相互独立完全可以用Promise.all并发发起async function loadAll() { const [user, posts] await Promise.all([ fetchUser(1), fetchPosts(1) ]); // 此时 user 和 posts 已经拿到了两个请求是并发发出的 }写 async/await 时一定要时刻提醒自己await 是“等待”语义它本身会让当前流程暂停所以如果多个任务没有依赖关系就别用多个 await 一个一个等否则整体耗时会变成所有请求耗时之和。这个优化点非常常见也是性能排查中经常遇到的一个问题。4. Generator 与 async 的对比什么时候该用谁4.1 核心差异对照表为了让大家一眼看清两者的区别我整理了一张对照表对比维度Generatorasync/await基本语法function*yieldasync functionawait返回值迭代器对象Promise 对象自动执行器无需要手动next()或自研 run 函数语言内置自动执行暂停恢复方式每次next()暂停/恢复await自动暂停/恢复是否与异步强绑定否可用来做惰性计算、状态机等场景是专为异步流程控制设计错误处理用 try/catch 配合it.throw()或手动传递原生 try/catch异常自动转化为 rejected Promise编写复杂度较高需要理解迭代器协议较低接近同步代码常见应用场景惰性求值、状态机、遍历生成大数据序列、异步自主控制绝大多数异步业务逻辑并发与流程编排从这张表能看出来async/await 几乎是 Generator 在异步方向上的“最终成品”舒适度最高。但 Generator 并没有被淘汰它在一些特殊场景里依旧不可替代下面展开说说。4.2 各自适合的场景分析先聊 async/await 的主场。日常业务开发里接口请求、数据库查询、文件读写、定时任务只要涉及异步流程控制优先用 async/await。它写起来自然调试的时候调用栈也比较直观团队协作时别人接手容易理解。尤其是现在 Node.js 生态里几乎所有异步 API 都支持 Promiseasync/await 已经成为服务端开发默认的异步方案。再聊 Generator 的非对称优势。我大概列几个实际场景第一个是惰性求值。Generator 的一个显著特点是“按需产出”它不会一次性把所有值算完。比如你需要生成一个很大范围的序列或者一个无限序列一次性生成全量数据会爆内存但用 Generator 可以做到边取边算。经典例子是斐波那契数列function* fibonacci() { let a 0; let b 1; while (true) { yield a; [a, b] [b, a b]; } } const fib fibonacci(); console.log(fib.next().value); // 0 console.log(fib.next().value); // 1 console.log(fib.next().value); // 1 console.log(fib.next().value); // 2这个函数永远不会耗尽因为每次next()才计算下一个值。如果你想取前 10 个直接 for 循环十次即可不会多算一个值。这种“按需计算”的能力是 async/await 不具备的。第二个是状态机封装。用 Generator 实现一个有限状态机非常自然因为yield天然适合表达“从一个状态转移到下一个状态”。有些流程编排库、表单流程引擎、游戏状态控制会刻意用 Generator 来写因为每个yield都代表一个状态点语义清晰。第三个是深度控制自身执行时序。因为 Generator 不会自动执行完全由外部控制所以适合一些特殊需求比如手动实现中断、恢复、取消。但坦白说现在 async/await 生态里有 AbortController 等机制能处理大部分取消场景Generator 这部分的用武之地在缩小。第四个是编写自定义迭代器。如果你需要遍历某个复杂数据结构且遍历顺序和默认方式不同Generator 可以帮助你快速生成一个符合迭代协议的对象。比如遍历一棵树深度优先、广度优先、条件过滤都可以用生成器函数来写可读性和扩展性都很好。从我个人的经验看绝大多数前端开发者日常根本不需要自己定义 Generator 函数但阅读库源码或实现一些工具库时掌握它能让你少踩很多坑。像 Redux-Saga、co、Koa 1.x 等框架都用过 Generator 作为核心机制如果你不了解它的语法和运行原理看这些源码会非常吃力。4.3 “异步自主控制”场景Generator 还香的几个瞬间我想特别展开一个点当异步流程需要“用户手动控制节奏”时async/await 反而不如 Generator 灵活。什么意思呢举个例子。假设你在做一个批量任务系统任务队列里有 1000 个任务每个任务都是异步的。async/await 的典型做法是用 for 循环加并发限制整体一起跑。但如果业务要求“每处理一个任务都要人工确认才能继续下一个”比如审计系统里每个导出操作需要管理员点击“确认”那 async/await 就需要额外引入状态变量还要写复杂的暂停逻辑。这个时候 Generator 天然就合适function* auditFlow(tasks) { for (const task of tasks) { const confirmed yield task; // 等待外部确认 if (!confirmed) { yield 中断; // 如果拒绝就返回中断状态 return; } } }外部代码拿到 Generator 后每次“确认”按钮点击时调用一次next(true)每次“拒绝”时调用next(false)或者generator.return()。整个过程根本不需要维护额外的“当前是不是暂停状态”变量Generator 本身就把状态保存好了。还有一个场景是表单分步提交。你做多步骤表单第一步填写资料、第二步验证手机号、第三步上传文件每步之间可能有异步校验。用 Generator 管理时每个yield就是一个步骤的边界步与步之间的数据传递也能通过next(data)实现。这比硬写一堆状态布尔值清爽很多而且代码的可测试性也高。说到底Generator 的哲学是“把控制权交给外部”async/await 的哲学是“语言帮你把控制权管好”。在常规业务中后者更舒服在特殊流程控制里前者更灵活。这也是为什么两者会长期共存而不是一方完全取代另一方。5. 实操案例从零封装一个异步控制工具5.1 需求分析与方案设计说了这么多理论该上手练一练了。我设计一个比较有代表性的小项目封装一个异步任务调度器支持串行执行、并发控制、失败重试。你可以在日常项目里直接改造使用。先明确需求点支持传入一个任务列表每个任务是一个返回 Promise 的函数。支持最大并发数limit同一时刻最多执行limit个任务多余任务排队等待。支持失败重试单个任务最多重试maxRetry次。返回一个 Promiseresolve 时拿到所有任务的执行结果数组reject 时捕获到第一个失败任务的重试耗尽错误。这个需求可以说是业务开发的常客批量上传文件、批量拉取数据、爬虫抓取页面、批量发送通知都会遇到类似的调度器。我见过很多人临时用Promise.all一把梭结果同时发起几百个请求直接把服务器打挂。所以一个有节制的并发调度器是很有价值的。设计上我打算先用 Generator 的思维来搭建框架展示主动暂停恢复的灵活性然后再用 async/await 改写成更简洁的版本。两种实现对比着看你就能更直观地感受到 Generator 和 async 在实际编码中的差异。最后再强调一遍这里真正推荐落地的版本是 async/await 那版Generator 版更多是为了理解原理。5.2 用 Generator 思维实现调度器如果坚持用 Generator 来做调度器的核心思路是把每个任务包装成一个“可暂停的流程”用yield来在需要等待并发名额时暂停在拿到名额时继续。但这里有个问题Generator 本身不负责并发调度它只负责“某一个任务”的暂停和恢复。所以更合理的做法是把调度逻辑写在外部循环里每个任务还是用 Promise 包装然后通过一个通用的 run 函数来执行 Generator 流程。不过这样会让代码分两层理解起来反而绕。我换一个更贴近 Generator 思想的方案写一个runTask函数它接收一个任务函数返回一个 Promise再写一个跑批的入口函数内部用 Generator 处理任务的“排队”和“执行”。简化版代码如下function runTask(task, maxRetry) { return new Promise((resolve, reject) { let retry 0; function attempt() { Promise.resolve() .then(() task()) .then(resolve) .catch(err { retry; if (retry maxRetry) { attempt(); } else { reject(err); } }); } attempt(); }); } function* scheduler(tasks, limit, maxRetry) { const results []; const executing new Set(); for (const task of tasks) { const p runTask(task, maxRetry).then(result { results.push(result); executing.delete(p); }); executing.add(p); if (executing.size limit) { yield executing; // 当并发数达到上限暂停并把当前执行集合交给外部 } } while (executing.size 0) { yield executing; } return results; }然后写一个调用器来驱动这个 Generatorfunction executeScheduler(tasks, limit, maxRetry 1) { const it scheduler(tasks, limit, maxRetry); return new Promise((resolve, reject) { function step() { const result it.next(); if (result.done) { resolve(result.value); return; } // 这里 result.value 是当前正在执行的任务集合等其中任何一个完成就继续 Promise.race(result.value).then(() step(), reject); } step(); }); }逻辑说明当并发队列满时yield会把当前的“执行中集合”抛出来外部用Promise.race等待任意一个任务完成一旦有任务完成步骤继续循环里会添加下一个新任务如果所有任务都已经提交了最后用一个while循环等所有剩余任务完成。这个方案能工作但说实话它写起来有点绕容易出错。比如executing.delete(p)删除的是当前 promise 实例但外部Promise.race拿到的也是这个集合需要保证引用一致。我在测试过程中就遇到过一个问题因为Set里存的 Promise 和Promise.race监听的 Promise 不同步导致调度提前推进后来调了半天才意识到要在 then 回调里删掉对应的 promise而不是删掉封装后的结果。这个排错过程其实很有价值算是 Generator 控制异步流程时一个典型的“手动管理状态”问题。5.3 直接用 async/await 实现更优雅版本接下来看 async/await 版本对比之下你立刻能感受到“语言内置执行器”有多香。核心思想是维护一个activeCount计数器和任务队列通过递归或者 while 循环来控制并发代码短且直观。async function runTask(task, maxRetry) { let retry 0; while (true) { try { return await task(); } catch (err) { retry; if (retry maxRetry) { throw err; } } } } async function executeTasks(tasks, limit, maxRetry 1) { const results new Array(tasks.length); let currentIndex 0; let activeCount 0; return new Promise((resolve, reject) { function dispatch() { while (activeCount limit currentIndex tasks.length) { const index currentIndex; activeCount; runTask(tasks[index], maxRetry) .then(value { results[index] value; activeCount--; dispatch(); }) .catch(err { reject(err); }); } if (activeCount 0 currentIndex tasks.length) { resolve(results); } } dispatch(); }); }这段代码的逻辑非常线性dispatch函数不断从任务列表取任务启动每次启动一个任务activeCount加一当任务完成时activeCount减一并再次调用dispatch补位当没有任何任务在跑且任务列表已取完就 resolve 最终结果。任务结果的写入通过index精确对应保证最终结果数组的顺序和传入的任务列表一致这一点对“按顺序拿结果”很关键。用的时候也很简单const tasks [ () fetchUser(1), () fetchUser(2), () fetchUser(3), () fetchUser(4), () fetchUser(5) ]; executeTasks(tasks, 2, 2) .then(results console.log(全部成功, results)) .catch(err console.error(有任务失败, err.message));我实际跑过这个例子并发数限制为 2 时任务不是一次性全部发出去而是两两并行整体耗时大约是串行的三分之一到二分之一取决于任务耗时。加上重试机制后如果某个任务第一次失败会在内部循环中自动重试直到超过maxRetry才真正 reject。这个调度器我一直在内部工具链里用稳定性还可以。5.4 两种实现的对比复盘对比这两种实现我的体感非常明显Generator 版的优势在于概念上把“调度器”和“被暂停的流程”分开了你可以通过yield手动控制何时让出执行权这对理解异步流程控制原理特别有帮助。但它写起来很别扭状态管理分散在外部调用方出错时排查链路也长。如果你只是要实现并发控制Generator 版明显“杀鸡用牛刀”不划算。async/await 版则把暂停、恢复、异常传递全部交给引擎代码可以专注于业务逻辑本身。它更短、更易读、更容易测试而且出错时 try/catch 包裹得也很自然。除非你有特殊需求比如要暴露一个外部句柄来随时暂停整个调度器否则我推荐都用 async/await 来写这类工具。很多人可能会疑惑既然 async/await 这么强为什么还要学 Generator我的回答是因为你需要理解 async/await 是怎么被设计出来的它有哪些边界和局限。你只有先懂 Generator 的迭代器协议和双向传参机制才能真正理解await背后那套“暂停执行、等待异步、恢复执行”的模型也才能在遇到奇怪行为时快速定位问题。6. 常见问题与排查技巧实录6.1 经典报错与误区速查表写异步代码总免不了踩坑我把实际开发中高频出现的几个问题和排查方法列成一个表方便你以后直接查。问题现象根本原因排查方法await is only valid in async function语法错误在普通函数里写了 await确认当前函数外层有没有 async 关键字或者换个写法async 函数内部抛出异常没有被 try/catch 捕获把同步异常当普通异常但 async 函数返回的是 Promise调用 async 函数时用.catch()或在外层 await 后 try/catchPromise.all其中一个失败整体直接失败且无法拿到其他成功结果Promise.all 是“全有或全无”语义改用Promise.allSettled()获取每个任务的状态和结果forEach 里使用 await发现并没有等待forEach 不会等待 async 返回的 Promise改用 for...of 或者 Promise.allSettled 方式处理await后续代码执行顺序不符合预期没有理解微任务和宏任务队列回顾事件循环确认await之后是注册微任务Generator 中next()传参始终拿不到预期值第一次next()不传参数后续传参传给的是上一个 yield画执行流程图确认每次 next 对应的 yield 位置并发限制场景中并发数没有生效任务一股脑全发了循环中直接调用多次 async 函数没有做并发计数用任务队列加计数器调度或直接用并发工具库使用重试机制时重试次数比预期多一次循环里 retry 初始值为 0条件是retry maxRetry把条件改成retry maxRetry或调整初始值并加日志这张表里的前几条是新手高频问题后面几条是我自己经常在项目里遇到的实际坑尤其是 forEach 里用 await 这个几乎所有刚上手 async 的人都会踩一次后面细说。6.2 深入排查forEach 里 async 失效的真相这个坑值得单独拿出来说。看下面的代码async function processList(items) { items.forEach(async (item) { const result await fetchData(item); console.log(result); }); }你可能会以为 forEach 会按顺序等待每次await执行完再进入下一轮但实际上 forEach 根本不会等待 async 回调返回的 Promise。它只是把所有回调都同步调用了一遍然后立刻返回。也就是说上面代码里的接口请求实际上是并发发出的这既能造成“请求顺序混乱”也会导致你在所有请求完成前无法统一处理最终结果。正确的做法有三种第一种用 for...of 循环配合 await 实现串行async function processList(items) { for (const item of items) { const result await fetchData(item); console.log(result); } }第二种如果需要并发执行但保持顺序收集结果用Promise.all或者Promise.allSettledasync function processList(items) { const results await Promise.all(items.map(item fetchData(item))); console.log(results); }第三种如果希望限制并发数量就用前面实现的那个executeTasks函数。这个坑我第一次踩的时候排查了半天才意识到是 forEach 的问题。后来就在团队里加了条规则所有涉及异步的数组遍历禁止直接用 forEach统一用 for...of 或 Promise.all 系列。6.3 Generator 状态错乱与调试技巧用 Generator 控制异步流程时最头疼的问题就是“状态错乱”。什么叫状态错乱就是你发现yield后面的变量值突然不对了或者执行顺序跳过了某段逻辑。我印象很深的一次经历是我用 Generator 写一个带“取消”功能的轮询流程。Generator 每次yield出一个定时器外部在超时后调用next()继续。结果我发现在切换浏览器标签页或者用户快速点击“取消”再“开始”时Generator 内部状态没有正确重置导致两个轮询流程互相干扰数据源都乱了。后来排查主要有两个难点第一个是 Generator 的变量状态保存在迭代器内部外部不好直接观察。解决办法是在关键yield前后打日志把next()传入的参数和执行到的分支都打印出来对照执行顺序表判断哪里多了一步或少了一步。第二个是“取消”的语义没有标准化。Generator 的return()方法可以在任意yield处强制终止迭代但如果你在外部还保存着对这个迭代器的引用仍然可以继续调用next()此时函数已经停止返回done: true但如果你没有用返回值判断就可能继续向外部提交任务造成“幽灵任务”。针对这种情况我在每次next()后都检查done并且用一个标志位标记“是否已取消”双重保险。调试 Generator 异步流程时我还会用一个小技巧在不影响业务逻辑的前提下把yield出来的 Promise 包装一下加一行日志再返回。这样每次进入暂停点时都能看到当前抛出的值和外部即将等待的东西排查效率高很多。等到稳定后再把日志移除。6.4 async/await 性能误区和优化建议最后聊几个关于 async/await 的性能误区。很多人觉得 async/await 比 Promise 链慢因为它有额外的生成器和自动执行器开销。这个说法在绝大多数业务场景下不成立。async/await 的开销主要来自 Promise 包装和微任务调度但这些开销比起一次真实接口请求、数据库查询或者 IO 操作完全可以忽略不计。你更应该关注的是有没有把应该并发的请求串行化导致整体耗时翻倍。比如下面这段“伪串联”代码就是性能问题的常见来源const user await fetchUser(id); const orders await fetchOrders(id); const coupons await fetchCoupons(id);如果三个接口互不依赖却用了三个await一个接一个等总耗时就是三者之和。优化成Promise.all后总耗时约等于最慢的那个请求。如果接口之间确实有依赖比如第二个请求需要第一个请求的结果作为参数那就保持await串行这是合理的。这里的关键是不要盲目把所有await都改成Promise.all也不要盲目保持await串行要根据依赖关系做决策。另外一个小技巧是如果并发数量很大而 Promise.all 容易把内存打满可以考虑用前面实现的并发调度器配合分片处理。先分成几个批次每批并发度控制在 5 到 10批与批之间再串行。这样既能保证吞吐又不会把事件循环压垮。我在实际项目里做过一次优化原来是一次性Promise.all拉取 500 条用户数据数据库连接直接打满接口超时率飙升改成并发限制为 10 的调度器后耗时虽然变长了一些但系统稳定性大幅提升超时率降到几乎为零。很多时候性能优化不是单纯追求“最快”而是在吞吐和稳定之间找到平衡点。到了这里Generator 和 async 的核心内容基本讲完了。最后再说说我个人的一个体会学这两块内容最重要的是动手跑一遍代码光看文章是记不住的。你可以把文章里的调度器例子复制到本地改一改参数故意制造几个 bug看看到底会发生什么。踩过一两次坑之后你对yield、next、await这些词的理解会比看十遍教程都深刻。后面的项目里遇到类似问题你也能比其他人更快定位到根因。