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.all 和 Promise.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-limit、p-queue 或 async-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 源码解析, 把类型安全和框架原理也一并补全。