深入理解JS异步:从Generator到async/await的底层原理与工程实践 📅 发布时间:2026/9/17 17:06:45 👁 浏览次数: 说实话JS 里 generator 和 async 函数这两块我一开始也是看了就忘、用了就懵。Generator 像是穿越剧async/await 又像是自动挡单独看语法都能看懂一组合起来就不知道内部到底怎么流转的。后来在项目里被回调嵌套、超时控制、并发限流连续教做人之后我才算真正把它们打通。这篇文章我就按我自己理解的路径来写——先搞懂 generator 到底是什么再去看 async/await 为什么被称为 generator 加 Promise 的语法糖最后落到实际项目里的并发、错误处理和可取消异步。如果你能把这两块吃透JS 的异步编程基本就没有盲区了。不管你是刚接触前端、写 React/Vue 业务遇到异步流程变复杂还是准备面试被问到generator 和 async 的区别这篇文章都能给到你一套可以直接抄作业的理解框架。我尽量把代码、执行顺序、内部机制都掰开揉碎讲清楚。1. 先建立宏观认知异步方案演进的本质逻辑1.1 回调地狱到 Promise解决的是信任问题在 Promise 出现之前JS 处理异步依赖回调函数。回调本身没什么问题问题出在多层嵌套和错误处理上getUser(id, function (user) { getPosts(user.id, function (posts) { getComments(posts[0].id, function (comments) { // 继续嵌套... }); }); });这段代码的缩进会随着业务复杂度呈指数增长而且一旦某一层报错你需要在每一层都写 error 判断。更麻烦的是回调方案存在一个非常隐晦的控制反转问题——你把后续代码的执行权交给了第三方库它什么时候调用、调用几次、传什么参数你都控制不了。Promise 解决了这个信任问题它把异步结果抽象成一个状态机pending、fulfilled、rejected。你可以通过.then()链式调用错误也能统一通过.catch()兜底。但它依然不够同步化链式调用一旦超过三层代码读起来还是绕。1.2 Generator 是可以暂停的函数async 是自动化的 GeneratorGenerator 函数最核心的特点是函数执行到yield时可以被暂停并且暂停的时候还能和外部通信。这种暂停恢复的能力让它天然适合用来处理异步任务。而 async 函数本质上就是Generator Promise 自动执行器的结合体。你不必手动去调用.next()驱动流程运行时帮你做了这件事。所以学这两块建议顺序是先懂 generator 的暂停机制 → 再用 generator 手动驱动 Promise → 最后发现这个驱动过程可以自动化于是就有了 async/await。你把这个链条想清楚这两个概念就彻底焊死在脑子里了。2. Generator 核心机制详解为什么它敢说自己能改变执行顺序2.1 迭代器协议所有集合遍历的统一接口要理解 generator先得理解迭代器Iterator协议。所谓迭代器就是一个带next()方法的对象每次调用next()都会返回一个{ value, done }格式的结果。数组、字符串、Map、Set 这些数据类型都已经内置了迭代器所以你可以直接用for...of遍历。Generator 函数的强大之处在于执行它会返回一个迭代器对象而这个对象的next()完全是惰性求值的——只有调用next()函数体才会从上一次暂停的位置继续执行到下一个yield。看一个最基本的例子function* gen() { console.log(开始执行); yield 1; console.log(第一次恢复); yield 2; console.log(第二次恢复); return 3; } const it gen(); console.log(it.next()); // 输出: 开始执行 → { value: 1, done: false } console.log(it.next()); // 输出: 第一次恢复 → { value: 2, done: false } console.log(it.next()); // 输出: 第二次恢复 → { value: 3, done: true }注意一个细节调用gen()的时候函数体没有立即执行而是返回了一个挂起的迭代器。第一次next()才真正开始执行遇到yield暂停并返回yield后面的值。这个暂停不是语言的模拟而是 ECMAScript 规范层面的真实行为——函数调用栈会被冻结。我在实际项目中理解这一点的最直观感受是generator 可以把一堆看似连续的代码人为切成一段一段的剧本每次next()就是往后播一集。这种代码分段执行的能力是后面实现异步流程控制的基石。2.2 yield 表达式的秘密数据的双向通道很多初学者以为yield只是把值抛出去其实它还可以接收外部传进来的值。这个特性通常被叫做双向传值它的语法非常隐蔽但极其关键function* twoWay() { const name yield 你叫什么名字; const age yield 你多大了; return ${name} 今年 ${age} 岁; } const it twoWay(); console.log(it.next()); // { value: 你叫什么名字, done: false } console.log(it.next(张三)); // { value: 你多大了, done: false } console.log(it.next(25)); // { value: 张三 今年 25 岁, done: true }这里的执行逻辑要理清楚第一次next()启动函数执行到const name yield ...暂停此时name还没有赋值。第二次next(张三)恢复执行传入的参数张三会成为上一个yield表达式整体的返回值于是name被赋值为张三函数继续往下走直到遇到下一个yield又暂停。我当初卡在这个点上很久后来用了一个类比才彻底明白可以把yield想象成一个传送带上的货筐。函数执行到yield相当于把货筐推到传送带上交给外部然后自己停下来等待。外部拿到value之后往next()里塞一个东西这个货筐就带着新东西又传回函数内部成为yield表达式的执行结果。2.2.1 外部抛出异常通过 throw 注入错误双向通信不止是传值还能传错误。通过调用迭代器的throw()方法可以给 generator 内部抛入一个异常而这个异常会在上一次暂停的yield位置被触发。function* risky() { try { const data yield 准备接收数据; console.log(拿到数据:, data); } catch (err) { console.log(内部捕获:, err.message); } } const it risky(); it.next(); it.throw(new Error(外部注入的错误)); // 输出: 内部捕获: 外部注入的错误这个能力非常震撼因为在普通的函数里外部想干扰函数内部执行是不可能的。但 generator 允许你从外部注入异常而且注入的位置精确到暂停点。后面我们处理 async 流程的取消操作时这种能力特别有用。2.2.2 yield* 委托多个生成器组合编排yield*可以把一个 generator 的执行委托给另一个 generator 或者可迭代对象这在编排复杂异步流程时可以减少重复代码function* child() { yield 子任务1; yield 子任务2; } function* parent() { yield 父任务开始; yield* child(); // 委托给子生成器 yield 父任务结束; } console.log([...parent()]); // 输出: [父任务开始, 子任务1, 子任务2, 父任务结束]yield*的语义是把子迭代器的next()调用完全委托出去直到子迭代器done为true再继续执行父生成器的代码。这个语法在处理分阶段的复杂流程时可以让你把一个大流程拆成多个小流程独立维护。2.3 用 Generator 手动驱动异步流程co 库的原理上面说了这么多 generator 的特性但它们和异步到底怎么结合核心就一句话在 generator 里 yield 一个 Promise然后在外部用迭代器不断驱动next()把 Promise 的结果传回内部。这种模式最早的经典实现是 TJ 的 co 库。我简化一下核心逻辑让你理解它的本质function co(genFn) { const it genFn(); function step(method, arg) { let result; try { result it[method](arg); } catch (err) { return Promise.reject(err); } if (result.done) { return Promise.resolve(result.value); } // 假设 yield 出来的一定是 Promise return Promise.resolve(result.value) .then( (val) step(next, val), (err) step(throw, err) ); } return Promise.resolve(step(next)); } // 使用示例 function delay(ms, value) { return new Promise((resolve) setTimeout(() resolve(value), ms)); } co(function* () { const a yield delay(1000, 第一个结果); console.log(a); const b yield delay(1000, 第二个结果); console.log(b); return [a, b]; });这段代码的核心逻辑是递归自调用step()拿到 Promise 就then它成功则把结果传回 generator 内部失败则通过throw注入异常。generator 内部看起来就是同步代码完全不需要自己处理回调。注意这只是一个极简版 co。真实项目里还要考虑 yield 数组、yield 对象、yield thunk 函数等情况。不过理解这个最小模型你就掌握了 co 的骨架。co 库流行的那几年是 generator 异步编程的黄金时期。但手动调用 co 还是有点繁琐而且异常处理要靠外部包装。于是 ES2017 把这种模式真正语言化了这就是 async/await。3. Async/Await 全面拆解语法糖背后的真实执行顺序3.1 async 函数必然返回 Promise这是 async 函数和普通函数最根本的区别。不管你在 async 函数里返回什么值它都会被Promise.resolve()包装成一个 Promiseasync function fetchData() { return { id: 1 }; } const result fetchData(); console.log(result instanceof Promise); // true如果函数内部抛出异常返回的 Promise 会变成 rejected 状态async function throwError() { throw new Error(出错了); } throwError().catch((err) console.log(err.message)); // 出错了这个特性意味着async 函数永远不要用返回值的思路去接要用Promise的思路去接。很多新手在这个地方踩坑以为const data fetchData()就能拿到数据结果拿到的是一个 Promise 对象。3.2 await 的暂停语义到底在等什么await关键字可以看作是 generator 中yield的语法升级版。它的行为可以概括为对await后面的表达式求值。如果结果不是 Promise则包装成 resolved 状态的 Promise。暂停当前 async 函数把控制权交回外部事件循环。等 Promise 落定后恢复函数执行把 resolved 的值作为await表达式的结果。这里有一个非常容易误解的地方await并不一定真的会等。如果await后面的值已经是一个 resolved 态 Promise 或者普通值函数依然会暂停但恢复的执行时机是当前宏任务之后的微任务队列里。为了说清楚执行顺序我经常拿这段代码举例async function test() { console.log(1: async 函数开始); const value await 直接量; console.log(3: await 之后拿到:, value); console.log(4: 继续执行); } console.log(start); test(); console.log(end); // 输出顺序: start → 1: async 函数开始 → end → 3: await 之后拿到: 直接量 → 4: 继续执行为什么end会先于3: await 之后输出因为await即使后面是普通字符串也会先把这个值交给 Promise 机制处理然后让出执行权。只有当外层调用栈空了微任务队列开始清空时async 函数才能恢复。这个机制保证了 async 函数绝对不会阻塞其他代码执行。3.2.1 await 与事件循环的精确对齐如果把 Promise 和事件循环放在一起看就更容易理解 await 的位置console.log(script start); setTimeout(() { console.log(setTimeout); }, 0); Promise.resolve().then(() { console.log(microtask); }); async function asyncTask() { console.log(async start); await null; console.log(async after await); } asyncTask(); console.log(script end); // 输出顺序: // script start // async start // script end // microtask // async after await // setTimeout这里有两个关键信息。第一async start是在同步阶段打印的因为 async 函数体在第一个await之前是同步执行的。第二async after await被放到微任务队列中和显式添加的Promise.resolve().then()一起排但按注册顺序优先于宏任务setTimeout。我一般在讲解时会强调一句话await右边的代码先执行await左边的代码隔一个微任务执行。这句话虽然不完全精确但足够应付大多数面试和业务场景。3.3 async/await 与 Generator 的等价转换手写自动执行器理解了 co 之后再来看 async 函数你会发现它其实就是语言内置的 co。我们写一个 async 函数async function example() { const a await Promise.resolve(1); const b await Promise.resolve(a 1); return a b; }如果你把它手动改写成 generator 自执行器大致是这样的const generatorFn function* () { const a yield Promise.resolve(1); const b yield Promise.resolve(a 1); return a b; }; function autoRun(genFn) { const it genFn(); function handle(result) { if (result.done) return Promise.resolve(result.value); return Promise.resolve(result.value).then( (res) handle(it.next(res)), (err) handle(it.throw(err)) ); } try { return handle(it.next()); } catch (err) { return Promise.reject(err); } } autoRun(generatorFn).then((result) { console.log(result); // 3 });你对比 async 版本和这个手写版本会发现语义几乎一样暂停、等待 Promise、恢复、再暂停、再恢复。所以可以很负责地说async/await 本质上就是 generator 的暂停能力加 Promise 的链式能力再加一个自动执行的引擎。实操心得如果你要给别人讲解这两个概念我建议用手动 co 驱动 generator作为中介。从纯 generator 到 co再从 co 到 async/await是一条非常自然的思维路径。直接跳到 async/await 语法很容易让人知其然不知其所以然。4. 业务实战把这些异步能力真正落地到项目里原理讲完接下来都是我在实际开发中踩过的坑和沉淀下来的方案。这部分我觉得才是最有参考价值的。4.1 用 async/await 控制并发Promise.all 与 for...of 的取舍很多人以为 async/await 只能串行其实它是可以配合并发控制的。最简单的并发写法是Promise.allasync function fetchAll() { const [user, posts, settings] await Promise.all([ fetch(/api/user), fetch(/api/posts), fetch(/api/settings), ]); return { user, posts, settings }; }三个请求同时发出总耗时约等于最慢的一个请求。这是并发。而for...of配合 async 则是严格的串行async function fetchSequentially(urls) { const results []; for (const url of urls) { results.push(await fetch(url)); } return results; }每个请求必须等前一个完成才开始总耗时是所有请求之和。很多时候你没注意到这个区别代码就莫名变慢了。我推荐的实践准则多个相互独立的请求用Promise.all别用for...of。有依赖关系的请求下一个需要上一个的结果用for...of或手动串行。如果希望批量请求但限制并发数不能直接用Promise.all需要下面的并发池方案。4.2 解决痛点写一个通用并发池限流一次性发出 100 个请求很容易把服务端打挂也可能触发浏览器的连接数限制。我一般会维护一个固定并发数的任务池。下面这个实现我建议你直接收藏async function asyncPool(limit, tasks) { const results []; const executing new Set(); for (const task of tasks) { const promise Promise.resolve().then(() task()); results.push(promise); const clean () { executing.delete(promise); }; promise.then(clean, clean); executing.add(promise); if (executing.size limit) { await Promise.race(executing); } } return Promise.all(results); } // 使用示例 const tasks Array.from({ length: 10 }, (_, i) async () { console.log(任务 ${i} 开始); await new Promise((resolve) setTimeout(resolve, 1000)); console.log(任务 ${i} 完成); return i; }); asyncPool(3, tasks).then((res) { console.log(全部完成:, res); });这个实现的核心技巧是executing集合维护当前正在执行的任务一旦达到并发上限就用Promise.race(executing)等待其中任何一个完成再继续添加新任务。日志里你会看到任务 0、1、2 同时开始然后某个完成后任务 3 立即补位。如果用的是 generator 思路也可以把任务定义成一个 generator每次yield一个 Promise外部控制并发数。但坦白说async 函数在这种场景下代码会更直观所以我更推荐上面的asyncPool方案。4.2.1 带依赖关系的流程编排用 generator 更清晰如果你的异步流程分支很多、依赖关系复杂比如步骤 A 和 B 并行都完成后执行 C然后根据 C 的结果决定执行 D 还是 E这种流程用 async/await 硬写代码会变得非常庞大。这种时候我习惯用 generator 作为流程描述器。function* workflow() { const [user, config] yield Promise.all([ fetchUser(), fetchConfig(), ]); if (config.vip) { yield fetchVipData(user.id); } else { yield fetchNormalData(user.id); } } // 用一个自动执行器驱动这个 generator autoRun(workflow).catch((err) { // 统一处理分支中的错误 console.error(err); });Generator 的好处是流程的形状一眼就能看出来而且分支逻辑可以通过外部包装器统一处理错误和日志。你甚至可以在yield的时候打日志记录每个步骤的执行耗时这个日志能力在排查性能问题时非常有用。4.3 错误处理体系不是所有地方都能 try/catchasync/await 的错误处理看起来很简单用try/catch包一层就行。但实际项目中坑不少。4.3.1 只看得到 await 同步错误的盲区async function handle() { try { const data await fetchData(); // 处理 data } catch (err) { // 能捕获 fetchData 的 rejection } }这个版本没问题。但如果fetchData本身是同步抛错呢其实 async 函数会自动把同步异常转成 rejected Promise所以依然能捕获。真正容易出问题的是事件回调里的异步错误someEventEmitter.on(error, async () { // 这里的错误如果 try/catch 只包住自己外部永远感知不到 }); async function badExample() { const promise fetchData(); // 忘记 await没有 catch return ok; }我见过太多线上事故是async 函数返回了 Promise调用方却没有 await 或 catch。所以我的强制习惯是凡是 async 函数调用方要么 await要么明确挂一个 .catch()没有第三种选择。4.3.2 多个 await 的错误隔离策略如果一个 async 函数里有多个相互独立的请求是一个大 try/catch 包住还是每个请求单独处理我给的方案是按域划分async function dashboard() { // 用户信息挂了整个页面直接进错误边界 const user await fetchUser(); // 推荐列表挂了不阻断主流程降级使用空推荐 let recommendations []; try { recommendations await fetchRecommendations(); } catch (err) { console.warn(推荐列表加载失败使用空列表降级, err); recommendations []; } return { user, recommendations }; }原则很简单核心依赖错误直接向外抛非核心功能错误做局部兜底降级。这个思路比一刀切的 try/catch 要合理得多。4.4 超时控制和异步取消generator 的主场浏览器自带AbortController可以取消 fetch 请求但如果你需要的是业务级取消——比如用户点击取消后不再继续执行后续异步流程——generator 的throw()就派上了大用场。function createCancellableTask(genFn) { const it genFn(); let cancelled false; const promise new Promise((resolve, reject) { function step(method, arg) { if (cancelled) return; let result; try { result it[method](arg); } catch (err) { reject(err); return; } if (result.done) { resolve(result.value); } else { Promise.resolve(result.value) .then((val) step(next, val)) .catch((err) step(throw, err)); } } step(next); }); promise.cancel () { cancelled true; try { it.throw(new Error(任务已取消)); } catch (err) { // generator 内部可能已经消费掉这个异常忽略即可 } }; return promise; } // 用法 function* longTask() { console.log(任务启动); const a yield new Promise((resolve) setTimeout(() resolve(A), 3000)); console.log(拿到 A:, a); const b yield new Promise((resolve) setTimeout(() resolve(B), 3000)); console.log(拿到 B:, b); return [a, b]; } const task createCancellableTask(longTask); task.then((result) console.log(成功:, result)); setTimeout(() task.cancel(), 1500); // 输出: 任务启动 (1.5秒后没有任何后续输出任务被安全取消)为什么纯 async/await 不好实现取消因为 async 函数一旦开始执行你无法从外部往它内部的await位置注入异常除非在每一行代码里检查一个是否取消的标志位那代码会变得非常啰嗦。而 generator 的throw()可以在暂停点精准打断这是它在异步编排中不可替代的价值。注意调用cancel()之后generator 内部的it.throw()如果遇到没被内部 try/catch 捕获的情况会向外抛异常。所以我在createCancellableTask里把 cancel 内部的异常 try/catch 掉了避免这个取消信号被当成真正的错误处理。5. 高频问题与面试题总结这一节把常见的坑和面试考法集中梳理一下方便你复习和自查。5.1 并发 vs 串行的选择await 与 Promise.all 的区别场景推荐方案原因请求相互独立Promise.all并发执行总耗时最优化请求有依赖关系for...of await必须等待前一个结果大数组批量请求需限流自定义并发池避免资源耗尽需要并发但允许部分失败Promise.allSettled不会因为单个失败而整体中断我见过不少代码在循环里直接await本来可以并发的事务全变成串行接口响应时间翻好几倍。这个性能损耗很难从代码审查里看出来需要结合请求耗时一起分析。5.2 不要在 forEach 里用 async 函数// 错误的写法 async function handleList(list) { list.forEach(async (item) { await process(item); }); // 这里会立即执行process 可能一个都没完成 } // 正确写法 async function handleList(list) { for (const item of list) { await process(item); } // 或并发 await Promise.all(list.map((item) process(item))); }forEach本身是同步方法它不会等待内部 async 回调完成。你用forEach包裹 async 函数这些任务实际上是同时启动、互相竞争而且handleList会在所有任务完成前就返回。这是一个每天都能在真实代码里看到的经典错误。5.3 面试手写题用 Generator 实现 async/await如果面试官让你从零实现一个 async/await上面的autoRun函数就是标准答案雏形。再加上错误处理、thenable 判断、返回值 Promise 化就可以做到比较完整的实现。function myAsync(genFn) { const it genFn(); function resolveNext(prevValue) { let result; try { result it.next(prevValue); } catch (err) { return Promise.reject(err); } if (result.done) { return Promise.resolve(result.value); } const value result.value; if (value typeof value.then function) { return value.then(resolveNext, handleThrow); } return resolveNext(value); } function handleThrow(err) { try { const result it.throw(err); if (result.done) return Promise.resolve(result.value); const value result.value; if (value typeof value.then function) { return value.then(resolveNext, handleThrow); } return resolveNext(value); } catch (e) { return Promise.reject(e); } } try { return resolveNext(undefined); } catch (e) { return Promise.reject(e); } }这个实现把两个核心能力都体现了it.next(val)传值恢复、it.throw(err)注入异常。你只要把这个写出来再讲讲为什么 async/await 是 generator 的语法糖面试基本稳了。5.4 事件循环里 async/await 的输出顺序面试最爱考这类题答案的核心是记住微任务排队的机制。async function async1() { console.log(async1 start); await async2(); console.log(async1 end); } async function async2() { console.log(async2); } console.log(script start); async1(); console.log(script end); // 输出顺序: // script start // async1 start // async2 // script end // async1 end这里的关键是await async2()会先执行async2()同步执行到它的第一个 await 或者返回然后async2()返回的 Promise 已经处于 resolved 状态但async1的恢复被放进了微任务队列。所以script end这个同步代码会先于async1 end输出。想搞清楚这一类题目你需要在脑海里画出调用栈和任务队列两个模型同步代码在调用栈执行Promise 回调进微任务队列宏任务进宏任务队列。微任务队列清空之后才会执行下一个宏任务。5.5 Generator 与 async 函数的根本区别维度GeneratorAsync/Await返回值返回迭代器对象自动返回 Promise执行方式惰性需要手动next()驱动立即执行到第一个 await 暂停双向通信支持next(value)/throw(err)不支持外部注入取消能力可外部注入异常实现取消原生不支持取消可读性需要配合执行器否则较繁琐语法直观内置执行器适用场景流程编排、惰性序列生成、自定义控制流绝大多数异步业务场景我的个人体会是99% 的业务代码用 async/await 就够了但遇到流程编排、可取消异步、需要外部介入的复杂场景generator 依然是不可替代的杀手锏。这两个不是替代关系而是互补关系。6. 我自己的一点建议如果你现在还在学习阶段我建议按这个顺序动手练习先用 generator 写一个斐波那契数列生成器感受一下惰性求值然后写一个delay函数尝试用 generator 依次取三个异步结果手动调用next()再用 co 库简化这个过程最后用 async/await 重写一遍同样的逻辑。走完这个流程你对 JS 异步的掌控感会完全不一样。最后再分享一个小技巧调试 async/await 的代码时光靠console.log费时费力直接在浏览器 DevTools 里给 async 函数打断点逐步执行并观察调用栈远比反复看输出日志高效得多。而调试 generator 时记得留意迭代器对象上的next()调用位置因为暂停恢复的执行点往往才是最隐蔽的逻辑错误来源。