本文目录
提交报销申请后,审批可能两小时才回来。你不会站在财务门口两小时什么也不做,而是先处理邮件;通知到达,再回到报销流程的下一步。
await 也不是让整个 JavaScript 世界停住。它暂停当前 async 函数后面的执行,把控制权还给调用者;等待结果准备好,再安排函数从暂停处继续。
等审批的人可以先去处理下一件事
Promise 链可以表达这段流程:
submitExpense()
.then((approval) => saveApproval(approval))
.then(() => console.log('报销流程结束'))步骤一多,业务的先后关系散落在回调里。await 允许我们按阅读顺序写:
async function finishExpense() {
const approval = await submitExpense()
await saveApproval(approval)
console.log('报销流程结束')
}看起来像同步代码,但 submitExpense() 等待期间,浏览器仍可处理其他任务。关键不是代码长得像什么,而是 async 函数怎样返回 Promise、await 后半段怎样恢复。
先猜输出:await 普通数字也会让出执行权吗
async function check() {
console.log('函数开始')
const value = await 0
console.log('恢复执行', value)
}
console.log('脚本开始')
check()
console.log('脚本结束')输出是:
脚本开始
函数开始
脚本结束
恢复执行 0await 0 等的不是一个真正耗时的操作,后半段仍不会同步接着跑。await 会把表达式结果按 Promise/thenable 解析规则处理,函数的继续执行被安排到之后。当前调用先返回一个 Promise,脚本打印“脚本结束”,恢复部分再通过微任务相关机制运行。
这项保证让 await 前后形成清楚的异步边界,不会因为某个值恰好已准备好,就突然变成同步执行。
async 函数从调用开始就返回 Promise
无论函数里有没有 await,async function 调用结果都是 Promise:
async function price() {
return 12
}
const result = price()
console.log(result instanceof Promise) // true
console.log(await result) // 12返回普通值时,调用得到的 Promise 最终 fulfilled 为该值。抛出异常时,Promise rejected:
async function loadOrder() {
throw new Error('订单不存在')
}
loadOrder().catch((error) => {
console.log(error.message)
})异常没有从 loadOrder() 调用表达式同步抛给外层普通 try/catch;它成为返回 Promise 的拒绝。调用者必须 await 或注册 rejection 处理。
即使 async 函数直接 return 一个现有 Promise,调用返回的也不是规范上要求与原 Promise 对象身份相同的那个对象。通常业务关心采用相同最终状态,不应依赖两者 ===。
await 暂停的是当前函数后半段
执行到 await expression 时,先求值表达式。若得到的是待完成 Promise,当前 async 函数不能继续取得结果,于是把后续执行挂起,并先把自己的 Promise 返回给调用者。
async function page() {
console.log('请求前')
const response = await fetch('/api/user')
console.log('请求后', response.status)
}
const promise = page()
console.log('page 已返回', promise)“请求前”同步出现,因为 async 函数会从入口开始执行,直到遇到第一个真正的异步边界。“page 已返回”随后出现。网络完成以后,“请求后”才恢复。
这不是把整条 JavaScript 线程冻结在 await 那一行。调用栈会归还给宿主,其他任务和微任务可以运行。恢复时也不是把当初整座调用栈从仓库搬回来,而是语言按 async 函数的保存状态继续执行后半段。
外层若也需要等待 page,它可以 await page();于是暂停关系逐层变成 Promise 依赖,而不是同步栈一直压着不返回。
普通值、Promise 和 thenable 都能被等待
await 不只接受原生 Promise:
console.log(await 42)
console.log(await Promise.resolve('完成'))
console.log(await {
then(resolve) {
resolve('thenable 完成')
},
})带可调用 then 的对象称为 thenable。Promise 解析过程会调用它的 then,采用最终结果。它让不同 Promise 实现可以互操作,也意味着不可信 thenable 的行为可能很奇怪:多次调用回调、抛异常或永不结束。标准解析流程会处理“一次确定状态”等边界,但业务仍应谨慎接收第三方对象。
等待普通值也会形成异步恢复,前面的 await 0 已经证明。不要把 await 理解成“如果右边不是 Promise 就原样同步返回”。
右侧表达式在暂停前求值。如果 getData() 同步抛错,async 函数也会沿当前 try/catch 处理;如果没有处理,返回 Promise rejected。
try/catch 接住的是哪一段错误
await 的好处之一,是可用结构化异常处理覆盖异步拒绝:
async function showProfile() {
try {
const response = await fetch('/api/profile')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const profile = await response.json()
renderProfile(profile)
} catch (error) {
showError(error)
} finally {
hideLoading()
}
}try 能接住表达式同步求值时的异常、被等待 Promise 的 rejection,以及恢复后代码抛出的错误。finally 无论成功失败都会执行,也可以包含 await,这时整个 async 函数的结束还要等清理完成。
但 fetch 收到 404 或 500 通常仍会 fulfilled 为 Response;只有网络失败、取消等情况才直接 rejected,所以要自己检查 response.ok。try/catch 不是 HTTP 业务状态的自动判断器。
若在 try 中启动 Promise 却既不 await 也不返回它,之后发生的 rejection 不在这段结构化等待链里:
try {
saveLater() // 没有 await
} catch {
// 通常接不到 saveLater 之后的异步 rejection
}要么 await saveLater(),要么明确返回或单独处理它。
连写两个 await 为什么可能白等
下面两个请求彼此独立,却被写成串行:
const user = await fetchUser()
const products = await fetchProducts()第二个函数要等第一个 Promise fulfilled 后才调用。若两者不依赖,可以先启动,再等待:
const userPromise = fetchUser()
const productsPromise = fetchProducts()
const [user, products] = await Promise.all([
userPromise,
productsPromise,
])这里并发发生在两个函数调用已经启动各自工作,Promise.all 只负责汇总结果。JavaScript 主线程没有因此并行执行两段 CPU 代码;网络等宿主工作可以重叠进行。
若后一个请求需要前一个结果,串行就是正确关系:
const user = await fetchUser()
const orders = await fetchOrders(user.id)不要为了追求“并发”把真实依赖拆坏,也不要无上限同时启动大量请求。并发数、失败策略和取消应按接口容量设计。
Promise.all 遇到一个 rejection 会立刻让聚合 Promise rejected,但其他已启动操作不会自动取消。需要取消时,要给底层操作传入 AbortSignal 或相应取消能力。
一段函数可以有多个恢复点
每个 await 都可能把函数拆成前后两个阶段:
async function checkout() {
console.log('1. 创建订单')
const order = await createOrder()
console.log('2. 发起支付')
const receipt = await pay(order)
console.log('3. 展示结果')
return receipt
}第一次调用先运行到 createOrder() 的等待处。恢复后,才会调用 pay(order);第二次恢复再完成函数。局部绑定 order 在暂停期间仍可供后半段使用,但同步调用栈已经归还。
若组件或页面在等待期间被销毁,函数后半段仍可能恢复。语言不知道 UI 生命周期,所以业务需要在恢复后检查状态或取消底层请求:
const controller = new AbortController()
async function load(signal) {
const response = await fetch('/api/data', { signal })
return response.json()
}
controller.abort()取消 Fetch 会让相关 Promise rejected,调用方仍要处理取消错误。await 提供暂停语义,不自动替应用管理“这个结果现在还要不要”。
循环里的 await 是清楚的顺序声明
对一组工作逐项等待:
for (const file of files) {
await upload(file)
}这不是语法错误,也不一定是性能 bug。若服务器要求按顺序上传、后一个依赖前一个版本号,或希望一次只占一个连接,它很合适。
若任务独立且数量可控,可以:
await Promise.all(files.map((file) => upload(file)))可是 map(async ...) 产生的是 Promise 数组。只写:
files.map(async (file) => upload(file))外层不会等待这些 Promise,错误也可能变成未处理 rejection。要么聚合并 await,要么明确把后台任务交给有监控和错误处理的机制。
大量文件不适合无上限 Promise.all。可使用固定数量 worker 从队列取任务,或借助成熟的并发限制工具。这里优化的是底层工作重叠,不是让 async 回调本身在主线程上并行。
顶层 await 会影响模块依赖
在 ES module 中可以使用 top-level await:
const config = await fetch('/config.json').then((response) => response.json())
export { config }依赖这个模块的其他模块需要等它的异步求值完成,才能继续相应的模块执行。它不是把整个浏览器脚本都变成一个大 async 函数,却会把等待传播到模块依赖图。
配置必须先准备好时,这很直观;放在被很多模块依赖的基础模块里,则可能延迟一大片模块的启动。循环依赖再叠加 top-level await 还会更难推理。是否使用要看模块初始化关系,而不是只因为它省掉一层函数。
普通经典脚本和非 async 函数中不能随便写 await。看到语法错误时先确认文件是否作为模块执行、所在函数是否为 async,而不是怀疑 Promise 状态。
async 调用链里的错误要有人最终负责
每个 async 函数都返回 Promise,调用者可以继续等待:
async function controller() {
return service()
}
async function service() {
return repository()
}如果没有任何一层捕获,repository 的 rejection 会沿 Promise 链传播到最外层。分层代码不必每层都 catch 后原样再抛;应该在能增加语境、执行补偿或转成用户状态的边界处理。
捕获后只打印、不重新抛出,会把返回 Promise 变成 fulfilled:
async function save() {
try {
await writeData()
} catch (error) {
console.error(error)
}
}调用者会以为保存成功结束。若失败仍应由上层决定,记录后要重新 throw,或返回明确的结果类型。异常处理不是越靠近 await 越好,而是要明确哪一层拥有恢复责任。
同样,故意启动且不等待的“即发即忘”任务也必须有负责人:至少附加 rejection 处理、日志与生命周期取消。省略 await 不是取消等待成本,而是把完成和失败从当前调用链中移走。
代码评审时看到孤立的 async 调用,应明确追问它由谁观察失败、页面离开后是否仍该继续。这比一律要求“加 await”更接近真实问题。
return await 不能被一句“多余”打发
有人会把所有 return await promise 改成 return promise。在简单函数中,两者通常给调用者相同的最终结果:
async function simple() {
return fetchData()
}但放进 try/catch,差别很实际:
async function withContext() {
try {
return await fetchData()
} catch (error) {
throw new Error('读取首页数据失败', { cause: error })
}
}await 让 rejection 在当前 try 内恢复并被捕获。若直接 return fetchData(),函数已经返回,Promise 之后的 rejection 通常不会再经过这个 catch。
现代引擎对 return await 已有专门优化,它还可能改善异步错误堆栈。是否保留应看异常语义和代码清晰度,而不是套用过时的“一定多一个 tick、一定删除”规则。
容易踩的坑:async/await 不是规范里的生成器改写
生成器和 async 函数都能暂停恢复,早期工具也曾用生成器加运行器模拟 async/await。这是很好的历史与实现类比,却不是 ECMAScript 规范把 async 函数定义成“先改写为 generator 再执行”。
两者公开接口不同:生成器调用返回 iterator,由外部主动 next();async 函数调用返回 Promise,await 根据等待结果自动安排恢复。生成器可以交出多项,普通 async 函数只最终完成一次。
另一个误解是“await 阻塞”。如果它阻塞线程,页面在网络请求期间就无法点击。准确说法是它暂停当前 async 函数的后续求值。若 await 右边之前有一个五秒同步循环,那五秒照样阻塞。
也不要在不需要顺序的每一行前机械加 await。它会建立真实的先后关系,可能把原本可重叠的工作变成排队。
验证题:把串行请求改成同时启动
先运行串行版本:
const waitFor = (name, ms) => new Promise((resolve) => {
setTimeout(() => resolve(name), ms)
})
async function serial() {
const start = performance.now()
const a = await waitFor('A', 300)
const b = await waitFor('B', 300)
console.log(a, b, performance.now() - start)
}再写并发版本:先调用两次 waitFor,保存 Promise,然后 await Promise.all。前者约 600 毫秒,后者约 300 毫秒,实际数值会受调度影响。
接着把第二个操作改成依赖第一个返回值,解释为什么此时不能安全并发。最后给其中一个 Promise 制造 rejection,观察 try/catch 放在 await Promise.all 外面时如何处理。
下一步:响应很大时为什么要一次读完
await response.json() 很方便,但它要读取完整正文并解析后才给出结果。如果服务端持续输出日志、模型回答或大量记录,我们可能希望第一部分到达就开始处理。
Fetch 的 Response.body 提供字节流;流的 reader 像异步 iterator 一样逐次交出数据,而 async generator 又能把字节适配成更好用的文本消息。最后一章把调用栈、事件循环、微任务、异步迭代和 await 放进同一个真实流程。