JavaScript 异步编程完全指南:从回调地狱到 async/await
深入理解 JavaScript 异步编程的演进历程,从回调函数到 Promise 再到 async/await,掌握异步编程的核心概念和最佳实践。
JavaScript 异步编程完全指南:从回调地狱到 async/await
「这段代码为什么不按顺序执行?」
「我明明先调用了 A,为什么 B 的结果先出来?」
「Promise 到底是个什么玩意儿?」
如果你曾被这些问题困扰过,恭喜你,你正在经历每个 JavaScript 开发者的必经之路——理解异步。
今天小明就带你从头到尾搞懂 JavaScript 的异步编程,保证你看完之后,再也不会被异步代码绕晕。
为什么 JavaScript 需要异步?
单线程的宿命
JavaScript 是单线程的。这意味着它一次只能做一件事。
想象一下,你在餐厅当服务员,但你是「单线程服务员」:
- 点完 1 号桌的菜,你必须站在厨房门口等菜做好
- 菜没好,你不能去服务 2 号桌
- 3 号桌想买单?等着,我还在等 1 号桌的菜呢
这效率也太低了吧?
如果 JavaScript 真这么干,你点开一个网页,发起一个请求,整个页面就卡死了,等请求回来才能动。这用户体验简直是灾难。
异步的救赎
异步就是「非阻塞」的意思。
还是那个服务员:
- 点完 1 号桌的菜,把订单交给厨房,然后去服务 2 号桌
- 厨房做好了会喊你(回调)
- 你听到喊声,再去端菜给 1 号桌
这就是异步模式。你不用干等,可以同时服务多桌客人。
// 同步方式(阻塞)
const data = fetchDataSync(); // 卡在这里等
console.log(data);
console.log('后面的代码'); // 等上面执行完才能执行
// 异步方式(非阻塞)
fetchDataAsync((data) => {
console.log(data);
});
console.log('后面的代码'); // 立即执行,不等
第一阶段:回调函数(Callback)
最原始的异步方案
回调函数是 JavaScript 最早的异步解决方案。
思路很简单:既然我不知道什么时候完成,那你完成了告诉我就行。
// 模拟一个异步操作:2秒后返回数据
function fetchUser(callback) {
setTimeout(() => {
const user = { id: 1, name: '小明' };
callback(user); // 完成后调用回调函数
}, 2000);
}
// 使用
console.log('开始请求...');
fetchUser((user) => {
console.log('收到数据:', user);
});
console.log('请求已发出,继续执行其他代码');
// 输出顺序:
// 开始请求...
// 请求已发出,继续执行其他代码
// (2秒后)收到数据: { id: 1, name: '小明' }
Node.js 的错误优先回调
Node.js 建立了一个约定:回调函数的第一个参数是错误对象。
const fs = require('fs');
// 错误优先回调(Error-First Callback)
fs.readFile('file.txt', 'utf8', (error, data) => {
if (error) {
console.error('读取失败:', error);
return;
}
console.log('文件内容:', data);
});
这个约定很重要,因为异步操作可能失败,我们需要一种统一的方式处理错误。
回调地狱(Callback Hell)
回调函数看起来不错,直到你遇到这种情况:
需求:先获取用户,再根据用户 ID 获取订单,再根据订单获取商品详情。
// 回调地狱示例
getUser(userId, (error, user) => {
if (error) {
handleError(error);
return;
}
getOrders(user.id, (error, orders) => {
if (error) {
handleError(error);
return;
}
getOrderDetail(orders[0].id, (error, detail) => {
if (error) {
handleError(error);
return;
}
getProduct(detail.productId, (error, product) => {
if (error) {
handleError(error);
return;
}
console.log('商品信息:', product);
// 还要继续嵌套吗?我已经晕了...
});
});
});
});
看到那个「金字塔」了吗?这就是臭名昭著的回调地狱。
问题:
- 可读性差:代码不断向右缩进,难以阅读
- 难以维护:逻辑散落在各个回调中
- 错误处理繁琐:每层都要处理错误
- 难以复用:逻辑耦合在一起
小明冷笑话时间:
回调地狱不是地狱,是深渊。 你以为到底了,结果还能继续往下嵌套。
第二阶段:Promise
Promise 是什么?
Promise(承诺)是 ES6 引入的异步解决方案。
你可以把 Promise 想象成一张「期货单」:
- Pending(进行中):订单已下,商品在路上
- Fulfilled(已完成):商品到了,可以取货
- Rejected(已拒绝):商品缺货,订单取消
// 创建一个 Promise
const promise = new Promise((resolve, reject) => {
// 异步操作
setTimeout(() => {
const success = true;
if (success) {
resolve('操作成功!'); // 完成
} else {
reject('操作失败!'); // 拒绝
}
}, 1000);
});
// 使用 Promise
promise
.then((result) => {
console.log(result); // '操作成功!'
})
.catch((error) => {
console.error(error);
});
Promise 的三种状态
┌─────────────┐
│ Pending │
│ (进行中) │
└──────┬──────┘
│
┌───────┴───────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Fulfilled │ │ Rejected │
│ (已完成) │ │ (已拒绝) │
└─────────────┘ └─────────────┘
注意:状态一旦改变就不可逆。Pending 变成 Fulfilled 后,就不能再变成 Rejected。
链式调用:告别回调地狱
Promise 最强大的特性是链式调用。.then() 返回的还是一个 Promise,所以可以继续 .then()。
// 回调地狱 → Promise 链
getUser(userId)
.then((user) => {
return getOrders(user.id);
})
.then((orders) => {
return getOrderDetail(orders[0].id);
})
.then((detail) => {
return getProduct(detail.productId);
})
.then((product) => {
console.log('商品信息:', product);
})
.catch((error) => {
// 统一处理所有错误
console.error('出错了:', error);
});
对比一下:
- 回调地狱:向右发展,金字塔形状
- Promise 链:向下发展,线性流程
Promise 的常用方法
then() - 成功回调
promise.then(
(value) => { /* 成功时执行 */ },
(error) => { /* 失败时执行(可选)*/ }
);
catch() - 错误捕获
promise
.then((value) => { /* ... */ })
.catch((error) => { /* 捕获前面所有的错误 */ });
finally() - 无论成功失败都执行
promise
.then((value) => { /* ... */ })
.catch((error) => { /* ... */ })
.finally(() => {
// 清理工作,无论成功失败都会执行
console.log('请求结束');
});
Promise 的静态方法
Promise.all() - 全部完成
等待所有 Promise 都完成,如果有一个失败,整体失败。
const p1 = fetch('/api/user');
const p2 = fetch('/api/orders');
const p3 = fetch('/api/products');
Promise.all([p1, p2, p3])
.then(([user, orders, products]) => {
// 三个请求都完成了
console.log('用户:', user);
console.log('订单:', orders);
console.log('商品:', products);
})
.catch((error) => {
// 任意一个失败就进这里
console.error('有请求失败:', error);
});
适用场景:多个请求相互独立,需要全部数据才能渲染页面。
Promise.race() - 竞速
返回最先完成的那个 Promise 的结果(无论成功还是失败)。
// 实现请求超时
const fetchData = fetch('/api/data');
const timeout = new Promise((_, reject) => {
setTimeout(() => reject('请求超时'), 5000);
});
Promise.race([fetchData, timeout])
.then((data) => {
console.log('请求成功:', data);
})
.catch((error) => {
console.log(error); // 可能是 '请求超时'
});
Promise.allSettled() - 全部完成(不管成功失败)
等待所有 Promise 都有结果,不管成功还是失败。
const promises = [
fetch('/api/user'),
fetch('/api/maybe-404'),
fetch('/api/products'),
];
Promise.allSettled(promises).then((results) => {
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`请求 ${index} 成功:`, result.value);
} else {
console.log(`请求 ${index} 失败:`, result.reason);
}
});
});
适用场景:需要知道每个请求的结果,不希望一个失败影响其他。
Promise.any() - 任意一个成功
返回第一个成功的 Promise,只有全部失败才算失败。
// 从多个镜像源获取资源,哪个快用哪个
const mirrors = [
fetch('https://mirror1.com/file'),
fetch('https://mirror2.com/file'),
fetch('https://mirror3.com/file'),
];
Promise.any(mirrors)
.then((response) => {
console.log('从最快的镜像获取到了:', response);
})
.catch((error) => {
console.log('所有镜像都失败了');
});
Promise 的常见陷阱
陷阱 1:忘记 return
// ❌ 错误:忘记 return
getUser()
.then((user) => {
getOrders(user.id); // 这个 Promise 被「吞掉」了
})
.then((orders) => {
console.log(orders); // undefined!
});
// ✅ 正确:记得 return
getUser()
.then((user) => {
return getOrders(user.id); // 返回 Promise
})
.then((orders) => {
console.log(orders); // 正确获取到 orders
});
陷阱 2:在 then 里面嵌套 then
// ❌ 错误:Promise 地狱(和回调地狱一样糟糕)
getUser()
.then((user) => {
getOrders(user.id)
.then((orders) => {
getOrderDetail(orders[0].id)
.then((detail) => {
// 又嵌套了...
});
});
});
// ✅ 正确:扁平化链式调用
getUser()
.then((user) => getOrders(user.id))
.then((orders) => getOrderDetail(orders[0].id))
.then((detail) => console.log(detail));
陷阱 3:没有处理错误
// ❌ 危险:没有 catch,错误会被吞掉
getUser()
.then((user) => {
throw new Error('出错了');
});
// 这个错误不会有任何提示
// ✅ 安全:添加 catch
getUser()
.then((user) => {
throw new Error('出错了');
})
.catch((error) => {
console.error('捕获到错误:', error);
});
第三阶段:async/await
语法糖的甜蜜
async/await 是 ES2017 引入的语法,它是 Promise 的语法糖,让异步代码看起来像同步代码。
// Promise 写法
function getData() {
return getUser()
.then((user) => getOrders(user.id))
.then((orders) => getOrderDetail(orders[0].id))
.then((detail) => getProduct(detail.productId));
}
// async/await 写法
async function getData() {
const user = await getUser();
const orders = await getOrders(user.id);
const detail = await getOrderDetail(orders[0].id);
const product = await getProduct(detail.productId);
return product;
}
哪个更好读?不用我说了吧。
async 函数的特点
- async 函数总是返回 Promise
async function foo() {
return 'hello';
}
// 等价于
function foo() {
return Promise.resolve('hello');
}
foo().then(console.log); // 'hello'
- await 只能在 async 函数内使用
// ❌ 错误:await 在普通函数中
function foo() {
const data = await fetchData(); // SyntaxError
}
// ✅ 正确:await 在 async 函数中
async function foo() {
const data = await fetchData(); // OK
}
- await 会暂停 async 函数的执行
async function demo() {
console.log('1. 开始');
const result = await new Promise((resolve) => {
setTimeout(() => resolve('异步结果'), 1000);
});
console.log('2. 拿到结果:', result);
console.log('3. 结束');
}
demo();
console.log('4. async 函数外');
// 输出顺序:
// 1. 开始
// 4. async 函数外
// (1秒后)
// 2. 拿到结果: 异步结果
// 3. 结束
错误处理:try/catch
async/await 的错误处理更加直观:
async function getData() {
try {
const user = await getUser();
const orders = await getOrders(user.id);
return orders;
} catch (error) {
console.error('请求失败:', error);
// 可以返回默认值或重新抛出
return [];
} finally {
console.log('请求结束');
}
}
并行执行 vs 串行执行
串行执行(慢)
async function serial() {
console.time('serial');
const user = await fetchUser(); // 等 1 秒
const orders = await fetchOrders(); // 再等 1 秒
const products = await fetchProducts(); // 再等 1 秒
console.timeEnd('serial'); // serial: 3000ms
}
三个请求相互独立,但我们串行执行,浪费了 2 秒。
并行执行(快)
async function parallel() {
console.time('parallel');
// 同时发起三个请求
const [user, orders, products] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchProducts(),
]);
console.timeEnd('parallel'); // parallel: 1000ms
}
三个请求同时发起,只需要最长的那个请求的时间。
经验法则:
- 如果请求之间有依赖关系(需要前一个的结果),用串行
- 如果请求相互独立,用
Promise.all()并行
async/await 的常见陷阱
陷阱 1:循环中的 await
// ❌ 问题:串行执行,很慢
async function processItems(items) {
for (const item of items) {
await processItem(item); // 一个一个处理
}
}
// ✅ 优化:并行执行,更快
async function processItems(items) {
await Promise.all(items.map((item) => processItem(item)));
}
// 💡 如果需要限制并发数,可以使用专门的库
// 如 p-limit 或 p-map
陷阱 2:忘记 await
async function foo() {
// ❌ 忘记 await,getData 返回的是 Promise 对象
const data = getData();
console.log(data); // Promise { <pending> }
// ✅ 正确
const data = await getData();
console.log(data); // 实际数据
}
陷阱 3:在非 async 函数中捕获错误
// ❌ 错误:try/catch 捕获不到异步错误
function foo() {
try {
asyncFunction(); // 没有 await
} catch (error) {
// 这里捕获不到 asyncFunction 内部的错误
}
}
// ✅ 正确:使用 async/await
async function foo() {
try {
await asyncFunction();
} catch (error) {
// 可以捕获到错误
}
}
// ✅ 或者使用 .catch()
function foo() {
asyncFunction().catch((error) => {
// 可以捕获到错误
});
}
事件循环:理解异步的底层原理
要真正理解异步,你需要了解 JavaScript 的事件循环(Event Loop)。
调用栈、任务队列和事件循环
┌─────────────────────────────────────────────────────┐
│ JavaScript 引擎 │
│ ┌─────────────┐ │
│ │ 调用栈 │ │
│ │ Call Stack │ │
│ │ │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────┘
↑ │
│ ↓
┌─────────────────────────────────────────────────────┐
│ 事件循环 │
│ Event Loop │
└─────────────────────────────────────────────────────┘
↑ │
│ ↓
┌─────────────────────────────────────────────────────┐
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ 宏任务队列 │ │ 微任务队列 │ │
│ │ setTimeout/setInterval│ │ Promise.then/catch │ │
│ │ I/O、DOM 事件 │ │ MutationObserver │ │
│ └─────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────┘
宏任务 vs 微任务
| 类型 | 举例 | 优先级 |
|---|---|---|
| 微任务(Microtask) | Promise.then/catch/finally、queueMicrotask、MutationObserver | 高 |
| 宏任务(Macrotask) | setTimeout、setInterval、I/O、DOM 事件、requestAnimationFrame | 低 |
执行顺序:
- 执行同步代码
- 清空微任务队列
- 执行一个宏任务
- 清空微任务队列
- 重复 3-4
经典面试题
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
// 输出顺序是什么?
分析:
console.log('1')- 同步,直接执行,输出 1setTimeout- 宏任务,放入宏任务队列Promise.then- 微任务,放入微任务队列console.log('4')- 同步,直接执行,输出 4- 同步代码执行完,清空微任务队列,输出 3
- 执行宏任务,输出 2
答案:1 4 3 2
再来一道
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
async1();
new Promise((resolve) => {
console.log('promise1');
resolve();
}).then(() => {
console.log('promise2');
});
console.log('script end');
// 输出顺序是什么?
答案:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
关键点:
await async2()后面的代码相当于.then()回调,是微任务new Promise()的执行器函数是同步执行的
实战:封装一个好用的请求函数
学了这么多,来个实战:
// utils/request.js
/**
* 封装 fetch,支持超时、重试、错误处理
*/
async function request(url, options = {}) {
const {
timeout = 10000,
retries = 3,
retryDelay = 1000,
...fetchOptions
} = options;
// 创建超时 Promise
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('请求超时')), timeout);
});
// 重试逻辑
for (let i = 0; i < retries; i++) {
try {
const response = await Promise.race([
fetch(url, fetchOptions),
timeoutPromise,
]);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
const isLastRetry = i === retries - 1;
if (isLastRetry) {
throw error;
}
console.warn(`请求失败,${retryDelay}ms 后重试 (${i + 1}/${retries})`);
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
}
}
// 使用示例
async function loadUserData() {
try {
const user = await request('/api/user', {
timeout: 5000,
retries: 2,
});
console.log('用户数据:', user);
} catch (error) {
console.error('获取用户数据失败:', error.message);
}
}
总结
JavaScript 异步编程的演进:
| 阶段 | 方案 | 优点 | 缺点 |
|---|---|---|---|
| 1 | 回调函数 | 简单直接 | 回调地狱、错误处理困难 |
| 2 | Promise | 链式调用、统一的错误处理 | 还是有点绕 |
| 3 | async/await | 同步写法、直观易读 | 需要注意并行/串行 |
实践建议:
- 优先使用 async/await:代码最清晰
- 相互独立的请求用 Promise.all():提高性能
- 总是处理错误:try/catch 或 .catch()
- 理解事件循环:知其然更要知其所以然
记住这句话:
异步不是魔法,只是把「等待」变成了「预约」。
当你搞懂了 JavaScript 是怎么管理这些「预约」的,异步就再也难不倒你了。
小明冷笑话收尾:
问:为什么 JavaScript 开发者不喜欢等待? 答:因为他们习惯了 await,觉得等待应该是 async 的事,不该是人的事。
问:Promise 和渣男有什么共同点? 答:都会给你一个承诺,但你不知道会 resolve 还是 reject。
「异步是一种思维方式,不只是一种语法。」—— 小明