ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

async/await 深度解析:从原理到实战的异步编程指南

async/await 深度解析:从原理到实战的异步编程指南

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 前,打印 A;await 把 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...of:

async function processAll(items) { for (const item of items) { await doSomething(item); } console.log('done'); // 现在真的等完了 }

或者如果不需要顺序执行,用Promise.all加 map:

await 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:要不要加这个 await

ESLint 有个规则叫no-return-await,建议不要在 return 后面加 await。原因是return await promise和return promise在大多数情况下效果一样,但前者多了一次微任务调度,性能略差。

但有个例外:在 try/catch 里,return await 是必要的。因为return promise不会等 Promise settle,try/catch 捕获不到它的 rejection;而return await promise会等,错误能被 catch 到。

async function f() { try { return await doSomething(); // 这里的 await 不能省 } catch (e) { return fallback(); } }

所以我的建议是:try/catch 外面可以省 await,try/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 之前先挂一个 catch:

async 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.allSettled:

const 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,而不是 forEach?
  • try/catch 的粒度是否匹配业务降级策略?
  • 有没有 return await 在 try/catch 外的情况,能不能省掉?

这几个问题过一遍,基本能避开大部分坑。迁移不是目的,写出清晰、健壮、好维护的异步代码才是。async/await 是个好工具,但工具用得好不好,还是看用的人对异步模型的理解有多深。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进