理解事件循环的底层机制(6.2 节)之后,我们就能明白为什么 JavaScript 必须依赖异步编程——单线程模型下,任何耗时操作都不能阻塞主线程。然而“知道为什么异步”和“怎么写好异步代码”是两回事。从早期的回调地狱到如今优雅的 async/await,JavaScript 异步编程经历了一条清晰的演进路径。理解这一段历史,不仅能帮你读懂旧代码,更能让你真正掌握现代异步编程的精髓。
6.4.1 回调函数:最原始的异步武器
回调函数是异步编程最基础的形式:把一个函数作为参数传给另一个函数,等到异步操作完成时,由后者调用这个函数。
// 模拟一个异步请求
function fetchData(callback) {
setTimeout(() => {
callback(null, "数据到达");
}, 1000);
}
fetchData((err, data) => {
if (err) {
console.error("出错啦");
} else {
console.log(data); // "数据到达"
}
});
优点:简单直接,浏览器中大量 API(setTimeout、addEventListener、XMLHttpRequest)都使用回调。
致命缺陷:当多个异步操作前后依赖时,就会出现臭名昭著的“回调地狱”(Callback Hell),代码不断向右缩进,逻辑难以阅读和维护:
step1(function(value1) {
step2(value1, function(value2) {
step3(value2, function(value3) {
step4(value3, function(value4) {
// 已经不知道在哪一层了……
});
});
});
});
此外,回调方式对错误处理极不友好,每一层都需要手动传 err 参数或 try-catch,极易遗漏。控制权倒置也意味着你无法信任回调是否只被调用一次、是否会在错误时机执行。
6.4.2 Promise:让异步操作可组合
ES6 引入的 Promise 彻底改变了异步编程的面貌。它将回调函数的“嵌套”结构拍平,变成链式调用。
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("数据到达");
}, 1000);
});
}
fetchData()
.then(data => {
console.log(data); // "数据到达"
return "下一步的数据";
})
.then(nextData => {
console.log(nextData); // "下一步的数据"
})
.catch(err => {
console.error("错误流被统一捕获");
});
核心价值:
- 状态不可逆:Promise 有三种状态(pending、fulfilled、rejected),一旦状态改变就不会再变,彻底解决了回调函数被多次调用或提前调用的不确定性。
- 链式调用:每个
.then返回一个新的 Promise,可以将多个异步步骤串联起来,逻辑沿着链条正向展开,不再向右疯狂缩进。 - 统一错误处理:链末端的
.catch能捕获前面任意.then中的错误,告别一层层手动传 err。 - 静态方法:
Promise.all处理并发,Promise.race响应竞速,Promise.allSettled等待所有结果(无论成功失败)——这些组合能力让复杂的异步流程控制变得优雅。
仍然存在的不足:链式调用虽然比金字塔好很多,但当异步步骤变多,代码还是会变成一长串 .then().then().then(),仍然不够接近同步代码的线性思维。此外,错误栈在 Promise 链中有时不够清晰。
6.4.3 Generator:暂停与唤醒的魔法
ES6 同时引入了 Generator 函数,它可以在函数执行过程中被“暂停”和“恢复”。这为异步编程提供了一种全新的思路:我们可以写一个看起来像同步的函数,在需要等待异步操作的地方用 yield 暂停,外部通过迭代器控制恢复时机。
function* fetchData() {
const data = yield new Promise(resolve => {
setTimeout(() => resolve("数据到达"), 1000);
});
console.log(data); // "数据到达"
}
但 Generator 本身并不自动处理异步,需要配合一个执行器(Runner)来管理 yield 后的 Promise。著名的 co 库就是干这个的:
const co = require('co');
co(function* () {
const data = yield fetchData(); // fetchData 返回 Promise
console.log(data);
});
Generator 的贡献:它第一次让异步代码可以用同步写法表达,也为后来的 async/await 提供了灵感。在实际业务中,Generator 目前已经不再是编写异步代码的主流选择,但它的思想仍然有价值——迭代器、生成器在数据流控制、惰性求值等场景依然活跃。
6.4.4 async/await:异步的终极语法糖
ES2017 引入的 async/await 是建立在 Promise 之上的语法糖,它直接吸收了 Generator 的“同步写法”和 Promise 的可组合性,让异步代码真正拥有了同步外观和同步的错误处理方式。
async function getData() {
try {
const data = await fetchData();
console.log(data);
const result = await processData(data);
console.log(result);
} catch (err) {
console.error(err);
}
}
关键特性:
async函数返回一个 Promise,可以用.then/.catch与其交互。await只能出现在async函数内部,用于等待一个 Promise 的结果,期间函数执行被暂停,不阻塞主线程。- 错误处理可以直接使用
try/catch,栈追踪友好,调试体验极佳。 - 多个独立的异步操作可以并行:
const [a, b] = await Promise.all([fetchA(), fetchB()]),既保持了同步写法,又不损失并发性能。
async/await 并非万能:它是 Promise 的包装,因此必须理解 Promise 才能用好它。常见的陷阱包括:忘记 await(结果拿到的是 Promise 对象本身)、在循环中不小心串行调用、错误处理遗漏等。但只要基础扎实,async/await 几乎是目前编写异步代码最舒适的方式。
6.4.5 演进路径背后的逻辑
| 范式 | 核心思路 | 解决什么问题 | 还有什么痛点 |
|------|---------|--------------|--------------|
| 回调函数 | 函数作为参数传递 | 实现基本的异步通知 | 回调地狱、错误处理困难、控制权倒置 |
| Promise | 状态机 + 链式调用 | 拍平嵌套、统一错误处理、不可逆状态 | 链式仍然不够直观,调试有时不便 |
| Generator | yield 暂停函数执行 | 用同步写法表达异步逻辑 | 需要手动执行器,侵入性强 |
| async/await | async 函数 + await 表达式 | 完美的同步写法、原生 try/catch | 建立在 Promise 之上,需理解底层 |
理解这条演进路径,不在于背诵各个时期的历史,而在于看清一个本质:JavaScript 社区一直在追求用同步的思维写异步的代码。从回调到 async/await,每一步都在拉近代码外观与开发者逻辑之间的距离,同时保留事件驱动模型非阻塞的硬实力。看懂这条脉络,你就拥有了驾驭一切异步代码的底层心智。