4.3 async-await语法糖拆解


4.3 async-await语法糖拆解

本节摘要:async 函数必然返回 Promise;await 是一个挂起点——把后半段函数打包成 then 回调排进微任务队列,函数立刻让出调用栈,外层代码继续跑。await 的右侧按「Promise 直等、thenable 转换、普通值包 Promise」三种方式归一。本节用逐行标注输出的方式拆开这颗糖,覆盖并发场景的正确写法(先启动后 await)、错误处理的 try-catch 与 finally、以及循环里 await 的顺序陷阱。

拆开一颗语法糖

一段代码,每行右侧标注执行时机,实测可复现:

async function work() { console.log('A 函数体开始,同步执行'); const v = await Promise.resolve('数据'); console.log('C await 之后,作为微任务执行'); return v; } console.log('0 调用前'); const p = work(); console.log('B 拿到 Promise,函数已在 await 处让出'); p.then(v => console.log('D then 回调', v)); console.log('E 继续同步'); // 实测输出:0 → A → B → E → C → D

回放引擎动作。work() 被调用,函数体作为同步代码上栈执行——A 打印。遇到 await,引擎做了三件事:对右侧表达式求值并归一成 Promise;把「await 之后的剩余函数体」打包成一个延续(continuation)挂到该 Promise 的 then 上;立刻返回一个 pending 的 Promise 给调用者——B 打印,注意此时 work 的栈帧已经弹出。外层继续同步跑完 E。栈清空,微任务检查点:C 的延续执行,work 的 Promise 落定;D 的 then 回调是又一个微任务,紧随其后。

所以 async 函数的本质是:生成器式的可暂停函数 + 引擎自动接线的 then 链。它没有引入新的调度规则,只是把 4.2 节的排队规则穿上了同步语法的外衣。外衫的价值在可读性:异步代码的「视觉顺序」终于与「执行顺序」一致,作用域不再被回调切断,try-catch 终于能罩住异步错误。

一、await 右侧的三种归一

await 对右侧表达式的处理与 then 的回调返回值规则同源:

async function demo() { const a = await 42; // 普通值:包成 Promise,下一拍解包 const b = await Promise.resolve(7); // Promise:直接等它 const c = await { then(res) { res(9); } }; // thenable:调用它的 then 转换 console.log(a + b + c); // 58 } demo();

第三种 thenable 是理解「await 任何东西」的关键:对象只要有 then 方法就被当作类 Promise 处理。框架利用这个缝隙做拦截器(比如测试库把 await 包装成可控的假异步)。日常代码不需要自造 thenable,但读库代码时认得出来。

await 一个 rejected 的 Promise 或值为 rejection 的链会就地抛异常,被外层 try-catch 接住——这是 async 相比裸 Promise 链最大的工程红利:

async function load() { try { const res = await fetch('不存在的地址'); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (e) { console.log('统一接住:', e.message); } finally { console.log('清理逻辑总会执行'); } } load(); // 实测:统一接住:Failed to fetch(离线或地址无效时)→ 清理逻辑总会执行

对比 4.2 节的链式 catch:语义完全等价(都是找最近的 rejection 处理器),但 try-catch 版可以让同步异常与异步 rejection 共用一套处理,且作用域里的中间变量(res、解析结果)全程可见。

二、顺序陷阱:循环里的 await

await 天然是串行的——它字面意思就是「等这个完」。放在循环里,每个迭代都要排队:

async function serial(urls) { const results = []; for (const u of urls) { results.push(await fetch(u)); // 逐个等待:总耗时≈各请求之和 } return results; }

三个请求各 300 毫秒,串行 900 毫秒。并发版的关键是先启动、后等待——启动 Promise 是同步动作(fetch 一调用就发出),await 只是等结果:

async function parallel(urls) { const promises = urls.map(u => fetch(u)); // 同时全部发出 return Promise.all(promises); // 总耗时≈最慢的一个 }

第三种形态介于两者之间:限流并发。比如同时最多三个请求,用分批 all 或滑动窗口:

async function pooled(urls, limit = 3) { const results = []; for (let i = 0; i < urls.length; i += limit) { const batch = urls.slice(i, i + limit); results.push(...await Promise.all(batch.map(u => fetch(u)))); } return results; // 分批等待:吞吐与压力的折中 }

选型判断题化为一句:「上一个的结果是否是下一个的输入」——是则必须串行(分页拉取下一页要带上一页的游标),否则并发;并发再看是否怕压垮对方,怕则限流。for...of 里滥用 await 是性能评审的常客,见到先问这题。

还有一个静默的坑:forEach 里写 await 无效。forEach 的回调是普通函数调用,不等待任何 Promise,循环瞬间跑完:

[1, 2, 3].forEach(async id => { await new Promise(r => setTimeout(r, 100)); console.log('完成', id); }); console.log('forEach 已返回'); // 实测:forEach 已返回 →(100ms 后)完成 1 完成 2 完成 3 几乎同时

需要顺序等待的循环用 for...of;需要对每个元素并发再统一收口,用 map + Promise.all。forEach 与 async 的组合基本可以判定为错误代码。

三、async 函数的错误传播与调用约定

async 函数永远返回 Promise,哪怕函数体里 return 了一个原始值——引擎自动包 Promise。这意味着调用 async 函数的世界里没有「裸值」,错误也以 rejection 形式向外传播,直到被某个 await 加 try-catch 或 .catch 接住。如果一层都不接,就是 unhandledrejection——第 7 章全局兜底的主角,这里先立个牌子:async 函数的调用者必须处理它的 rejection,编译器不会提醒你

顶层 await(模块顶层的 await)让模块本身变成异步加载单元:依赖它的模块必须等它落定。适合「模块初始化需要拉配置」的场景,代价是拖慢依赖链加载,公共库慎用。

最后用一张对照表收束三节的接力:裸 Promise 链与 async-await 是同一状态机的两种语法,选型主要看团队习惯与场景形态。

场景 推荐写法 理由
顺序依赖的多步异步 async + 循环内 await 视觉顺序即执行顺序
无依赖的批量异步 map + Promise.all 并发,总耗时取最慢
超时控制 Promise.race 包 timeout 竞速语义天然匹配
回调风格的老 API 包一层 new Promise(4.4 节) 一次性改造,永久受益
事件订阅等多次触发的流 不适合 Promise Promise 只落定一次,用事件或异步迭代器
  • async 函数必返 Promise,await 是挂起点:后半段打包成微任务,函数立刻让出栈;
  • await 右侧三态归一:Promise 直等、thenable 转换、普通值包一拍;
  • try-catch-finally 罩得住异步错误,这是相对裸链最大的工程红利;
  • 并发先启动后等待,forEach 里 await 无效,循环等待用 for...of;
  • 调用者必须处理 rejection,没人接就是 unhandledrejection。

综合演练:把回调风格完整翻译成 async

一段贴近老项目的三步流程(查用户、拉订单、取明细,错误优先回调风格),做一次完整翻译:

// 原始形态:错误优先回调,三层嵌套 function loadLegacy(userId, done) { fetchUser(userId, (err, user) => { if (err) return done(err); fetchOrders(user, (err, orders) => { if (err) return done(err); fetchDetail(orders[0].id, (err, detail) => { if (err) return done(err); done(null, { user, orders, detail }); }); }); }); }

第一步:每个回调 API 包成 Promise(4.4 节的 promisify 手工版):

const fetchUserP = id => new Promise((res, rej) => fetchUser(id, (e, v) => e ? rej(e) : res(v))); const fetchOrdersP = user => new Promise((res, rej) => fetchOrders(user, (e, v) => e ? rej(e) : res(v))); const fetchDetailP = oid => new Promise((res, rej) => fetchDetail(oid, (e, v) => e ? rej(e) : res(v)));

第二步:async 函数串起来,错误统一 try-catch:

async function loadModern(userId) { try { const user = await fetchUserP(userId); const orders = await fetchOrdersP(user); // 真依赖:串行合理 const detail = await fetchDetailP(orders[0].id); return { user, orders, detail }; } catch (e) { console.log('任一步失败都到这:', e.message); throw e; // 处理不了就上抛,别吞 } }

翻译后的对照收益清单:嵌套归零,中间变量作用域贯穿全程;三层 if (err) 合并成一个 catch;返回值从「回调传参」变成函数返回值,调用方还能继续 await。注意串行判断——这里每步都依赖上一步结果,串行正确;若第二步与第三步互不依赖,就该并发(先启动后 await),别让翻译惯性带走并发机会。

第三步:调用方配超时与并发盲区检查。 给 loadModern 套 withTimeout(上节组合件),再确认没有「await 循环里藏串行」——三步翻译下来,这段老代码的现代化就完成了,行为可实测对照(两版对同一输入产出相同结果)。

常见问答

await 能用在普通函数里吗?
不能,await 只出现在 async 函数体(或模块顶层)。语法层面直接报错,不会静默失败——这比某些语言的隐式异步传播要友好。看到「await is only valid in async functions」就回去给函数加 async,加完后记得检查调用链:async 会传染,调用它的函数通常也得 async 化。

顶层 await 会拖慢页面吗?
会拖慢「依赖它的模块」的可用时机:模块在顶层 await 落定前不算加载完成,依赖链上的模块全部排队。适合「模块初始化必须有配置」的场景;公共库与被广泛依赖的模块应避免,改用「导出一个初始化函数让使用者自己 await」的形态,把等待的决策权交出去。

async 函数里的循环,什么时候必须 for...of?
「必须顺序、且每轮要等结果」的时候:分页抓取(下一页要上一页的游标)、按序写日志、限速逐个请求。其余情况优先 map 加 all(无依赖并发)或分批 all(限流并发)。一个快速判断:把循环体里的 await 删掉看逻辑是否还成立——成立就说明这里本不需要顺序,是白白串行。

async 递归:不会爆栈的递归

同步递归怕栈配额(第 2 章),async 递归却天然免疫——因为每次 await 都是挂起点,恢复时从空栈起步:

async function countDown(n) { if (n === 0) return '完成'; await new Promise(r => setTimeout(r, 0)); // 挂起:当前栈帧弹出 return countDown(n - 1); // 恢复后在新栈上继续 } countDown(20000).then(v => console.log(v)); // 完成 —— 两万层深度不爆栈

同样的深度用同步递归早爆了(第 2 章实测万帧即上限)。代价同样明确:返回值要走 Promise 链传递,无法用普通的返回值类型推断(外层拿到的一定是 Promise);每层有一次微任务调度开销,深递归比同步版慢一个量级。适用判断也简单:递归的每步需要真正等待(IO、定时)时用 async 递归,纯计算请回显式栈——本节的模式解决的是「等待的深度」,不是「计算的深度」。

顺带把 async 函数的 this 说清:async 不改变 this 规则,函数体里的 this 仍按第 2 章四条规则判定(通常是隐式或默认绑定);箭头函数式的 async(async () => {})则照抄外层。容易混淆的点是「async 看起来像特殊函数」,实际上它在 this 语义上毫无特殊性——特殊只在返回值与挂起点两处。

async 化改造的评审清单

老代码 async 化是高频评审场景,一份清单防止改写引入新问题。逐项过:

其一,串行还是并发审过了吗。 原来回调嵌套的「不得不串行」改写时常常被原样翻译成逐个 await——但其中若有步骤互不依赖,就白白丢了并发。评审时对每个 await 问一句「它的输入依赖上一步的输出吗」。

其二,错误处理补齐了吗。 回调版的错误散在各层 if (err) 里,翻译时容易只搬快乐路径。每个 await 点都要么在 try 内、要么确认上层有 catch(第 7 章的三层防线)。

其三,返回值链闭合了吗。 async 函数返回 Promise,调用方需要 await 或 then;「翻译了函数却没翻译调用方」会导致返回值变成 Promise 对象被当作数据使用(打印出 Promise 字样的对象)。

其四,循环里的 await 是有意为之吗。 for...of 内 await 是串行语义,forEach 内 await 是无效写法(本节专题)——翻译时最容易把两者搞混的位置,值得单独标记评审。

其五,共享状态的时序变了吗。 回调版的执行时机由事件驱动,await 版由调用链驱动;原来「注册即异步」的代码现在「await 后才继续」,若有全局可变状态的交错访问,时序可能变化。并发安全问一遍再合入。

五条清单背后是同一个思想:async 改写是行为等价变换,等价性要逐点论证,不是语法替换。评审时把这五条当核对项打勾,翻译事故率会显著下降。

调试 async 代码有什么特别技巧?
两个动作最有效:其一,断点配合「异步调用栈」选项——开发者工具默认把异步延续当成无主回调,开启异步栈追踪后,await 后半段的栈里会带上「逻辑上的调用链」(引擎在挂起时保存的上下文);其二,给每个异步函数入口打点日志带序号,肉眼重建时序——调度顺序的混乱只能靠时序日志看清,断点是「时间放大镜」看不了全局。两个技巧解决的是 async 排障的两类顽疾:「栈断在回调边界」与「顺序不符合直觉」。

糖拆到这里已经见底:async 是返回 Promise 的函数,await 是挂起加排队的延续。沿途拆出的还有三种右侧归一、循环与并发的选型口诀、async 化改造的五条评审清单,以及 async 递归不爆栈的原理——它们共同保证你写下的每颗糖都甜在明处。最后一节回望来路——在这颗糖出现之前,异步是怎么从回调一路演化到生成器方案的。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U