前端开发2026-03-18 13 分钟

JavaScript 异步编程进阶:从回调地狱到 async/await 的演进之路

从回调函数到 Promise,再到 async/await 和并发控制,系统梳理 JavaScript 异步编程的演进历程,掌握每种模式的适用场景和最佳实践。

JavaScript 是一门单线程语言,但它的运行环境(浏览器和 Node.js)从来不是单线程的。从最早的回调函数到如今的 async/await,异步编程的范式经历了多次革命性的演进。理解这条演进之路,不只是为了面试——更是为了写出可维护、可调试的异步代码。

这篇文章不会从零讲「什么是异步」,而是侧重每种模式的实际痛点和最佳实践。每个章节都配有可运行的代码示例和常见错误对比,看完直接能在项目里用。

1. 回调函数时代:一切的起点

在 Promise 出现之前,Node.js 和浏览器中处理异步操作的唯一方式就是回调函数。读文件、发请求、定时器——所有异步操作都需要传入一个回调。一层还好,多层嵌套就是噩梦的开始:

// ❌ 经典的回调地狱(Callback Hell)
getUser(userId, function (err, user) {
  if (err) {
    console.error('获取用户失败:', err)
    return
  }
  getOrders(user.id, function (err, orders) {
    if (err) {
      console.error('获取订单失败:', err)
      return
    }
    getOrderDetail(orders[0].id, function (err, detail) {
      if (err) {
        console.error('获取详情失败:', err)
        return
      }
      getProductInfo(detail.productId, function (err, product) {
        if (err) {
          console.error('获取商品失败:', err)
          return
        }
        console.log('最终结果:', product)
        // 还要继续嵌套?...
      })
    })
  })
})

这段代码有三个致命问题:

// 问题 1:错误处理分散在每一层,容易遗漏
// 问题 2:嵌套层级越深,缩进越深,可读性急剧下降
// 问题 3:无法轻松实现「并行执行多个异步操作后统一处理结果」

// 尝试用命名函数拆分,但本质上只是移动了代码,没有解决控制流问题
function handleUser(err, user) {
  if (err) return console.error(err)
  getOrders(user.id, handleOrders)
}
function handleOrders(err, orders) {
  if (err) return console.error(err)
  getOrderDetail(orders[0].id, handleDetail)
}
function handleDetail(err, detail) {
  if (err) return console.error(err)
  getProductInfo(detail.productId, handleProduct)
}
function handleProduct(err, product) {
  if (err) return console.error(err)
  console.log('最终结果:', product)
}

getUser(userId, handleUser)  // 稍微清晰一点,但跳转阅读很痛苦

回调模式的核心缺陷

回调函数最大的问题不是缩进,而是「控制反转」(Inversion of Control)。你把后续逻辑的执行权交给了被调用的函数——它可能调用你的回调零次、一次或多次,可能同步调也可能异步调,你完全无法控制。Promise 正是为了解决这个信任问题而诞生的。

2. Promise:告别嵌套地狱

ES6 引入的 Promise 彻底改变了异步编程的写法。一个 Promise 对象代表一个「最终会完成(或失败)的异步操作及其结果」,它有三种状态:

// Promise 的三种状态:
// 1. pending   — 初始状态,既没有成功,也没有失败
// 2. fulfilled — 操作成功完成(调用了 resolve)
// 3. rejected  — 操作失败(调用了 reject 或抛出异常)

// 状态一旦从 pending 变为 fulfilled 或 rejected,就不可逆转
const promise = new Promise((resolve, reject) => {
  // 异步操作...
  if (success) {
    resolve(data)   // pending → fulfilled
  } else {
    reject(error)   // pending → rejected
  }
})

Promise 的杀手锏是链式调用。同样的「获取用户→获取订单→获取详情」逻辑,用 Promise 写成一条扁平的链:

// ✅ Promise 链式调用:扁平结构,清晰可读
getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetail(orders[0].id))
  .then(detail => getProductInfo(detail.productId))
  .then(product => {
    console.log('最终结果:', product)
  })
  .catch(err => {
    // 统一错误处理!任何一步失败都会被这里捕获
    console.error('操作失败:', err)
  })
  .finally(() => {
    // 无论成功还是失败,都会执行(适合清理工作)
    hideLoading()
  })

错误传播是 Promise 链的一个重要特性。如果链中任何一个 .then() 抛出异常或返回 rejected Promise,错误会沿着链「跳过」后续的 .then(),直接被最近的 .catch() 捕获:

// 错误传播示意
Promise.resolve('start')
  .then(val => {
    console.log(val)           // 'start'
    throw new Error('boom!')   // 抛出错误
  })
  .then(val => {
    console.log('这里不会执行')  // 被跳过
  })
  .then(val => {
    console.log('这里也不会执行') // 被跳过
  })
  .catch(err => {
    console.error(err.message)  // 'boom!' — 被捕获
    return 'recovered'          // 从错误中恢复,链可以继续
  })
  .then(val => {
    console.log(val)            // 'recovered' — catch 之后可以继续链
  })

微任务队列(Microtask Queue)

Promise 的 .then() 回调不是同步执行的,而是被放入「微任务队列」。微任务的优先级高于宏任务(setTimeout、setInterval),这就是为什么 Promise.resolve().then(() => console.log(1)) 会比 setTimeout(() => console.log(2), 0) 先执行。理解事件循环机制对调试异步代码至关重要。

3. async/await:同步风格写异步

ES2017 引入的 async/await 是 Promise 的语法糖。它让异步代码看起来几乎和同步代码一模一样,但背后依然基于 Promise 运行。

// ✅ async/await:读起来就像同步代码
async function getProductForUser(userId) {
  const user = await getUser(userId)
  const orders = await getOrders(user.id)
  const detail = await getOrderDetail(orders[0].id)
  const product = await getProductInfo(detail.productId)
  return product
}

// 调用方式
getProductForUser('user-123')
  .then(product => console.log(product))
  .catch(err => console.error(err))

async 函数本质上返回一个 Promise。await 会暂停函数执行,等待 Promise 完成后继续往下走。错误处理使用传统的 try/catch:

// 错误处理:try/catch 比 .catch() 更直观
async function fetchData() {
  try {
    const user = await getUser(userId)
    const orders = await getOrders(user.id)
    return orders
  } catch (err) {
    // 任何一个 await 抛出的错误都会被捕获
    console.error('请求失败:', err.message)
    // 可以返回默认值、重试或重新抛出
    throw err
  } finally {
    // 无论成功失败都执行
    hideLoading()
  }
}

但 try/catch 套多了也很烦。一个常见的技巧是封装一个工具函数,把 try/catch 变成解构赋值:

// 工具函数:优雅的错误处理
async function to(promise) {
  try {
    const data = await promise
    return [null, data]
  } catch (err) {
    return [err, null]
  }
}

// 使用方式:不需要 try/catch,通过解构判断
async function main() {
  const [err, user] = await to(getUser(userId))
  if (err) {
    console.error('获取用户失败:', err)
    return
  }

  const [err2, orders] = await to(getOrders(user.id))
  if (err2) {
    console.error('获取订单失败:', err2)
    return
  }

  console.log('订单列表:', orders)
}

语法糖不代表可以忽略 Promise

async/await 只是让代码「看起来」同步,底层依然是 Promise 和事件循环。如果你不理解 Promise 的链式传播、微任务队列和状态不可逆等概念,使用 async/await 时遇到的 bug 会更难调试。建议先彻底掌握 Promise,再使用 async/await。

4. 并发控制:Promise.all vs Promise.allSettled vs Promise.race

实际开发中,经常需要同时发多个请求。JavaScript 提供了四个静态方法来处理 Promise 的并发场景,它们的行为差异非常关键:

// Promise.all — 全部成功才算成功,一个失败就全部失败
// 适用场景:多个请求全部成功才有意义(例如加载页面所有模块数据)
const [users, orders, products] = await Promise.all([
  fetchUsers(),
  fetchOrders(),
  fetchProducts()
])
// 如果 fetchOrders() 失败,整个 Promise.all 立即 reject
// 其他请求的结果会被丢弃(但请求本身不会被取消!)
// Promise.allSettled — 等所有 Promise 都完成(无论成功或失败)
// 适用场景:需要知道每个请求的结果,失败的不影响其他(例如批量操作)
const results = await Promise.allSettled([
  fetchUsers(),
  fetchOrders(),
  fetchProducts()
])

results.forEach(result => {
  if (result.status === 'fulfilled') {
    console.log('成功:', result.value)
  } else {
    console.error('失败:', result.reason)
  }
})
// 永远不会 reject,即使所有 Promise 都失败
// Promise.race — 最先完成的(无论成功或失败)决定结果
// 适用场景:请求超时控制、多源竞速
async function fetchWithTimeout(url, ms) {
  return Promise.race([
    fetch(url),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('请求超时')), ms)
    )
  ])
}

const response = await fetchWithTimeout('/api/data', 5000)
// 5 秒内没响应就自动超时
// Promise.any — 最先成功的决定结果(忽略失败)
// 适用场景:多个备选方案,只要一个成功就行(例如 CDN 多源加载)
const fastest = await Promise.any([
  fetch('https://cdn1.example.com/data.json'),
  fetch('https://cdn2.example.com/data.json'),
  fetch('https://cdn3.example.com/data.json')
])
// 只有全部失败时才会 reject,抛出 AggregateError

选择哪一个?

Promise.all:需要全部成功,一个都不能少。Promise.allSettled:不在乎失败,需要每一个的结果。Promise.race:只关心最快的那一个(成功或失败都算)。Promise.any:只关心最快成功的那一个。在实际项目中,Promise.allPromise.allSettled 使用频率最高。

5. 实战:实现一个并发限制器

Promise.all 有一个问题:如果传入 1000 个请求,它会「同时」发出去。这可能导致服务器扛不住、浏览器连接数耗尽,或者触发接口的限流策略。我们需要一个并发限制器,控制同时最多执行 N 个异步任务:

// 并发限制器:同时最多执行 limit 个异步任务
async function asyncPool(limit, items, iteratorFn) {
  const results = []       // 存放所有结果
  const executing = []     // 当前正在执行的 Promise

  for (const [index, item] of items.entries()) {
    // 创建一个 Promise,执行异步任务
    const p = Promise.resolve().then(() => iteratorFn(item, index))
    results.push(p)

    // 当 items 总数 > 并发限制时,需要控制并发
    if (items.length >= limit) {
      // 任务完成后从 executing 中移除
      const e = p.then(() => executing.splice(executing.indexOf(e), 1))
      executing.push(e)

      // 当并发数达到上限,等待最快完成的那个
      if (executing.length >= limit) {
        await Promise.race(executing)
      }
    }
  }

  // 等待所有任务完成
  return Promise.all(results)
}
// 使用示例:批量下载 100 张图片,同时最多 5 个并发
const imageUrls = Array.from({ length: 100 }, (_, i) => `/images/${i}.jpg`)

async function downloadImage(url) {
  const response = await fetch(url)
  const blob = await response.blob()
  console.log(`下载完成: ${url}, 大小: ${blob.size}`)
  return blob
}

// 同时最多 5 个下载任务
const blobs = await asyncPool(5, imageUrls, downloadImage)
console.log(`全部完成,共 ${blobs.length} 张图片`)

生产环境建议

上面的实现适合理解原理。在生产环境中,推荐使用成熟的库如 p-limitp-queueasync-pool。这些库处理了错误重试、任务优先级、动态调整并发数等边界情况。原理理解了,用库才不会掉坑里。

6. 实战:可取消的异步操作

Promise 有一个设计上的「缺陷」——一旦创建就无法取消。但在实际场景中,取消操作非常常见:用户切换了页面、搜索框输入了新内容、组件卸载了。AbortController 就是为此而生的:

// 基础用法:取消 fetch 请求
const controller = new AbortController()
const { signal } = controller

// fetch 原生支持 signal 参数
fetch('/api/large-data', { signal })
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('请求已取消')
    } else {
      console.error('请求失败:', err)
    }
  })

// 3 秒后取消请求
setTimeout(() => controller.abort(), 3000)

在 Vue 3 和 React 中,组件卸载时取消未完成的请求是最佳实践。下面是一个封装了自动取消逻辑的 useFetch 示例:

// 封装可取消的 fetch(适用于 Vue 3 / React 等框架)
function useCancelableFetch() {
  let controller = null

  async function request(url, options = {}) {
    // 如果有上一个未完成的请求,先取消它
    if (controller) {
      controller.abort()
    }

    controller = new AbortController()

    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      })
      return await response.json()
    } catch (err) {
      if (err.name === 'AbortError') {
        console.log('请求被取消,忽略结果')
        return null
      }
      throw err
    }
  }

  function cancel() {
    if (controller) {
      controller.abort()
      controller = null
    }
  }

  return { request, cancel }
}
// 实战场景:搜索框输入时取消上次请求
const { request, cancel } = useCancelableFetch()

searchInput.addEventListener('input', async (e) => {
  const keyword = e.target.value
  if (!keyword) return

  // 每次输入会自动取消上一次未完成的搜索请求
  const results = await request(`/api/search?q=${keyword}`)
  if (results) {
    renderSearchResults(results)
  }
})

// 组件卸载时取消
// Vue 3: onUnmounted(() => cancel())
// React: useEffect(() => () => cancel(), [])

AbortSignal.timeout()

从 Node.js 17.3+ 和现代浏览器开始,可以用 AbortSignal.timeout(5000) 快速创建一个 5 秒后自动取消的信号,不需要再手动 setTimeout + abort()。此外,AbortSignal.any([signal1, signal2]) 可以组合多个信号——任何一个触发取消,整个操作就取消。

7. 常见陷阱与最佳实践

async/await 虽然让代码看起来简单了,但也引入了新的陷阱。以下是三个最容易踩的坑:

陷阱一:forEach 中的 async 不会等待

// ❌ forEach 中的 async 函数不会被 await
const urls = ['/api/a', '/api/b', '/api/c']

urls.forEach(async (url) => {
  const data = await fetch(url)
  console.log(data)
})
console.log('完成')
// 输出顺序:'完成' 先打印!forEach 不会等待 async 回调

// ✅ 方案 1:用 for...of(串行执行)
for (const url of urls) {
  const data = await fetch(url)
  console.log(data)
}
console.log('完成')  // 全部请求完成后才打印

// ✅ 方案 2:用 Promise.all + map(并行执行)
await Promise.all(urls.map(async (url) => {
  const data = await fetch(url)
  console.log(data)
}))
console.log('完成')  // 全部请求完成后才打印

陷阱二:未捕获的 Promise rejection

// ❌ 忘记 catch 的 Promise 会导致 UnhandledPromiseRejection
async function riskyOperation() {
  throw new Error('something went wrong')
}

riskyOperation()  // 没有 .catch(),也没有 try/catch → 应用崩溃!

// ✅ 始终处理可能的错误
riskyOperation().catch(err => console.error(err))

// 或者用全局兜底(最后一道防线,不应依赖它)
// 浏览器
window.addEventListener('unhandledrejection', event => {
  console.error('未处理的 Promise 拒绝:', event.reason)
  event.preventDefault()
})

// Node.js
process.on('unhandledRejection', (reason) => {
  console.error('未处理的 Promise 拒绝:', reason)
})

陷阱三:竞态条件(Race Condition)

// ❌ 竞态条件:先发的请求可能后返回,覆盖了正确结果
let currentData = null

async function loadData(id) {
  const data = await fetchData(id)
  currentData = data  // 如果用户快速切换 id,后发先至的结果会被覆盖
}

// 用户快速点击:loadData(1) → loadData(2) → loadData(3)
// 如果请求 1 最后返回,currentData 会变成 id=1 的数据,而不是 id=3

// ✅ 方案:用版本号或 AbortController 防止竞态
let requestVersion = 0

async function loadDataSafe(id) {
  const version = ++requestVersion
  const data = await fetchData(id)

  // 只有最新一次请求的结果才会被使用
  if (version === requestVersion) {
    currentData = data
  }
}

更多细节陷阱

还有一些容易忽略的问题:在 .then() 中忘记 return Promise 导致链断裂;把 async 函数赋值给不期望 Promise 的回调(如 Array.sort());在循环中写 await 导致本可以并行的请求变成串行。核心原则:想清楚你需要「串行」还是「并行」,然后选择合适的模式。

8. 异步编程的未来

异步编程的演进并没有停止。以下是一些已经可用或即将到来的新特性:

AsyncIterator 与 for await...of

// 异步迭代器:处理流式数据的利器
async function* fetchPages(baseUrl) {
  let page = 1
  while (true) {
    const response = await fetch(`${baseUrl}?page=${page}`)
    const data = await response.json()

    if (data.items.length === 0) break  // 没有更多数据
    yield data.items
    page++
  }
}

// 用 for await...of 消费异步迭代器
for await (const items of fetchPages('/api/products')) {
  console.log(`获取到 ${items.length} 条数据`)
  renderItems(items)
}
// 每次循环会等待一页数据返回后再请求下一页

Top-level await

// ES2022:模块顶层直接使用 await,无需包裹在 async 函数中
// config.js(ESM 模块)
const response = await fetch('/api/config')
export const config = await response.json()

// main.js
import { config } from './config.js'
// config 在导入时就已经是解析好的值
console.log(config.apiBaseUrl)

Promise.withResolvers()(ES2024)

// 以前:需要在 Promise 外部获取 resolve/reject 很别扭
let resolve, reject
const promise = new Promise((res, rej) => {
  resolve = res
  reject = rej
})

// 现在:Promise.withResolvers() 一行搞定
const { promise, resolve, reject } = Promise.withResolvers()

// 适用场景:在事件回调中 resolve 外部的 Promise
button.addEventListener('click', () => resolve('用户点击了按钮'))
const result = await promise  // 等待用户点击

Readable Streams + async iteration

// 流式读取大文件或 SSE 响应
const response = await fetch('/api/stream')
const reader = response.body.getReader()
const decoder = new TextDecoder()

while (true) {
  const { done, value } = await reader.read()
  if (done) break
  const text = decoder.decode(value, { stream: true })
  console.log('收到数据块:', text)
}

// 更简洁的写法(部分浏览器支持)
for await (const chunk of response.body) {
  console.log('收到数据块:', new TextDecoder().decode(chunk))
}

趋势:从「命令式」到「声明式」

异步编程正在从「手动管理异步流程」向「声明式描述数据依赖关系」演进。React 的 Suspense、Vue 的 <Suspense> 组件、Solid.js 的 createResource 都在尝试让开发者只声明「我需要什么数据」,而框架负责处理加载、错误、竞态等所有异步细节。

总结

从回调到 Promise,再到 async/await,JavaScript 的异步编程范式经历了从「能用」到「好用」的跨越。但工具再好,也需要理解底层原理——Promise 的状态机、微任务队列、事件循环——这些才是写出健壮异步代码的根基。掌握了这些模式之后,不妨也看看 TypeScript 大型项目实战技巧Vue3 源码解析, 把类型安全和框架原理也一并补全。

更多前端技术文章

OneKit 博客持续分享前端工程化、框架原理和最佳实践,帮你在项目中写出更好的代码。