【JS 异步】告别回调地狱:Async/Await 和 Promise 的优雅实践与错误处理
所属专栏: 《前端小技巧集合:让你的代码更优雅高效
上一篇: 【JS 数组】数组操作的“瑞士军刀”:精通 Array.reduce()
的骚操作
作者: 码力无边
✨ 引言:那座名为“回调地狱”的金字塔,我们曾亲手搭建
嘿,各位在代码世界里追求光与热的道友们,我是码力无边!
在我们的前端江湖中,如果说 DOM 操作是“外家功夫”,数据处理是“内功心法”,那么异步编程,就是那门决定你能否“御剑飞行”的“轻功”。因为在 Web 世界,几乎所有有价值的操作都是异步的:
- 向服务器请求数据(Ajax/Fetch)
- 读取本地文件
- 设置一个定时器 (
setTimeout
) - 等待用户点击一个按钮
这些操作都不会立即返回结果。JavaScript 作为一门单线程语言,为了不被这些耗时的任务阻塞主线程(否则页面就会卡死),它采用了“稍后处理”的异步模型。而在远古时代,我们实现这种“稍后处理”的唯一方式,就是回调函数 (Callback)。
让我们一起瞻仰一下那座由我们亲手搭建,又让我们备受折磨的“史前遗迹”——回调地狱 (Callback Hell),又称“毁灭金字塔 (Pyramid of Doom)”:
// 史前遗迹,请勿模仿
ajax('api/user/1', function(user) {console.log('获取到用户:', user);ajax(`api/posts?userId=${user.id}`, function(posts) {console.log('获取到帖子:', posts);ajax(`api/comments?postId=${posts[0].id}`, function(comments) {console.log('获取到评论:', comments);// 如果还有下一步...天啊...ajax(`api/replies?commentId=${comments[0].id}`, function(replies) {console.log('获取到回复:', replies);// 金字塔已经高耸入云...}, function(error) {// 每一层都要处理错误...});}, function(error) {// ...});}, function(error) {// ...});
}, function(error) {// ...
});
这种代码,就像一个向右无限延伸的俄罗斯套娃。它的问题显而易见:
- 可读性极差:代码逻辑不是从上到下,而是像贪吃蛇一样扭曲。
- 难以维护:想在中间加一步?或者修改某一步的逻辑?祝你好运。
- 错误处理复杂:每一层嵌套都需要单独处理错误,很容易遗漏。
为了推翻这座压迫我们多年的“金字塔”,JavaScript 社区的先贤们进行了不懈的斗争,最终为我们带来了两件划时代的法宝:Promise 和 async/await
。
今天,码力无边就将带你走过这条从“地狱”到“天堂”的救赎之路,让你彻底掌握现代 JavaScript 中最优雅、最强大的异步编程范式。
一、Promise:从“回调”到“承诺”的革命
Promise 的出现,是异步编程思想的一次伟大飞跃。它把“传递一个函数进去等待执行”的模式,变成了“给你一个承诺对象,你拿着它等结果”的模式。
一个 Promise 对象,就像一张“彩票”。你买下它的时候,它处于 pending (进行中) 状态。未来,它可能会中奖,变成 fulfilled (已成功) 状态,并给你奖金(结果值);也可能没中奖,变成 rejected (已失败) 状态,并告诉你原因(错误信息)。
1.1 用 .then()
链式调用,拆解金字塔
Promise 最核心的变革,就是引入了 .then()
方法。.then()
方法可以接收两个函数作为参数,一个用于处理成功状态,一个用于处理失败状态。更重要的是,.then()
方法会返回一个新的 Promise 对象,这使得我们可以进行链式调用!
让我们用 Promise 来重构上面的“地狱”代码:
// 假设 ajax 函数现在返回一个 Promise
ajax('api/user/1').then(user => {console.log('获取到用户:', user);// 返回一个新的 Promisereturn ajax(`api/posts?userId=${user.id}`); }).then(posts => {console.log('获取到帖子:', posts);// 返回又一个新的 Promisereturn ajax(`api/comments?postId=${posts[0].id}`);}).then(comments => {console.log('获取到评论:', comments);return ajax(`api/replies?commentId=${comments[0].id}`);}).then(replies => {console.log('获取到回复:', replies);}).catch(error => {// 革命性的改变:用一个 .catch() 捕获链条上任何一个环节的错误!console.error('发生错误:', error);});
看到了吗?金字塔被夷为平地!代码变成了从上到下的线性结构,逻辑清晰无比。
- 线性流程:每一步操作都清晰地写在一个
.then()
中。 - 统一错误处理:链式调用中任何一个 Promise 变成
rejected
,都会被最后的.catch()
捕获。告别了层层嵌套的错误处理。
1.2 Promise 的“静态方法”:Promise.all
和 Promise.race
Promise 还提供了一些强大的工具函数:
-
Promise.all(iterable)
: 并行执行,等待所有。- 场景:你需要同时请求用户基本信息、用户的好友列表和用户的相册,三者没有依赖关系,但你希望等它们全部成功后,再渲染页面。
- 用法:它接收一个 Promise 数组,返回一个新的 Promise。只有当数组中所有的 Promise 都成功时,它才会成功,并且结果是一个包含所有 Promise 结果的数组。如果其中任何一个失败了,它就会立刻失败。
Promise.all([ajax('api/userInfo'),ajax('api/friendList'),ajax('api/album') ]).then(([userInfo, friendList, album]) => {// 在这里,三个请求都已成功完成renderPage(userInfo, friendList, album); }).catch(error => {// 只要有一个请求失败,就会进入这里showErrorPage(error); });
-
Promise.race(iterable)
: 并行执行,谁快用谁。- 场景:你向两个不同的 CDN 节点请求同一个资源,哪个先返回就用哪个。或者,给一个请求设置超时:让你的请求和
setTimeout
返回的 Promise 赛跑。 - 用法:它也接收一个 Promise 数组,但只要其中任何一个 Promise 率先改变状态(无论是成功还是失败),它就会立即采用那个 Promise 的状态和结果。
function requestWithTimeout(url, timeout) {let timeoutPromise = new Promise((_, reject) => {setTimeout(() => reject(new Error('请求超时!')), timeout);});return Promise.race([fetch(url),timeoutPromise]); }requestWithTimeout('api/slow-resource', 3000).then(response => console.log('请求成功:', response)).catch(error => console.error(error.message)); // 可能是网络错误,也可能是“请求超时!”
- 场景:你向两个不同的 CDN 节点请求同一个资源,哪个先返回就用哪个。或者,给一个请求设置超时:让你的请求和
Promise 已经非常强大了,但它仍然需要 .then()
的回调函数语法。人类的大脑,终究还是更习惯同步的、阻塞式的代码写法。于是,终极形态的“异步救世主”登场了。
二、async/await
:用写同步代码的方式,来写异步
async/await
是 ES2017 (ES8) 引入的,它并不是一个新东西,而是建立在 Promise 之上的语法糖 (Syntactic Sugar)。它的目标只有一个:让异步代码看起来、写起来都像同步代码。
两个关键词:
async
: 用来修饰一个函数,表明这个函数是一个异步函数。任何async
函数的返回值,都会被自动包装成一个 Promise。await
: 只能用在async
函数内部。它后面通常跟着一个 Promise。它的作用是**“暂停”当前async
函数的执行,等待后面的 Promise 状态变为fulfilled
,然后直接返回 Promise 的结果值**。如果 Promise 失败了,它会抛出 (throw) 错误。
2.1 终极进化:最“人类友好”的异步代码
让我们用 async/await
来重写我们最初的那个例子,你将见证代码的可读性如何达到巅峰:
async function fetchAllData() {try {// 代码像同步一样,从上到下执行const user = await ajax('api/user/1');console.log('获取到用户:', user);const posts = await ajax(`api/posts?userId=${user.id}`);console.log('获取到帖子:', posts);const comments = await ajax(`api/comments?postId=${posts[0].id}`);console.log('获取到评论:', comments);const replies = await ajax(`api/replies?commentId=${comments[0].id}`);console.log('获取到回复:', replies);return replies; // async 函数的返回值} catch (error) {// 同样革命性的改变:用标准的 try...catch 来捕获所有 await 的错误!console.error('发生错误:', error);}
}fetchAllData().then(result => {console.log('所有数据获取完毕:', result);
});
震撼吗?
- 完全同步的写法:没有
.then
,没有回调,代码的执行顺序和你的阅读顺序完全一致。 - 标准的错误处理:
try...catch
是我们再熟悉不过的同步代码错误处理机制,现在它完美地适用于异步流程。任何一个await
的 Promise 失败,都会被catch
块捕获。 - 优雅的返回值:
async
函数的返回值就是一个 Promise,你可以继续在外部用.then()
来处理最终的结果。
async/await
同样能和 Promise.all
等工具完美结合:
async function fetchParallelData() {try {const [userInfo, friendList, album] = await Promise.all([ajax('api/userInfo'),ajax('api/friendList'),ajax('api/album')]);renderPage(userInfo, friendList, album);} catch (error) {showErrorPage(error);}
}
三、现代异步编程最佳实践
-
优先使用
async/await
:在任何可以使用它的地方(现代浏览器、Node.js、或经过 Babel 等工具编译的环境),async/await
都应该是你的首选。它的可读性和可维护性是无与伦比的。 -
不要忘记
Promise.all
:在使用async/await
时,要警惕一种反模式——串行执行本可以并行的任务。// 反模式:不必要的串行等待 async function getTwoThings() {const thing1 = await fetchThing1(); // 等待 thing1const thing2 = await fetchThing2(); // thing1 好了才开始请求 thing2return [thing1, thing2]; }// 正确模式:并行执行 async function getTwoThingsInParallel() {const [thing1, thing2] = await Promise.all([fetchThing1(),fetchThing2()]);return [thing1, thing2]; }
-
顶层
await
:最新的 JavaScript (ES2022) 已经支持在模块的顶层使用await
,无需包裹在async
函数中。这在一些初始化脚本中非常有用。
写在最后:从驯服异步,到驾驭异步
从回调地狱的混乱,到 Promise 链的秩序,再到 async/await
的优雅,JavaScript 的异步编程演进史,就是一部不断追求“人性化”和“可读性”的奋斗史。
掌握 async/await
和 Promise,你就不再是那个被异步任务牵着鼻子走的“回调奴隶”,而是一个能够从容地编排、组织、驾驭复杂异步流程的“时间管理者”。你的代码将不再是难以理解的“面条”,而是结构清晰、逻辑顺畅的“诗篇”。
所以,道友们,请彻底告别回调地狱吧。在你的下一个项目中,大胆地拥抱 async/await
,用最现代、最优雅的方式,去驯服时间,驾驭异步!
专栏预告与互动:
我们已经掌握了现代 JS 的异步核心。但代码写得再优雅,也得简洁。ES6 引入了许多强大的“语法糖”,它们能让你用更少的代码,做更多的事。
下一篇,我们将深入 ES6+ 的代码整洁之道,探索解构赋值和展开语法的 5 个神仙用法,让你的代码瞬间“瘦身”,可读性翻倍!
码力无边的异步心法,你 Get 了吗?点赞、收藏、关注,用你的三连,为我的下一次“瞬移”积蓄能量!
今日思考题:
forEach
循环是同步的,如果我在forEach
的回调里使用await
,会发生什么?它会按顺序等待每一个await
完成吗?为什么?这是一个经典的async/await
陷阱,把你的分析写在评论区,我们一起探讨!