1. 从回调地狱到同步写法async/await 到底解决了什么第一次接触 async/await 的人多半会有一种这不就是把 Promise 换了个写法吗的错觉。我当初也是这么想的直到在一个真实项目里把一段嵌套了四层.then()的异步逻辑重写成 async/await 之后才真正体会到它带来的改变——不是语法糖那么简单而是异步代码的思维方式被彻底重构了。先说清楚它要解决的问题。JavaScript 是单线程语言遇到网络请求、文件读写、定时器这类耗时操作时不能傻等着否则整个页面就卡死了。早期大家用回调函数处理结果就是著名的回调地狱一层套一层缩进越来越深错误处理散落各处读代码像在走迷宫。后来 Promise 出现了用.then()链式调用把嵌套拉平了但链式调用写多了依然啰嗦尤其是多个异步操作有依赖关系时.then()里套.then()的情况还是避免不了。async/await 是 ES2017 正式纳入标准的语法它建立在 Promise 之上让你用写同步代码的方式去写异步逻辑。一个函数前面加上async它就自动返回一个 Promise函数体内部用await等待一个 Promise 完成代码就会暂停在这一行等结果回来再继续往下走。注意这里的暂停是带引号的——它并不会阻塞主线程只是让出了执行权等 Promise 有了结果再恢复执行。这个机制叫协程或者生成器式的执行暂停底层其实是 Generator 加自动执行器实现的只不过引擎帮你封装好了你不需要关心。为什么这个改变很重要我举个实际场景。假设你要依次做三件事先请求用户信息拿到用户 ID 后再请求订单列表拿到订单后再请求每个订单的详情。用 Promise 写大概是这样的getUser() .then(user getOrders(user.id)) .then(orders Promise.all(orders.map(o getOrderDetail(o.id)))) .then(details render(details)) .catch(err handleError(err));用 async/await 写async function loadData() { try { const user await getUser(); const orders await getOrders(user.id); const details await Promise.all(orders.map(o getOrderDetail(o.id))); render(details); } catch (err) { handleError(err); } }两段代码功能完全一样但第二段的阅读顺序和人的思维顺序是一致的先拿用户再拿订单再拿详情。没有回调嵌套没有链式跳转错误处理用一个 try/catch 就包住了所有 await 的异常。这就是它最大的价值——降低异步代码的认知负担。适合谁来学如果你已经会写基本的 Promise但对 async/await 的理解还停留在知道这么写的层面或者你在实际项目里遇到过 await 用错导致性能问题、错误捕获不到、循环里 await 顺序不对这些坑那这篇内容就是写给你的。我会从底层原理讲到实战细节把那些文档里不会明说、但踩过一次就忘不掉的经验都摊开来讲。2. async 函数的返回值不只是自动包一层 Promise那么简单很多人对 async 函数的理解就一句话加了 async函数就返回 Promise。这话没错但太粗糙了。实际用起来返回值的处理有好几个容易翻车的细节我一个个拆。2.1 返回普通值、返回 Promise、抛异常三种情况的真实行为先看最基础的。一个 async 函数无论你 return 什么调用它拿到的永远是一个 Promise。具体分三种情况return 一个普通值这个值会被自动包装成Promise.resolve(值)。比如async function f() { return 1; }调用f()得到的是Promise.resolve(1)。return 一个 Promise不会再多包一层直接返回那个 Promise 的状态。比如async function f() { return Promise.resolve(1); }f()得到的就是那个 resolved 为 1 的 Promise而不是Promise 套 Promise。函数体内抛异常等价于返回一个 rejected 的 Promise。比如async function f() { throw new Error(x); }f()得到的是 rejected 的 Promise错误信息是 x。这里有个反直觉的点async 函数里 return 一个 rejected 的 Promise和 throw 一个错误效果是一样的都会让返回的 Promise 变成 rejected。但如果你 return 的是一个 rejected Promise 而没 await 它错误不会在函数内部被 catch 到而是直接传递到外层。这个区别在写错误处理时很关键。async function a() { return Promise.reject(new Error(boom)); } async function b() { throw new Error(boom); } // a() 和 b() 返回的 Promise 都是 rejected 状态行为一致2.2 await 后面跟的不是 Promise 会怎样await后面理论上可以跟任何值。如果跟的是 Promise就等它 settle如果跟的是普通值引擎会把它包装成Promise.resolve(值)再等但因为是微任务实际上会让出一次事件循环。这意味着即使你await 1后面的代码也会被推迟到当前同步代码执行完之后。这个特性有个实际影响在循环里写await一个非 Promise 值虽然不会真的等待什么但每次都会产生一次微任务调度性能上比纯同步循环差。当然这种场景很少见但理解这个机制能帮你明白为什么 await 总是异步的。2.3 一个容易忽略的细节async 函数的执行时机调用一个 async 函数时函数体会立即同步执行直到遇到第一个 await 才暂停。这一点和 Promise 的 executor 类似——new Promise(fn)里的 fn 也是立即执行的。很多人以为 async 函数一调用就异步了其实不是await 之前的代码是同步跑的。async function f() { console.log(1); await null; console.log(2); } console.log(3); f(); console.log(4); // 输出顺序3 1 4 2这个输出顺序值得你亲手跑一遍。1 在 f() 调用时同步打印3 在 f() 之前4 在 f() 之后但 2 之前因为 await 把 2 推到了微任务队列。理解这个顺序后面讲并发控制时你会用得上。3. await 的执行机制暂停的是函数不是线程这是 async/await 最核心也最容易被误解的部分。我见过不少初学者以为await会阻塞整个程序其实完全不是。它暂停的只是当前 async 函数的执行主线程该干嘛干嘛。3.1 用生成器理解 await 的本质要真正搞懂 await绕不开 Generator。async/await 本质上是 Generator 函数加一个自动执行器的语法糖。Generator 函数用yield暂停用next()恢复async 函数用await暂停由引擎自动恢复。// Generator 版本 function* gen() { const a yield Promise.resolve(1); const b yield Promise.resolve(2); return a b; } // 手动执行器 function run(genFn) { const it genFn(); function step(value) { const result it.next(value); if (result.done) return result.value; return Promise.resolve(result.value).then(step); } return step(); }async/await 就是把这个执行器内置了。每次 await 一个 Promise函数就交出控制权等 Promise resolve 后引擎把结果通过类似next(value)的方式塞回去函数从暂停处继续。这就是为什么 await 能拿到 Promise 的 resolve 值——它相当于yield的返回值。理解这一层你就能明白几个现象为什么 await 只能在 async 函数里用因为需要执行器上下文、为什么 await 会暂停函数但不阻塞线程因为控制权交还给了事件循环、为什么 await 后面的代码总是在微任务里执行因为恢复是通过 Promise.then 调度的。3.2 微任务队列里的 await一个实测案例我做过一个实验把 await 和 setTimeout、Promise.then 混在一起观察执行顺序async function test() { console.log(A); await null; console.log(B); } console.log(C); setTimeout(() console.log(D), 0); test(); Promise.resolve().then(() console.log(E)); console.log(F); // 输出C A F B E D拆解一下同步代码先跑打印 C调用 test()同步执行到 await 前打印 Aawait 把 B 推入微任务继续同步打印 F然后微任务队列开始清空B 和 E 按入队顺序执行最后宏任务 setTimeout 打印 D。这个顺序如果你能自己推出来说明对事件循环和 await 的机制已经理解到位了。3.3 await 的错误传播路径await 一个 rejected 的 Promise 时错误会以异常的形式在 await 那一行抛出。如果当前 async 函数没有 try/catch这个错误会让函数返回的 Promise 变成 rejected继续往外层传播。这跟同步代码里 throw 的传播路径是一样的只不过载体从调用栈变成了 Promise 链。async function inner() { await Promise.reject(new Error(inner error)); } async function outer() { await inner(); // 这里会抛出 inner error } outer().catch(e console.log(e.message)); // inner error关键点await 会把 Promise 的 rejection 解包成同步异常。这是它和.then()最大的区别之一。用.then()时rejection 会走第二个参数或.catch()用 await 时rejection 直接变成 throw可以用 try/catch 捕获。两种风格不要混用否则容易漏掉错误。4. 实战中最容易踩的五个坑理论讲完了接下来是我在实际项目里踩过的坑每一个都让我改过代码、加过班。这些经验文档里不会写但你不注意就会中招。4.1 在 forEach 里用 await等了个寂寞这是新手最容易犯的错没有之一。看这段代码async function processAll(items) { items.forEach(async (item) { await doSomething(item); }); console.log(done); }你以为 done 会在所有 doSomething 完成后打印错了。forEach的回调是 async 函数但它返回的 Promise 被 forEach 忽略了forEach 本身是同步的不等回调完成就进入下一轮循环结束后立刻打印 done。所有 doSomething 还在后台跑着。正确做法是用for...ofasync function processAll(items) { for (const item of items) { await doSomething(item); } console.log(done); // 现在真的等完了 }或者如果不需要顺序执行用Promise.all加 mapawait Promise.all(items.map(item doSomething(item)));for...of是串行等待Promise.all是并行等待选哪个取决于你的业务需求。我一般的原则是有依赖关系或需要控制并发就用 for...of互相独立且数量可控就用 Promise.all。4.2 串行 await 拖慢性能能并行就别串行接着上面的说。假设你要请求三个互不依赖的接口很多人会这么写async function load() { const a await fetchA(); const b await fetchB(); const c await fetchC(); return [a, b, c]; }这段代码功能没问题但性能很差。三个请求本来可以同时发出现在变成了一个接一个总耗时是三者之和。如果每个请求 200ms串行就是 600ms并行只要 200ms 出头。改成并行async function load() { const [a, b, c] await Promise.all([fetchA(), fetchB(), fetchC()]); return [a, b, c]; }注意fetchA()这些调用是立即发起的Promise.all 只是等它们全部完成。这个改写我在项目里做过很多次接口越多收益越明显。判断标准很简单后一个 await 是否依赖前一个的结果。不依赖就并行依赖就串行。4.3 try/catch 包不住异步回调里的错误看这段async function f() { try { setTimeout(() { throw new Error(timeout error); }, 100); } catch (e) { console.log(caught, e); // 不会执行 } }try/catch 捕获不到 setTimeout 回调里的错误因为回调是在另一个事件循环 tick 里执行的早就脱离了 try 的作用域。这个坑在 await 场景下也有变体如果你 await 的是一个内部用了回调、但没正确 reject 的 Promise错误同样会丢失。解决办法是确保所有异步操作都返回 Promise并且错误通过 reject 传递。如果用的是老式回调 API用util.promisify或手写 Promise 包装function delay(ms) { return new Promise((resolve, reject) { setTimeout(() { try { // 可能抛错的逻辑 resolve(); } catch (e) { reject(e); } }, ms); }); }4.4 await 在循环里做并发控制别一次发一万个请求Promise.all虽然快但如果数组有一万个元素它会一次性发起一万个请求浏览器可能直接卡死服务器也可能把你限流。这时候需要并发控制限制同时进行的请求数量。我常用的一个简单实现async function pool(tasks, limit) { const results []; const executing []; for (const task of tasks) { const p Promise.resolve().then(() task()); results.push(p); if (limit tasks.length) { const e p.then(() executing.splice(executing.indexOf(e), 1)); executing.push(e); if (executing.length limit) { await Promise.race(executing); } } } return Promise.all(results); }用法await pool(urls.map(u () fetch(u)), 5)同时最多 5 个请求。这个模式在处理批量数据同步、图片上传时特别有用。Promise.race的作用是等最快完成的一个腾出并发名额。4.5 async 函数里的 return await要不要加这个 awaitESLint 有个规则叫no-return-await建议不要在 return 后面加 await。原因是return await promise和return promise在大多数情况下效果一样但前者多了一次微任务调度性能略差。但有个例外在 try/catch 里return await 是必要的。因为return promise不会等 Promise settletry/catch 捕获不到它的 rejection而return await promise会等错误能被 catch 到。async function f() { try { return await doSomething(); // 这里的 await 不能省 } catch (e) { return fallback(); } }所以我的建议是try/catch 外面可以省 awaittry/catch 里面必须加。这个细节很多人不知道导致错误处理失效。5. 错误处理的正确姿势try/catch 之外的几种方案async/await 让错误处理变得像同步代码一样直观但用好 try/catch 也有讲究。这一节聊聊几种常见的错误处理模式以及各自的适用场景。5.1 try/catch 的粒度包多少代码合适最直接的方式是把所有 await 包在一个 try/catch 里async function load() { try { const user await getUser(); const orders await getOrders(user.id); return { user, orders }; } catch (e) { console.error(e); return null; } }这种写法简单但粒度太粗——你分不清是 getUser 失败还是 getOrders 失败。如果两个操作需要不同的降级策略就得拆开async function load() { let user; try { user await getUser(); } catch (e) { return { error: user failed }; } let orders; try { orders await getOrders(user.id); } catch (e) { orders []; // 订单失败不影响用户展示 } return { user, orders }; }我的经验是错误处理粒度跟着业务降级策略走。能统一降级的就包一起需要区别对待的就拆开。不要为了代码简洁把所有东西塞一个 try 里那样出了问题很难定位。5.2 用 .catch() 处理单个 Promise 的失败有时候你不想让某个 Promise 的失败中断整个流程可以在 await 之前先挂一个 catchasync function load() { const user await getUser().catch(() null); const orders await getOrders().catch(() []); return { user, orders }; }这种写法适合失败也无所谓给个默认值就行的场景。好处是不用写 try/catch代码更扁平坏处是错误被吞掉了如果需要在失败时打日志或上报得在 catch 回调里处理。5.3 全局兜底unhandledrejection 事件无论你多小心总有可能漏掉某个 Promise 的 rejection。浏览器和 Node.js 都提供了全局兜底机制window.addEventListener(unhandledrejection, (event) { console.error(未处理的 Promise 拒绝:, event.reason); event.preventDefault(); // 阻止默认的控制台报错 });Node.js 里对应的是process.on(unhandledRejection, ...)。这个机制适合做监控上报但不应该作为主要的错误处理手段——它只能告诉你有错误漏了没法帮你恢复业务逻辑。我一般用它来兜底加日志配合 Sentry 之类的监控工具能快速发现线上问题。5.4 一个反模式async 函数里混用回调和 Promise最后说一个我见过的反模式。有些代码在 async 函数里又用回调又用 await结果错误处理乱成一团async function f() { return new Promise((resolve) { doSomething((err, result) { if (err) throw err; // 这个 throw 会被吞掉 resolve(result); }); }); }回调里的 throw 不会让外层 Promise reject因为它在 Promise 的 executor 之外执行。正确做法是reject(err)而不是throw err。这个坑在包装老 API 时特别常见一定要用 reject 传递错误。6. 并发与顺序async/await 在真实业务里的编排技巧前面讲了单个 await 的用法这一节聊聊多个异步操作怎么编排。真实业务里很少只有一个异步调用更多是多个操作有依赖、有并发、有超时、有重试需要一套组合拳。6.1 串行、并行、混合三种编排模式先明确三种基本模式模式写法适用场景耗时串行多个 await 依次写有依赖关系各操作之和并行Promise.all互相独立最慢的那个混合分组后组内并行、组间串行部分依赖各组最慢之和混合模式举个例子先并行请求用户基本信息和配置等两者都回来后再用用户 ID 串行请求订单。这种先并行后串行的结构在实际项目里很常见。async function load() { const [user, config] await Promise.all([getUser(), getConfig()]); const orders await getOrders(user.id, config.region); return { user, config, orders }; }6.2 Promise.allSettled不因一个失败而全盘皆输Promise.all有个特性只要有一个 Promise reject整个 all 就 reject其他成功的结果也拿不到。如果你希望不管成功失败都要拿到所有结果用Promise.allSettledconst results await Promise.allSettled([fetchA(), fetchB(), fetchC()]); results.forEach(r { if (r.status fulfilled) { console.log(成功:, r.value); } else { console.log(失败:, r.reason); } });这个 API 是 ES2020 引入的在批量操作场景下比 Promise.all 更实用。比如批量上传文件你希望知道哪些成功哪些失败而不是一个失败就全部放弃。6.3 超时控制给 await 加一个时间上限await 一个永远不 resolve 的 Promise 会让函数永远卡住。实际项目里需要给关键操作加超时function withTimeout(promise, ms) { const timeout new Promise((_, reject) { setTimeout(() reject(new Error(timeout)), ms); }); return Promise.race([promise, timeout]); } // 用法 try { const data await withTimeout(fetchData(), 3000); } catch (e) { console.log(请求超时或失败); }Promise.race返回最先 settle 的那个所以超时 Promise 先 reject 时整个 race 就 reject 了。注意超时后原 Promise 还在跑如果它有副作用比如写数据库需要额外的取消机制。不过大多数 HTTP 请求库都支持 AbortController可以配合使用。6.4 重试机制失败后自动再试几次网络请求偶尔失败很正常加个重试能提升稳定性async function retry(fn, times 3, delay 1000) { for (let i 0; i times; i) { try { return await fn(); } catch (e) { if (i times - 1) throw e; await new Promise(r setTimeout(r, delay * (i 1))); } } } // 用法 const data await retry(() fetchData(), 3, 500);这里用了指数退避的思路每次重试间隔递增避免短时间内大量重试压垮服务。实际项目里还要考虑哪些错误值得重试——网络超时可以重试参数错误重试多少次都没用。6.5 一个真实的编排案例批量数据同步假设你要把一个列表的数据同步到服务端每个数据要经过校验→上传→确认三步且服务端限制同时最多 3 个请求。这个场景综合了串行、并行、并发控制async function syncAll(items) { const tasks items.map(item async () { const valid await validate(item); if (!valid) return { id: item.id, status: invalid }; const uploaded await upload(item); await confirm(uploaded.id); return { id: item.id, status: ok }; }); return pool(tasks, 3); }每个 item 内部是串行校验→上传→确认item 之间是并发但限制 3 个。这种结构在数据同步、批量导入场景里非常典型。写的时候先把单个 item 的流程理清楚再用 pool 控制整体并发逻辑会清晰很多。7. 从 Promise 迁移到 async/await哪些该改哪些别动最后聊聊迁移。很多老项目里全是.then()链要不要全改成 async/await我的答案是渐进式迁移别一刀切。7.1 优先改造嵌套深、错误处理乱的代码迁移的收益和代码的复杂度成正比。那些嵌套三四层、错误处理散落各处的.then()链改成 async/await 收益最大。而简单的单层.then()改不改都行别为了统一风格去动它容易引入 bug。改造的时候注意保持行为一致。.then()链里如果某个环节返回了非 Promise 值await 也能正确处理但如果某个环节依赖了.then()的微任务时序改成 await 后时序可能变化需要测试验证。7.2 顶层 await模块里的新玩法ES2022 引入了顶层 await允许在模块的顶层直接使用 await不需要包在 async 函数里// module.js const config await fetch(/config.json).then(r r.json()); export default config;这个特性在模块初始化需要异步加载资源时很有用。但要注意用了顶层 await 的模块导入它的模块会等待它完成可能影响加载性能。而且目前只在 ES 模块里支持CommonJS 里用不了。7.3 性能敏感场景await 不是免费的每次 await 都会产生微任务调度有轻微的性能开销。在极端性能敏感的场景比如每秒处理上万次的高频循环大量 await 可能成为瓶颈。这种场景下可以考虑用 Promise 链或者批量处理来减少 await 次数。不过说实话绝大多数业务代码根本到不了这个量级可读性比这点性能重要得多。我见过有人为了性能把清晰的 async/await 改成晦涩的 Promise 链结果维护成本飙升得不偿失。先保证正确和可读性能问题等 profiling 数据出来再优化。7.4 我个人的迁移检查清单每次迁移一段代码我会过一遍这几个问题所有 await 的 Promise 是否都有错误处理路径串行的 await 是否真的需要串行能不能改并行循环里的 await 是否用了 for...of 或 Promise.all而不是 forEachtry/catch 的粒度是否匹配业务降级策略有没有 return await 在 try/catch 外的情况能不能省掉这几个问题过一遍基本能避开大部分坑。迁移不是目的写出清晰、健壮、好维护的异步代码才是。async/await 是个好工具但工具用得好不好还是看用的人对异步模型的理解有多深。