- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
本篇技术指南源自前端精读周刊对 Aditya Agarwal《Avoiding the Async/Await Hell》一文的精读。文章以现代前端常见的顺序await代码为切入点,剖析 async/await 作为语法糖带来的"隐形串行化"问题:语法变清爽了,异步并发却被悄悄牺牲,性能直接传导到用户体验。读完本文,你将掌握"先发起、后等待"的并行写法、Promise.all的正确姿势,以及回调、async/await、Promise 三种异步形态各自的适用边界,从而在依赖链与并发场景中做出有据可依的选择。
1 引言:async/await 终于也被吐槽了
在 async/await 大行其道的年代,很少有人质疑它的副作用。Aditya Agarwal 却认为,async/await 语法让开发者陷入了新的麻烦之中——它把异步问题从"写法丑"变成了"性能差"。
笔者也早就觉得哪里不对劲,终于有人把实话说了出来:async/await 可能会带来麻烦。本篇精读正是围绕这一观点展开,并结合本仓库中 《AsyncAwait 优越之处》、《Javascript 事件循环与异步》 等姊妹篇,从语法糖本质、并发语义、事件循环三个层面把它讲透。
2 概述:随处可见的"顺序 await"代码
下面是随处可见的现代化前端代码——下单点餐场景,每个操作都标注了同步/异步类型:
(async () => { const pizzaData = await getPizzaData(); // async call const drinkData = await getDrinkData(); // async call const chosenPizza = choosePizza(); // sync call const chosenDrink = chooseDrink(); // sync call await addPizzaToCart(chosenPizza); // async call await addDrinkToCart(chosenDrink); // async call orderItems(); // async call })();这段代码读起来行云流水,但性能隐患就藏在第一行与第二行之间:await语法本身没有问题,问题在于使用者用错了。当pizzaData与drinkData之间没有依赖关系时,顺序的await会让getPizzaData与getDrinkData变成串行执行——整体执行时间最多会增加一倍(等于getPizzaData的耗时被额外叠加了一次)。
回到我们一直吐槽的回调地狱:虽然回调代码比较丑,但至少两行并列的回调代码并不会带来阻塞。语法的简化换来了性能问题,而且这个性能问题会直接落到用户体验上,是不是值得我们反思一下?
2.1 正确做法一:先发起 Promise,再 await 结果
正确的思路是先同时发起异步函数,再 await 它们的返回值,让异步调用在等待期间并行推进:
(async () => { const pizzaPromise = selectPizza(); const drinkPromise = selectDrink(); await pizzaPromise; await drinkPromise; orderItems(); // async call })();这里的关键差异在于:selectPizza()与selectDrink()的调用在await之前就已经执行并返回 Promise,两个异步操作随即并行开始;后续的两个await只是等待各自的结果就绪。
2.2 正确做法二:Promise.all 更可读
也可以使用Promise.all让并发意图更直白:
(async () => { Promise.all([selectPizza(), selectDrink()]).then(orderItems); // async call })();对比三个版本可以看出:顺序await写法最"顺眼",但并发度最差;先发起再等待的写法性能正确;Promise.all则在性能正确的基础上,把"这批操作是并行的"这个意图显式写进了代码里。不要随意地 await,它很可能让你的代码性能降低。
值得注意的是,同样的并发陷阱也出现在 《AsyncAwait 优越之处》 的精读中:一个模块需要发送 3 个互不依赖的请求后再渲染,如果写成
async function mount() { const result1 = await fetch('a.json'); const result2 = await fetch('b.json'); const result3 = await fetch('c.json'); render(result1, result2, result3); }3 个请求便是顺序执行的,异步优势被完全浪费;真正实现并发仍需Promise.all封装一层:
async function mount() { const result = await Promise.all([ fetch('a.json'), fetch('b.json'), fetch('c.json') ]); render(...result); }可见"无依赖的异步任务必须显式并行"是 async/await 时代反复踩坑后沉淀下来的通用结论。
3 精读:为什么 async/await 会被滥用
仔细思考 async/await 被滥用的原因,笔者认为是它的功能比较反直觉导致的。
首先,async/await 真的是语法糖,功能也仅是让代码写得舒服一些。先不看它的语法或特性,仅从"语法糖"三个字就能看出:它一定局限了某些能力。
举个例子:我们用 html 标签封装了一个组件,带来便利性的同时,其功能一定是 html 的子集。又比如,某个轮子哥觉得某个组件 API 太复杂,于是基于它封装了一个语法糖,我们多半可以认为这个便捷性是牺牲了部分功能换来的。
功能完整度与使用便利度一直是相互博弈的,很多框架思想的不同开源版本,几乎都是把功能完整度与便利度按照不同比例混合的结果。
那么回到 async/await,它要解决的问题是回调地狱带来的灾难:
a(() => { b(() => { c(); }); });为了减少嵌套结构太多对大脑造成的冲击,async/await 决定这么写:
await a(); await b(); await c();虽然层级上一致了,但逻辑上仍然是嵌套关系——b必须等a完成、c必须等b完成。这不是另一个程度上增加了大脑负担吗?而且这个转换还是隐形的,所以许多时候,我们倾向于忽略它,于是造成了语法糖的滥用。
3.1 理解语法糖:await 的"隐形串行"语义
虽然要正确理解 async/await 的真实效果比较反人类,但为了清爽的代码结构,以及防止写出低性能的代码,还是挺有必要认真理解 async/await 带来的改变。
首先,async/await 只能实现一部分回调支持的功能——也就是仅能方便应对层层嵌套的场景。其他场景,就要动一些脑子了。
比如下面两对独立的回调,a与c之间没有任何依赖:
a(() => { b(); }); c(() => { d(); });如果写成下面的方式,虽然一定能保证功能一致,但变成了最低效的执行方式:
await a(); await b(); await c(); await d();因为翻译成回调,它就变成了:
a(() => { b(() => { c(() => { d(); }); }); });然而我们发现,原始代码中,函数c可以与a同时执行,但 async/await 语法会让我们倾向于在b执行完后,再执行c——这正是"隐形串行化"的根源:表面上await让代码变平了,实际上它把原本可并行的独立任务排成了一条依赖链。
所以当我们意识到这一点,可以优化一下性能:
const resA = a(); const resC = c(); await resA; b(); await resC; d();先同时发起a()与c(),再分别等待。但这个逻辑其实仍然无法完全达到回调的效果:虽然a与c同时执行了,但d原本只需要等待c执行完;现在如果a的执行时间比c长,d就变成了在等a完成之后才执行,等效于:
a(() => { d(); });也就是d被a的慢速执行"拖累"了,并发收益被部分抵消。
看来只有完全隔离成两个函数,才能恢复原始的并发结构:
(async () => { await a(); b(); })(); (async () => { await c(); d(); })();或者利用Promise.all把两条独立链路组织起来:
async function ab() { await a(); b(); } async function cd() { await c(); d(); } Promise.all([ab(), cd()]);这就是 async/await 的可怕之处。回调方式这么简单的过程式代码,换成 async/await 居然写完还要反思一下,再反推着去优化性能,这简直比回调地狱还要可怕。
而且大部分场景的代码是非常复杂的,同步与await混杂在一起,想捋清楚其中的脉络、并正确优化性能往往是很困难的。但是我们为什么要自己挖坑再填坑呢?很多时候还会导致忘了填。
原文作者给出了Promise.all的方式简化逻辑,但笔者持保留态度:不要一味追求 async/await 语法,在必要情况下适当使用回调,是可以增加代码可读性的。
3.2 源码视角:async/await 到底做了什么
从 《AsyncAwait 优越之处》 的源码级分析可以看到,async/await 与 Promise 存在千丝万缕的联系——一个 async 函数总是会返回一个 Promise。因此可以说,async/await 只是 Promise 的语法糖,这解释了它为什么"天生"只能表达 Promise 的表达能力。
下面是最基础的 async/await 例子:
async function test() { const img = await fetch('tiger.jpg'); }使用 Babel 转换后,会被改写为基于_asyncToGenerator与regeneratorRuntime的状态机代码:
'use strict'; var test = function() { var _ref = _asyncToGenerator(regeneratorRuntime.mark(function _callee() { var img; return regeneratorRuntime.wrap(function _callee$(_context) { while (1) { switch (_context.prev = _context.next) { case 0: _context.next = 2; return fetch('tiger.jpg'); case 2: img = _context.sent; case 3: case 'end': return _context.stop(); } } }, _callee, this); })); return function test() { return _ref.apply(this, arguments); }; }(); function _asyncToGenerator(fn) { return function() { var gen = fn.apply(this, arguments); return new Promise(function(resolve, reject) { function step(key, arg) { try { var info = genkey; var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function(value) { step("next", value); }, function(err) { step("throw", err); }); } } return step("next"); }); }; }从这段转换代码可以推断:await在底层就是Promise.resolve(value).then(...)的逐级衔接,step("next", value)每推进一次,就代表一个await挂起并等待一个 Promise 完成——顺序await的串行化并非引擎优化失误,而是这种状态机推进方式最直接的表达。原来只需 3 行代码解决的问题,被转换成 52 行代码,这还是基于执行环境中已经存在 regenerator 的前提;若要在兼容性不佳的 Web 环境下使用,代码 overhead 成本也必须纳入考虑。
此外,async 函数默认返回 Promise 还带来一个隐藏问题:异常会被静默吞掉。虽然 async/await 能用try...catch...这种符合同步习惯的方式捕获异常,但你依然不得不手动给每个await调用添加try...catch...语句;否则,async 函数返回的只是一个 reject 掉的 Promise 而已——这与本篇文章讨论的"双刃剑"主题互为镜像:便利的另一面,总是隐藏着需要开发者主动补齐的责任。
3.3 事件循环视角:await 在何时恢复执行
要真正理解await为什么不阻塞、以及它的执行时机,可以结合 《Javascript 事件循环与异步》 中的原理:JS 主线程的同步代码全部运行在调用栈(Call Stack)中,异步代码则通过事件循环(Event Loop)调度。Promise之流的任务属于 Microtask,插入当前执行队列的"横排",是最快被执行到的一类;setTimeout之流的 Macrotask 则被添加到新的"纵排",执行优先级更低。
await恢复执行的本质,就是 async 函数内部状态机在对应 Promise 落定(settle)后,通过 Microtask 继续推进step("next", ...)。这也意味着:await并不会让出线程,它只是把函数的剩余部分挂起,等 Promise 就绪后再以 Microtask 的优先级快速恢复。理解这一点,才能解释为什么"先发起、后等待"的写法能并发——因为await挂起的是函数的执行流,而不是异步操作本身;异步操作从函数调用那一刻就已开始。
4 实战准则:何时用 async/await,何时显式并发
综合以上分析,可以沉淀出几条可执行的选型准则:
| 场景 | 推荐写法 | 原因 |
|---|---|---|
| 严格依赖链(如取到 id 再查详情) | 顺序await | 串行是业务语义本身的要求,顺序await最直白 |
| 互不依赖的多个异步任务 | 先发起、后await,或Promise.all | 恢复并发,避免执行时间叠加 |
| 依赖链 + 独立链路混杂的复杂流程 | 拆分为多个 async 函数,用Promise.all组织 | 保持每段链路内串行、链路之间并行,可读性与性能兼得 |
| 复杂流程控制(中断、进度、暂停等) | 适当回归回调或引入流程控制库 | async/await 缺少 abort、progress、pause、resume 等控制能力,硬写会"自己挖坑再填坑" |
其中"严格依赖链"的典型例子可见 《AsyncAwait 优越之处》 中的建文档逻辑:先db.post({})得到新文档 id,再db.get(response.id)按 id 查询——两个操作必须串行,此时顺序await就是最优解:
async function createNewDoc() { let response = await db.post({}); // post a new doc return await db.get(response.id); // find by id }而"串行"本身也有语法层面的选择。在需要"一个接一个"执行 Promise 队列(而非并发)时,《用 Reduce 实现 Promise 串行执行》 给出了两种等价思路:一是用reduce在内存中构造 Promise 队列(同步构造、异步执行,一个事件循环内完成队列搭建):
function runPromiseByQueue(myPromises) { myPromises.reduce( (previousPromise, nextPromise) => previousPromise.then(() => nextPromise()), Promise.resolve() ); }二是在 async/await 支持下简化为for...of逐项等待:
async function runPromiseByQueue(myPromises) { for (let value of myPromises) { await value(); } }需要注意两种思路的差异:reduce版本整体是同步函数,先执行完毕构造出 Promise 队列,再在内存异步执行;for...of版本则是把自身改造成异步函数,逐个等待每个 Promise 执行完毕。串行队列在真实业务中往往用得不多,因为串行会阻塞,而用户交互往往是并行的——这也再次印证了本篇的核心观点:并发场景下,串行await是最大的性能隐患。
5 总结:决定代码质量的是思维
async/await 双刃剑的讨论提醒着我们:不要过度依赖新特性,否则可能带来代码执行效率的下降,进而影响到用户体验。同时,也不要过度利用新特性去修复新特性带来的问题,这样反而会导致代码可读性下降。
翻开 redux 刚火起来那段时期的老代码,可以看到许多过度抽象、为了用而用的代码:硬是把两行代码能写完的逻辑,拆到了 3 个文件、分散在 6 行不同位置,只能靠字符串搜索的方式查找线索,最后发现这个抽象整个项目仅用了一次。写出这种代码的可能性只有一个——在精神麻木的情况下,一口气喝完了 redux 提供的全部鸡汤。就像 async/await 地狱一样,看到这种 redux 代码,远不如所谓没跟上时代的老前端写出的 jquery 代码。
决定代码质量的是思维,而非框架或语法。async/await 虽好,但也要适度。
补充说明:经过讨论,笔者把原文的"async/await 地狱"标题改成了"async/await 是把双刃剑"。因为 async/await 并没有回调地狱那么可怕,称它为"地狱"有误导的可能性——它牺牲的不是代码的可用性,而是并发性能与心智负担,二者危害程度并不相同。
6 延伸阅读
- 精读《AsyncAwait 优越之处》:async/await 的六大优势、Babel 编译产物与并发局限的正面论证
- 精读《Javascript 事件循环与异步》:Microtask 与 Macrotask 的调度机制,理解
await的恢复时机 - 精读《用 Reduce 实现 Promise 串行执行》:Promise 队列串行的两种实现及其差异
- readme.md:前端精读周刊的完整目录与全部精读主题索引
- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
相关推荐
Async/Await 的优越之处与实现原理:前端精读周刊 we/weekly 异步编程详解
Async/Await 的优越之处与实现原理:前端精读周刊 we/weekly 异步编程详解 本文基于前端精读周刊( readme.md https://lin
文档技术博客教程如何利用Supabase-kt在iOS平台构建跨平台应用:SwiftUI与Kotlin的完美融合
如何利用Supabase kt在iOS平台构建跨平台应用:SwiftUI与Kotlin的完美融合 Supabase kt是一个强大的Kotlin多平台客户端库,
30 分钟搭好一个 ESP32 温湿度光照监测节点,硬件不到 150 元
30 分钟搭好一个 ESP32 温湿度光照监测节点,硬件不到 150 元 昨晚回家,卧室又闷又干,空调到底有没有在制热、绿植盆土干了没有,全靠感觉。这类"人不在
嵌入式物联网驱动开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考