K 的一隅

JavaScript JavaScript 核心机制

async/await 为什么看起来像同步代码

从等待审批时先做别的工作说起,理解 async 函数返回值、await 恢复、错误处理与串并行选择。

11 分钟阅读 更新于 2026-07-23
本文目录

提交报销申请后,审批可能两小时才回来。你不会站在财务门口两小时什么也不做,而是先处理邮件;通知到达,再回到报销流程的下一步。

await 也不是让整个 JavaScript 世界停住。它暂停当前 async 函数后面的执行,把控制权还给调用者;等待结果准备好,再安排函数从暂停处继续。

等审批的人可以先去处理下一件事

Promise 链可以表达这段流程:

js
submitExpense()
  .then((approval) => saveApproval(approval))
  .then(() => console.log('报销流程结束'))

步骤一多,业务的先后关系散落在回调里。await 允许我们按阅读顺序写:

js
async function finishExpense() {
  const approval = await submitExpense()
  await saveApproval(approval)
  console.log('报销流程结束')
}

看起来像同步代码,但 submitExpense() 等待期间,浏览器仍可处理其他任务。关键不是代码长得像什么,而是 async 函数怎样返回 Promise、await 后半段怎样恢复。

先猜输出:await 普通数字也会让出执行权吗

js
async function check() {
  console.log('函数开始')
  const value = await 0
  console.log('恢复执行', value)
}

console.log('脚本开始')
check()
console.log('脚本结束')

输出是:

text
脚本开始
函数开始
脚本结束
恢复执行 0

await 0 等的不是一个真正耗时的操作,后半段仍不会同步接着跑。await 会把表达式结果按 Promise/thenable 解析规则处理,函数的继续执行被安排到之后。当前调用先返回一个 Promise,脚本打印“脚本结束”,恢复部分再通过微任务相关机制运行。

这项保证让 await 前后形成清楚的异步边界,不会因为某个值恰好已准备好,就突然变成同步执行。

async 函数从调用开始就返回 Promise

无论函数里有没有 awaitasync function 调用结果都是 Promise:

js
async function price() {
  return 12
}

const result = price()
console.log(result instanceof Promise) // true
console.log(await result)              // 12

返回普通值时,调用得到的 Promise 最终 fulfilled 为该值。抛出异常时,Promise rejected:

js
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 返回给调用者。

js
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:

js
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 的好处之一,是可用结构化异常处理覆盖异步拒绝:

js
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.oktry/catch 不是 HTTP 业务状态的自动判断器。

若在 try 中启动 Promise 却既不 await 也不返回它,之后发生的 rejection 不在这段结构化等待链里:

js
try {
  saveLater() // 没有 await
} catch {
  // 通常接不到 saveLater 之后的异步 rejection
}

要么 await saveLater(),要么明确返回或单独处理它。

连写两个 await 为什么可能白等

下面两个请求彼此独立,却被写成串行:

js
const user = await fetchUser()
const products = await fetchProducts()

第二个函数要等第一个 Promise fulfilled 后才调用。若两者不依赖,可以先启动,再等待:

js
const userPromise = fetchUser()
const productsPromise = fetchProducts()

const [user, products] = await Promise.all([
  userPromise,
  productsPromise,
])

这里并发发生在两个函数调用已经启动各自工作,Promise.all 只负责汇总结果。JavaScript 主线程没有因此并行执行两段 CPU 代码;网络等宿主工作可以重叠进行。

若后一个请求需要前一个结果,串行就是正确关系:

js
const user = await fetchUser()
const orders = await fetchOrders(user.id)

不要为了追求“并发”把真实依赖拆坏,也不要无上限同时启动大量请求。并发数、失败策略和取消应按接口容量设计。

Promise.all 遇到一个 rejection 会立刻让聚合 Promise rejected,但其他已启动操作不会自动取消。需要取消时,要给底层操作传入 AbortSignal 或相应取消能力。

一段函数可以有多个恢复点

每个 await 都可能把函数拆成前后两个阶段:

js
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 生命周期,所以业务需要在恢复后检查状态或取消底层请求:

js
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 是清楚的顺序声明

对一组工作逐项等待:

js
for (const file of files) {
  await upload(file)
}

这不是语法错误,也不一定是性能 bug。若服务器要求按顺序上传、后一个依赖前一个版本号,或希望一次只占一个连接,它很合适。

若任务独立且数量可控,可以:

js
await Promise.all(files.map((file) => upload(file)))

可是 map(async ...) 产生的是 Promise 数组。只写:

js
files.map(async (file) => upload(file))

外层不会等待这些 Promise,错误也可能变成未处理 rejection。要么聚合并 await,要么明确把后台任务交给有监控和错误处理的机制。

大量文件不适合无上限 Promise.all。可使用固定数量 worker 从队列取任务,或借助成熟的并发限制工具。这里优化的是底层工作重叠,不是让 async 回调本身在主线程上并行。

顶层 await 会影响模块依赖

在 ES module 中可以使用 top-level await:

js
const config = await fetch('/config.json').then((response) => response.json())

export { config }

依赖这个模块的其他模块需要等它的异步求值完成,才能继续相应的模块执行。它不是把整个浏览器脚本都变成一个大 async 函数,却会把等待传播到模块依赖图。

配置必须先准备好时,这很直观;放在被很多模块依赖的基础模块里,则可能延迟一大片模块的启动。循环依赖再叠加 top-level await 还会更难推理。是否使用要看模块初始化关系,而不是只因为它省掉一层函数。

普通经典脚本和非 async 函数中不能随便写 await。看到语法错误时先确认文件是否作为模块执行、所在函数是否为 async,而不是怀疑 Promise 状态。

async 调用链里的错误要有人最终负责

每个 async 函数都返回 Promise,调用者可以继续等待:

js
async function controller() {
  return service()
}

async function service() {
  return repository()
}

如果没有任何一层捕获,repository 的 rejection 会沿 Promise 链传播到最外层。分层代码不必每层都 catch 后原样再抛;应该在能增加语境、执行补偿或转成用户状态的边界处理。

捕获后只打印、不重新抛出,会把返回 Promise 变成 fulfilled:

js
async function save() {
  try {
    await writeData()
  } catch (error) {
    console.error(error)
  }
}

调用者会以为保存成功结束。若失败仍应由上层决定,记录后要重新 throw,或返回明确的结果类型。异常处理不是越靠近 await 越好,而是要明确哪一层拥有恢复责任。

同样,故意启动且不等待的“即发即忘”任务也必须有负责人:至少附加 rejection 处理、日志与生命周期取消。省略 await 不是取消等待成本,而是把完成和失败从当前调用链中移走。

代码评审时看到孤立的 async 调用,应明确追问它由谁观察失败、页面离开后是否仍该继续。这比一律要求“加 await”更接近真实问题。

return await 不能被一句“多余”打发

有人会把所有 return await promise 改成 return promise。在简单函数中,两者通常给调用者相同的最终结果:

js
async function simple() {
  return fetchData()
}

但放进 try/catch,差别很实际:

js
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。它会建立真实的先后关系,可能把原本可重叠的工作变成排队。

验证题:把串行请求改成同时启动

先运行串行版本:

js
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 放进同一个真实流程。