深入理解 JavaScript 异步编程:从回调到 async/await
##
手撕:sleep,Promise.all,红绿灯
JavaScript 是单线程的,这意味着它一次只能执行一个任务。然而,在处理耗时操作(如网络请求、定时器)时,我们不能让程序停下来等待。因此,异步编程应运而生。
本文将带你回顾 JavaScript 异步编程的几个重要阶段,并探讨它们的优缺点,帮你构建一个清晰、系统的知识框架。
1. 回调函数:异步编程的起点
最早期的异步编程主要依赖回调函数(Callback)。当一个异步操作完成后,它会调用你提供的回调函数来处理结果。
// 示例:使用回调函数实现异步 function red() { setTimeout(() => { console.log('red'); // 继续调用下一个异步任务 green(); }, 3000); } function green() { setTimeout(() => { console.log('green'); yellow(); }, 2000); } function yellow() { setTimeout(() => { console.log('yellow'); }, 1000); } red();
优点: 简单直观,容易理解。
缺点: - 回调地狱(Callback Hell): 当多个异步操作需要按顺序嵌套执行时,代码会变得层层缩进,难以阅读和维护。
- 错误处理分散: 错误处理逻辑需要分散在每个回调函数中,不够集中。
2. Promise:链式调用的救星
为了解决回调地狱问题,ES6 引入了 Promise。它提供了一种更优雅、可预测的方式来处理异步操作。Promise 代表一个异步操作的最终结果,它有三种状态:pending
(进行中)、fulfilled
(已成功)和 rejected
(已失败)。
Promise A+规范是什么
第一点:Promise 的状态
它规定了 Promise 必须有且只能有三种状态:pending
(进行中)、**fulfilled
(已成功)**和 rejected
(已失败)。
状态的转换是单向且不可逆的。
第二点:then
方法
then
方法是整个规范的核心。它规定了 then
方法必须返回一个新的 Promise。正是因为这个特性,我们才能进行链式调用。
它接收两个可选的回调函数:onFulfilled
(成功时调用)和 onRejected
(失败时调用)。这两个回调函数必须是异步执行的,保证了 Promise 异步非阻塞的特性。
第三点:Promise 的解决过程
- 如果你的回调函数返回一个普通值,那么下一个 Promise 会立即成功,并以这个值为结果。
- 如果你的回调函数返回一个新的 Promise,那么下一个 Promise 的状态和结果,将完全取决于这个新 Promise。这就像是“嵌套”了一层异步,整个链条依然能平稳运行。
- 如果你的回调函数抛出了一个错误,那么下一个 Promise 就会被立即拒绝。
使用 .then()
和 .catch()
方法,我们可以轻松地将异步任务串联起来。
// 示例:使用 Promise 链式调用 const sleep = (fn, time) => new Promise(resolve => { setTimeout(() => { fn(); resolve(); }, time); }); sleep(() => red(), 3000) .then(() => sleep(() => green(), 2000)) .then(() => sleep(() => yellow(), 1000)) .catch(e => console.error(e));
优点:
- 链式调用: .then() 允许我们将异步任务以链式方式组织,代码更加扁平化。
- 集中错误处理: 只需要一个 .catch() 就可以捕获整个 Promise 链中的错误。
3. async/await
:异步编程的终极优雅
ES2017 引入的 async/await
是在 Promise 基础上的一层语法糖。它让异步代码的写法看起来就像同步代码一样,极大地提高了可读性。
async
函数会返回一个 Promise,而 await
关键字会暂停 async
函数的执行,直到其后的 Promise 成功解决。
// 示例:使用 async/await async function runTrafficLights() { try { await sleep(() => red(), 3000); await sleep(() => green(), 2000); await sleep(() => yellow(), 1000); console.log('所有任务成功完成!'); } catch (error) { console.error('任务执行失败:', error.message); } } runTrafficLights();
优点:
- 极高的可读性: 代码逻辑清晰,几乎和同步代码无异。
- 强大的错误处理: 可以直接使用 try...catch 块来捕获异步操作的错误。
4. 异步循环的陷阱:forEach
与 await
这是一个常见的面试考点。尽管 forEach
是一个遍历数组的常用方法,但它和 await
的组合方式并不能实现串行执行。
为什么 forEach
无效?
forEach
的回调函数本身是同步执行的,它会立即遍历完数组中的所有元素,并为每个元素开启一个独立的异步任务。因此,所有任务会并发执行,而不是按顺序等待。
// ❌ 错误示范:并发执行 urls.forEach(async (url) => { // 这里不会等待,所有任务同时开始 const response = await fetch(url); console.log(response); }); // ✅ 正确做法:串行执行 async function fetchUrlsInOrder(urls) { for (const url of urls) { // await 会暂停整个循环,直到 Promise 完成 const response = await fetch(url); console.log(response); } }
5. 全局错误处理
在异步编程中,如果 Promise 没有被 .catch()
或 try...catch
捕获,就会导致未处理的拒绝(unhandled rejection
)。为了防止程序崩溃,我们可以设置全局的错误监听。
// 全局 Promise 错误处理 window.addEventListener('unhandledrejection', event => { console.error('未处理的 Promise 拒绝:', event.reason); event.preventDefault(); // 防止默认错误处理 }); // 全局普通错误处理 window.addEventListener('error', event => { console.error('全局错误:', event.error); });
总结
- 回调是最早的异步解决方案,但易陷入“回调地狱”。
- Promise 解决了回调地狱问题,提供了链式调用和集中的错误处理。
- async/await 是基于 Promise 的语法糖,让异步代码像同步代码一样直观,是现代 JavaScript 异步编程的首选。
- 在异步循环中,请使用 for 或 for...of 循环来确保串行执行,避免 forEach 带来的并发问题。