JavaScript是单线程的,但它却能同时处理很多事情。这是怎么做到的?今天我们就来聊聊异步编程,看看JS是怎么一边听歌一边刷网页的。从最原始的回调函数,到Promise,再到优雅的async/await,这不仅是技术的演进,更是一场"程序员不熬夜"的运动。
前言
你有没有经历过这种绝望:写了一个网络请求,结果后面的代码先执行了,请求的数据还没回来,页面已经渲染完了,一片空白。或者你见过这样的代码:
js
getUser(function(user) {
getOrders(user.id, function(orders) {
getOrderDetails(orders[0].id, function(details) {
getProductInfo(details.productId, function(product) {
console.log(product);
});
});
});
});
这就是传说中的回调地狱------代码像楼梯一样往右歪,看得人头晕眼花。
今天我们就来走一遍JS异步编程的进化史,看看前辈们是怎么从地狱里爬出来的。
一、为什么需要异步?
JavaScript是单线程的,也就是说同一时间只能做一件事。如果所有事情都排队等着,那遇到一个耗时操作(比如网络请求、读取文件),整个页面就得卡住,用户点哪儿都没反应。
异步就是解决方案:遇到耗时操作,先丢给浏览器或Node去"慢慢做",JS主线程继续执行后面的代码。等耗时操作完成了,再通知JS:"嘿,我完事了,你处理一下结果吧。"
这就好比你点外卖:你不会站在店门口干等一小时,而是该干嘛干嘛,等外卖小哥打电话叫你,你再去取餐。异步就是这种"不干等"的机制。
二、回调函数:异步的原始形态
回调函数是最早的异步解决方案:把一个函数作为参数传给另一个函数,等异步操作完成后调用这个函数。
js
function fetchData(callback) {
setTimeout(() => {
callback('数据来了');
}, 1000);
}
fetchData(function(data) {
console.log(data); // 一秒后输出:数据来了
});
看起来还行,对吧?但一旦有多个依赖的异步操作,就出事了。
回调地狱长什么样?
js
// 先获取用户
getUser(function(user) {
// 再根据用户ID获取订单
getOrders(user.id, function(orders) {
// 再获取第一个订单的详情
getOrderDetails(orders[0].id, function(details) {
// 再根据商品ID获取商品信息
getProductInfo(details.productId, function(product) {
// 终于拿到了
console.log(product);
});
});
});
});
代码往右飞,一眼看不到头。这还没算错误处理------每个回调都要处理错误,代码量直接翻倍。这种代码别说维护了,写的时候自己都要绕晕。
回调的痛点:
- 嵌套太深,代码可读性差
- 错误处理困难,每个回调都要try-catch
- 难以并行执行多个异步操作
三、Promise:打破地狱的"链式反应"
ES6引入了Promise,它像是一个"承诺":现在还没有结果,但将来一定会有(要么成功,要么失败)。
js
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('数据来了');
// 如果出错:reject('错误信息')
}, 1000);
});
promise
.then(data => {
console.log(data);
})
.catch(error => {
console.error(error);
});
Promise最大的好处是链式调用,可以把嵌套的异步操作拍平:
js
getUser()
.then(user => getOrders(user.id))
.then(orders => getOrderDetails(orders[0].id))
.then(details => getProductInfo(details.productId))
.then(product => console.log(product))
.catch(error => console.error(error));
看,从"右飞"变成了"下飞",代码清晰多了。
Promise的几个关键点
-
状态不可逆:Promise有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。一旦从pending变成fulfilled或rejected,就不能再变了。
-
链式传递 :
then返回的是一个新的Promise,所以可以一直链下去。 -
错误冒泡 :只要链尾有一个
catch,前面任何一个环节出错都会落进来。 -
并行操作 :
Promise.all等待所有完成,Promise.race等待最快的一个。
js
// 并行请求
Promise.all([fetchUser(), fetchOrders(), fetchProduct()])
.then(([user, orders, product]) => {
console.log('全部完成', user, orders, product);
});
Promise解决了回调地狱的问题,但还是有些繁琐------你需要写很多.then和.catch,而且处理复杂的逻辑时,还是有点绕。
四、async/await:异步代码同步写
ES2017推出的async/await,是Promise的语法糖,让异步代码看起来像同步代码一样直观。
js
async function getProductInfo() {
try {
const user = await getUser();
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0].id);
const product = await getProductInfo(details.productId);
console.log(product);
} catch (error) {
console.error(error);
}
}
关键点:
async标记的函数返回一个Promiseawait后面跟一个Promise,它会"暂停"函数执行,直到Promise出结果- 错误处理直接用
try/catch,和同步代码一模一样
这感觉就像:终于可以用写同步代码的姿势写异步了!不用再管什么then、catch,代码一下子就清爽了。
但注意:await会阻塞函数内部,但不阻塞外部
js
async function test() {
console.log('1');
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('2'); // 一秒后才输出
}
console.log('3');
test();
console.log('4');
// 输出顺序:1,3,4,(一秒后)2
await只阻塞它所在的async函数,外面的代码照常执行。这正是异步的精髓:不干等。
五、事件循环:异步背后的幕后黑手
说了这么多,你有没有想过一个问题:异步操作完成之后,回调是怎么被调用的?这就要提到**事件循环(Event Loop)**了。
JS的执行机制大概是这样的:
- 主线程执行同步代码,遇到异步任务(比如setTimeout、网络请求)就交给Web APIs(浏览器)或libuv(Node)去处理。
- 异步任务完成后,回调函数被放入任务队列。
- 主线程的同步代码执行完后,会不断从任务队列里取回调来执行。
- 这个过程不断重复,就是事件循环。
任务队列还分宏任务 和微任务:
- 宏任务:setTimeout、setInterval、I/O操作、UI渲染
- 微任务:Promise.then、MutationObserver、queueMicrotask
执行顺序是:一个宏任务 → 所有微任务 → 渲染(如果有) → 下一个宏任务。
js
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出:1,4,3,2
为什么?同步代码先执行(1,4)→ 微任务Promise.then(3)→ 下一个宏任务setTimeout(2)。
六、实战:封装一个带超时的fetch
我们来用async/await封装一个实用的网络请求函数:
js
async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('请求超时');
}
throw error;
}
}
// 使用
try {
const data = await fetchWithTimeout('https://api.example.com/data', 3000);
console.log(data);
} catch (error) {
console.error(error.message);
}
这个函数既支持超时控制,又有完善的错误处理,用起来就像同步代码一样简单。
七、异步编程的最佳实践
-
能用async/await就用:比原生Promise更易读,错误处理也更自然。
-
避免"忘掉await":忘记await会得到一个Promise对象,而不是实际值,这个bug很难找。
-
并行任务用Promise.all:如果多个异步任务互不依赖,用Promise.all并行执行,而不是挨个await。
js
// 慢:串行执行,总耗时2秒
const user = await getUser();
const orders = await getOrders();
// 快:并行执行,总耗时1秒(如果每个请求1秒)
const [user, orders] = await Promise.all([getUser(), getOrders()]);
-
错误处理要完整:async/await用try/catch,Promise用.catch(),不要漏掉。
-
避免在循环里用await:除非你确实需要串行执行,否则可以用Promise.all或for...of配合异步。
js
// 这样会串行执行,很慢
for (const id of ids) {
const item = await fetchItem(id);
items.push(item);
}
// 并行执行,快很多
const items = await Promise.all(ids.map(id => fetchItem(id)));
八、总结:从地狱到天堂
JS异步编程的演进史,就是一部程序员与复杂性抗争的历史:
- 回调函数:原始但容易陷入地狱
- Promise:链式调用打破嵌套
- async/await:让异步代码回归同步的直觉
现在,你应该能理解为什么异步这么重要,以及怎么优雅地处理异步了。记住:不要在回调里写回调,不要在地狱里挣扎,用Promise和async/await解救自己。
明天我们将深入JS的另一座大山------事件循环(Event Loop),彻底搞懂微任务、宏任务、渲染时机这些核心概念。到时候你会发现,那些让人头疼的异步面试题,不过是一层窗户纸。
如果你觉得今天的异步进化史讲得通透,点个赞让更多人看到。有疑问评论区见,我们明天见!