JavaScript 发布于:2024-01-05 阅读时长:约14分钟

JavaScript 异步编程:Promise/Async/Await 深入解析

JS 异步编程封面

一、异步编程的本质:为什么需要它?

JavaScript 是单线程语言,意味着同一时间只能执行一个任务。如果所有任务都同步执行,遇到网络请求、文件读取这类耗时操作时,页面会卡住(阻塞),用户体验极差。

异步编程的核心就是「不等待耗时操作完成,继续执行后续任务,等耗时操作有结果后再回头处理」。比如我们用 fetch 请求接口数据时,不会一直等着数据返回,而是继续执行下面的代码,等数据返回后再通过回调函数处理结果。

前端常用的异步场景有:网络请求(AJAX/fetch)、定时器(setTimeout/setInterval)、事件监听、文件读写等。

二、异步编程的演进:从回调地狱到 Promise

1. 第一代异步:回调函数(Callback)

最早期的异步实现方式是回调函数,比如:

// 定时器回调
setTimeout(() => {
  console.log('1秒后执行');
}, 1000);

// 事件监听回调
document.getElementById('btn').addEventListener('click', () => {
  console.log('按钮被点击');
});

回调函数简单直观,但遇到多个依赖的异步操作时,会出现「回调地狱」(Callback Hell)—— 嵌套层级越来越深,代码可读性和可维护性极差。

示例:多个依赖的网络请求

// 先请求用户信息,再根据用户ID请求订单列表,再根据订单ID请求订单详情
$.get('/api/user', (user) => {
  $.get(`/api/orders?userId=${user.id}`, (orders) => {
    $.get(`/api/orderDetail?orderId=${orders[0].id}`, (detail) => {
      console.log('订单详情', detail);
    }, (err) => {
      console.error('请求订单详情失败', err);
    });
  }, (err) => {
    console.error('请求订单列表失败', err);
  });
}, (err) => {
  console.error('请求用户信息失败', err);
});

上面的代码嵌套了3层回调,看起来像「金字塔」,后续维护时要一层层找逻辑,非常痛苦。

2. 第二代异步:Promise(ES6)

Promise 是 ES6 引入的异步编程解决方案,它把异步操作封装成一个「承诺」对象,用链式调用替代嵌套回调,解决了回调地狱的问题。

Promise 的核心特性:

  • 有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)
  • 状态一旦改变(pending → fulfilled 或 pending → rejected),就会凝固,无法再改变
  • 提供 then() 方法用于处理成功结果,catch() 方法用于处理失败结果

示例1:创建 Promise 对象

const promise = new Promise((resolve, reject) => {
  // 异步操作:这里用定时器模拟网络请求
  setTimeout(() => {
    const success = true;
    if (success) {
      // 成功时调用 resolve,传递结果
      resolve('请求成功的数据');
    } else {
      // 失败时调用 reject,传递错误信息
      reject(new Error('请求失败'));
    }
  }, 1000);
});

示例2:用 Promise 解决回调地狱

// 先把异步请求封装成 Promise
function getUser() {
  return new Promise((resolve, reject) => {
    $.get('/api/user', resolve, reject);
  });
}

function getOrders(userId) {
  return new Promise((resolve, reject) => {
    $.get(`/api/orders?userId=${userId}`, resolve, reject);
  });
}

function getOrderDetail(orderId) {
  return new Promise((resolve, reject) => {
    $.get(`/api/orderDetail?orderId=${orderId}`, resolve, reject);
  });
}

// 链式调用,替代嵌套
getUser()
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetail(orders[0].id))
  .then(detail => console.log('订单详情', detail))
  .catch(err => console.error('请求失败', err)); // 统一捕获所有错误

链式调用把「金字塔」变成了「扁平结构」,逻辑清晰了很多,而且可以用一个 catch() 统一捕获所有环节的错误,不用每个回调都写错误处理。

Promise 常用静态方法:

  • Promise.all([p1, p2, p3]):接收一个 Promise 数组,所有 Promise 都成功才返回成功结果数组;只要有一个失败,就立即返回失败信息。适用于「多个独立异步操作,需要全部完成后再处理」的场景(比如同时请求多个接口数据)。
  • Promise.race([p1, p2, p3]):接收一个 Promise 数组,只要有一个 Promise 状态改变(成功或失败),就立即返回该结果。适用于「超时控制」场景(比如请求接口超过3秒就提示超时)。
  • Promise.resolve(data):快速创建一个成功状态的 Promise,直接传递数据。
  • Promise.reject(err):快速创建一个失败状态的 Promise,直接传递错误信息。

三、第三代异步:Async/Await(ES2017)

Promise 解决了回调地狱,但链式调用依然需要写 then(),代码还是不够直观。Async/Await 是 ES2017 引入的语法糖,基于 Promise 实现,让异步代码看起来像同步代码一样简洁。

Async/Await 的核心用法:

  • async 关键字修饰函数,该函数会自动返回一个 Promise 对象
  • async 函数内部,用 await 关键字等待 Promise 完成(只能在 async 函数内部使用)
  • try/catch 捕获异步操作的错误(替代 Promise 的 catch()

示例:用 Async/Await 重写之前的嵌套请求

// 复用之前封装好的 Promise 函数
function getUser() { /* ... */ }
function getOrders(userId) { /* ... */ }
function getOrderDetail(orderId) { /* ... */ }

// 用 async/await 编写异步代码
async function getOrderInfo() {
  try {
    const user = await getUser(); // 等待获取用户信息
    const orders = await getOrders(user.id); // 等待获取订单列表
    const detail = await getOrderDetail(orders[0].id); // 等待获取订单详情
    console.log('订单详情', detail);
    return detail; // async 函数返回的结果会被包装成 Promise 成功值
  } catch (err) {
    console.error('请求失败', err); // 捕获所有异步操作的错误
    throw err; // 可选:把错误抛出去,让调用者可以继续处理
  }
}

// 调用 async 函数
getOrderInfo();

上面的代码完全没有嵌套和 then(),看起来和同步代码几乎一样,可读性大大提升。这也是目前前端异步编程的主流方案。

Async/Await 高级技巧:并行执行异步操作

注意:如果直接用多个 await 执行独立的异步操作,会变成串行执行(上一个完成才执行下一个),效率很低。如果多个异步操作没有依赖关系,应该用 Promise.all() 并行执行。

// 错误示范:串行执行,总耗时 = 1秒 + 2秒 + 3秒 = 6秒
async function getMultiData() {
  const data1 = await new Promise(resolve => setTimeout(() => resolve('数据1'), 1000));
  const data2 = await new Promise(resolve => setTimeout(() => resolve('数据2'), 2000));
  const data3 = await new Promise(resolve => setTimeout(() => resolve('数据3'), 3000));
  return [data1, data2, data3];
}

// 正确示范:并行执行,总耗时 = 3秒(取最长的异步操作时间)
async function getMultiData() {
  // 先创建所有 Promise(立即执行)
  const promise1 = new Promise(resolve => setTimeout(() => resolve('数据1'), 1000));
  const promise2 = new Promise(resolve => setTimeout(() => resolve('数据2'), 2000));
  const promise3 = new Promise(resolve => setTimeout(() => resolve('数据3'), 3000));
  // 同时等待所有 Promise 完成
  const [data1, data2, data3] = await Promise.all([promise1, promise2, promise3]);
  return [data1, data2, data3];
}

四、常见误区与最佳实践

1. 误区:忘记处理错误

Promise 如果不写 catch(),Async/Await 如果不写 try/catch,异步操作的错误会被忽略(在浏览器控制台会报 Uncaught (in promise) Error),导致程序隐藏 bug。

解决方案:

  • Promise 必须加 catch() 捕获错误
  • Async/Await 必须用 try/catch 包裹 await 操作
  • 全局捕获未处理的 Promise 错误(兜底方案):
// 全局捕获未处理的 Promise 错误
window.addEventListener('unhandledrejection', (event) => {
  console.error('未处理的 Promise 错误', event.reason);
  event.preventDefault(); // 阻止浏览器默认报错
});

2. 误区:滥用 await 导致性能问题

如前所述,没有依赖关系的异步操作,不要用多个 await 串行执行,要用 Promise.all() 并行执行,提升效率。

3. 最佳实践

  • 优先使用 Async/Await 编写异步代码,可读性最高
  • 封装异步操作时,返回 Promise 对象,方便复用
  • 多个独立异步操作用 Promise.all() 并行执行
  • 必须处理异步错误,不要依赖全局捕获(仅作为兜底)
  • 避免在循环中使用 await(会串行执行),如果需要循环执行异步操作,用 Promise.all() + map() 替代

五、总结

JavaScript 异步编程的演进是一个「从复杂到简洁」的过程:回调函数解决了异步执行的问题,但带来了回调地狱;Promise 用链式调用解决了回调地狱,但代码仍有冗余;Async/Await 基于 Promise 实现,让异步代码变得像同步代码一样直观。

实际开发中,建议:

  1. 封装异步逻辑时,返回 Promise 对象;
  2. 编写业务逻辑时,用 Async/Await 简化代码;
  3. 根据场景选择并行(Promise.all)或串行(await)执行异步操作;
  4. 重视错误处理,避免隐藏 bug。