【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 社区的先贤们进行了不懈的斗争,最终为我们带来了两件划时代的法宝:Promiseasync/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);
    // 返回一个新的 Promise
    return ajax(`api/posts?userId=${user.id}`); 
  })
  .then(posts => {
    console.log('获取到帖子:', posts);
    // 返回又一个新的 Promise
    return 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.allPromise.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)); // 可能是网络错误,也可能是“请求超时!”
    

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);
  }
}

三、现代异步编程最佳实践

  1. 优先使用 async/await:在任何可以使用它的地方(现代浏览器、Node.js、或经过 Babel 等工具编译的环境),async/await 都应该是你的首选。它的可读性和可维护性是无与伦比的。

  2. 不要忘记 Promise.all:在使用 async/await 时,要警惕一种反模式——串行执行本可以并行的任务。

    // 反模式:不必要的串行等待
    async function getTwoThings() {
      const thing1 = await fetchThing1(); // 等待 thing1
      const thing2 = await fetchThing2(); // thing1 好了才开始请求 thing2
      return [thing1, thing2];
    }
    
    // 正确模式:并行执行
    async function getTwoThingsInParallel() {
      const [thing1, thing2] = await Promise.all([
        fetchThing1(),
        fetchThing2()
      ]);
      return [thing1, thing2];
    }
    
  3. 顶层 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 陷阱,把你的分析写在评论区,我们一起探讨!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐