Skip to content

异步流程与 Promise

异步代码的难点不在语法,而在于结果何时回来、失败由谁处理、旧结果是否还能写入页面。先把这三个问题想清楚,再选择 Promiseasync/await

一个 Promise 只描述一次最终结果

Promise 只有三种状态:等待中、已完成、已拒绝。状态一旦从等待中离开就不能再改变,所以它适合表示一次请求、一次文件读取或一次后台计算。

js
function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms)
  })
}

wait(300).then(() => console.log('done'))

不要把 Promise 当作“能随时发消息的对象”。持续事件应使用事件监听、可观察流或明确的订阅接口。

async/await 让顺序流程更容易读

async 函数总会返回 Promise;await 只暂停当前函数,不会阻塞浏览器线程。把可能失败的步骤放在 try/catch 中,并让调用方拿到可处理的错误。

js
async function loadProfile(userId) {
  try {
    const response = await fetch(`/api/users/${encodeURIComponent(userId)}`)
    if (!response.ok) throw new Error(`request failed: ${response.status}`)

    return await response.json()
  } catch (error) {
    console.error('load profile failed', error)
    throw error
  }
}

catch 不是必须在每一层都写。能够补充上下文或转成用户提示的层处理错误;其余层应继续抛出,避免“失败被吃掉后页面一直加载中”。

并发不是把多个 await 排成一行

彼此独立的任务应并发发起,再统一等待。依赖上一步结果的任务才需要串行。

js
const [profile, notices] = await Promise.all([
  getProfile(),
  getNotices()
])
方法适用场景失败行为
Promise.all所有结果都不可缺少任一失败立即拒绝
Promise.allSettled需要汇总每项结果总会完成,逐项查看状态
Promise.race超时或抢占场景最先结束的结果决定状态

处理竞态:旧请求不能覆盖新页面

用户快速切换筛选条件时,先发出的请求可能后返回。用 AbortController 取消旧请求,或者只接受最新一次请求的结果。

js
let controller

async function search(keyword) {
  controller?.abort()
  controller = new AbortController()

  const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, {
    signal: controller.signal
  })

  return response.json()
}

取消是正常控制流,不应当显示为“系统错误”。在错误处理处识别取消原因后安静结束即可。

常见错误:在 forEach 里用 async 回调

需要"依次处理一批异步任务"时,很多人直觉地写 forEach + await,但这正是异步里最隐蔽的坑:

js
// 看起来对,实际不会等待
ids.forEach(async (id) => {
  await deleteUser(id)
})
console.log('全部完成') // 会在删除还没结束时就执行

症状:循环"跑完了"但任务其实没结束;后续代码提前执行;外层的 try/catch 抓不到里面的异常;并发触发还可能撞上接口限流。最迷惑的是——本地数据少时偶尔正常,上线后偶发出错又难复现。

为什么错forEach 不等待回调返回的 Promise,也不会把多个 Promise 串起来。它只是"把回调丢出去",至于回调里的异步什么时候完成、有没有失败,forEach 一概不管。

正确做法:要串行用 for...of,要并发用 Promise.all

js
// 串行:一个完成再做下一个
for (const id of ids) {
  await deleteUser(id)
}

// 并发:同时发起,全部完成再继续
await Promise.all(ids.map((id) => deleteUser(id)))

记住一条:forEach 是同步的,它不认识 async。循环里要 await,就别用 forEach

异步代码检查表

  • 每个加载状态都要有成功、空数据和失败出口。
  • 独立请求使用并发,存在数据依赖时才串行。
  • 重试只用于可安全重试的操作;写入请求先考虑幂等性。
  • 路由切换、组件卸载或条件变化时,取消不再需要的请求。

为复用而记录,为理解而整理。