前言
在写 JavaScript 代码时,我们常常会遇到一些“慢动作”任务,比如从服务器获取数据、读取文件或者等待用户操作,这些任务不能立刻完成,但又不能让整个程序停下来等它们——这就是 异步编程 要解决的问题。
JavaScript 是单线程语言,它天生就擅长处理异步操作。但随着需求越来越复杂,异步的写法也经历了几次演变:
从最开始的 回调函数,到后来的 Promise,再到如今广泛使用的 async/await。
下面我们来一步步了解它们是怎么演进的,以及为什么我们现在更喜欢用 async/await 来写异步代码。
1 同步与异步
同步代码:按顺序执行,每一步都必须等上一步完成。
console.log("A");
console.log("B");
// 输出顺序一定是 A → B
异步代码:某些操作可以“先发起”,不用立刻等待结果,继续执行其他任务。
console.log("A");
setTimeout(() => {
console.log("B");
}, 1000);
console.log("C");
// 输出顺序是 A → C → B(1秒后)
2 早期处理异步操作 —— 回调函数嵌套(callback)
异步操作看起来有点不符合我们的思考方式,为了让开发者看起来更轻松,JS开发者想了办法:模拟顺序执行,让多个异步操作 按顺序执行,看起来像是同步一样一步一步。
fs.readFile('file1.txt', 'utf8', function(err, data1) {
if (err) return console.error(err);
fs.readFile(data1, 'utf8', function(err, data2) {
if (err) return console.error(err);
fs.readFile(data2, 'utf8', function(err, data3) {
if (err) return console.error(err);
console.log(data3);
});
});
});
虽然每个任务都是异步操作,但是它们好像是 “同步”代码 一样,先执行第一个 readFile,等他执行完毕后在执行第二个 readFile,等他执行完毕后再执行最后一个 readFile。这样就实现了多个异步操作的顺序执行。
但是这样有很大弊端,如果有100个异步操作怎么办?一层层嵌套到眼花!
这就是所谓的:回调地狱
还有,每个异步操作都要手动判断 if (err),然后处理错误,非常不利于统一管理,使代码混乱臃肿。
3 Promise的引入
为了避免回调地狱,ES6 引入了 Promise,它是对异步操作的封装
Promise 的三种状态:
- pending(进行中)
- fulfilled(成功)
- rejected(失败)
基本用法:
const promise = new Promise((resolve, reject) => {
// 模拟异步操作
setTimeout(() => {
const success = true;
if (success) {
resolve("操作成功!");
} else {
reject("出错了!");
}
}, 1000);
});
promise
.then(result => console.log(result)) // 成功处理
.catch(error => console.error(error)); // 失败处理
链式调用(Chaining):
readFile('file1.txt')
.then(data1 => readFile(data1))
.then(data2 => readFile(data2))
.then(data3 => console.log(data3))
.catch(err => console.error(err));
要记住 每个 .then() 是前一个的结果。
且 then方法里面实质是执行 回调函数。
为了更好理解 resolve 和 reject,我们简要看一下Promise 函数的底层代码 (.then方法未写) :
function MyPromise(executor) {
const self = this;
self.status = 'pending'; // 当前状态
self.value = undefined; // 成功的值
self.reason = undefined; // 失败的原因
self.onFulfilledCallbacks = []; // 存储成功的回调
self.onRejectedCallbacks = []; // 存储失败的回调
function resolve(value) {
if (self.status === 'pending') {
self.status = 'fulfilled';
self.value = value;
// 执行所有成功回调
self.onFulfilledCallbacks.forEach(fn => fn());
}
}
function reject(reason) {
if (self.status === 'pending') {
self.status = 'rejected';
self.reason = reason;
// 执行所有失败回调
self.onRejectedCallbacks.forEach(fn => fn());
}
}
try {
executor(resolve, reject); // 执行用户传入的函数
} catch (e) {
reject(e); // 捕获执行器中的错误
}
}
fetch API
fetch() 是浏览器内置的一个用于发起网络请求的方法,返回一个 Promise,常用于从服务器获取数据(如 JSON)。
基本用法:
fetch('https://jsonplaceholder.typicode.com/posts/1')
.then(response => response.json()) // 将响应体转为 JSON
.then(data => console.log(data)) // 打印数据
.catch(error => console.error('Error:', error));
注意事项: fetch 不会自动抛出错误(即使 HTTP 状态码是 4xx 或 5xx),需要手动判断:
fetch('https://example.com/data')
.then(res => {
if (!res.ok) throw new Error("HTTP 错误");
return res.json();
})
.then(data => console.log(data))
.catch(err => console.error(err));
4 async / await
ES8引入了 async 和 await,让异步代码看起来更像同步代码,极大提升了可读性和可维护性。
async function getData() {
try {
const user = await fetchUser(1);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
console.log(comments);
} catch (error) {
console.error("获取数据失败:", error);
}
}
getData();
这段代码看起来就像同步函数一样,逻辑清晰,易于调试和测试。
await 的作用到底是什么?
await 关键字只能出现在 async 函数内部,它的作用是:
暂停当前函数的执行,直到右边的 Promise 被解决(resolved),然后返回 Promise 的结果值。
例如:
const result = await someAsyncFunction();
console.log(result); // 这里拿到的是 Promise 成功后的返回值
虽然 JS 是单线程语言,但 await 并不会“卡住”整个程序,只是“暂停”当前 async 函数的执行,主线程可以继续运行其他任务。
有几个点需要非常注意:
- await 只能在 async 函数内部使用
- await 会暂停当前函数执行,直到右边的 Promise 被解决
类比生活中的例子
想象你在餐厅点了一份牛排和一杯咖啡:
- 回调方式:你点完餐就去做别的事,服务员端上来时通知你;
- Promise 方式:你点餐后说:“等我牛排好了再上甜点”,然后继续做别的;
async/await方式:你说:“等我吃完牛排再点甜点。” —— 你不需要关心背后是不是异步,只要在逻辑上顺序写下来就行。
5 多个异步操作的 【同步感】
什么是同步感?
传统的异步写法(比如回调函数或 .then() 链式调用)会让代码看起来不像是顺序执行的。
而 async/await 的出现,让我们可以用类似同步的方式去写异步代码 —— 就像下面这样:
async function getUserData() {
const user = await fetchUser(1); // 等待用户数据
const posts = await fetchPosts(user.id); // 等待文章数据
const comments = await fetchComments(posts[0].id); // 等待评论数据
console.log(comments);
}
这段代码从上到下依次执行:
- 先获取用户信息;
- 再根据用户 ID 获取文章;
- 最后根据文章 ID 获取评论;
虽然这些操作都是异步的,但我们在写法上可以像同步一样按顺序书写和阅读,这就是所谓的 “具有同步感” 。
注意: .then() 本身是同步的,它只是为异步操作注册回调。真正异步的是 Promise 的 resolve 过程。
.then() 链让我们看起来像是按顺序执行多个异步任务,这和 await 的效果其实是一样的。
总结一下:
| 特性 | 同步代码 | 异步代码(传统) | async/await |
|---|---|---|---|
| 执行顺序 | 顺序执行 | 不确定(依赖回调触发) | 顺序执行(视觉上) |
| 错误处理 | try/catch | 每个回调都要判断 err | 统一 try/catch 处理异常 |
| 可读性 | 高 | 中 | 高 |
| 编写体验 | 直观 | 易嵌套、难维护 | 更接近自然语言顺序 |
| 是否阻塞主线程 | 会 | 不会 | 不会(只是暂停 async 函数) |
这也是为什么后来 JavaScript 社区逐渐转向了 Promise,以及更进一步的 async/await,它们不仅保留了“顺序感”,还极大地提升了代码的可读性、可维护性和开发体验。