JavaScript 异步编程完全指南:从回调地狱到 async/await

深入理解 JavaScript 异步编程的演进历程,从回调函数到 Promise 再到 async/await,掌握异步编程的核心概念和最佳实践。

20 分钟阅读
小明

JavaScript 异步编程完全指南:从回调地狱到 async/await

「这段代码为什么不按顺序执行?」

「我明明先调用了 A,为什么 B 的结果先出来?」

「Promise 到底是个什么玩意儿?」

如果你曾被这些问题困扰过,恭喜你,你正在经历每个 JavaScript 开发者的必经之路——理解异步

今天小明就带你从头到尾搞懂 JavaScript 的异步编程,保证你看完之后,再也不会被异步代码绕晕。


为什么 JavaScript 需要异步?

单线程的宿命

JavaScript 是单线程的。这意味着它一次只能做一件事。

想象一下,你在餐厅当服务员,但你是「单线程服务员」:

  • 点完 1 号桌的菜,你必须站在厨房门口等菜做好
  • 菜没好,你不能去服务 2 号桌
  • 3 号桌想买单?等着,我还在等 1 号桌的菜呢

这效率也太低了吧?

如果 JavaScript 真这么干,你点开一个网页,发起一个请求,整个页面就卡死了,等请求回来才能动。这用户体验简直是灾难。

异步的救赎

异步就是「非阻塞」的意思。

还是那个服务员:

  • 点完 1 号桌的菜,把订单交给厨房,然后去服务 2 号桌
  • 厨房做好了会喊你(回调)
  • 你听到喊声,再去端菜给 1 号桌

这就是异步模式。你不用干等,可以同时服务多桌客人。

// 同步方式(阻塞)
const data = fetchDataSync(); // 卡在这里等
console.log(data);
console.log('后面的代码'); // 等上面执行完才能执行

// 异步方式(非阻塞)
fetchDataAsync((data) => {
  console.log(data);
});
console.log('后面的代码'); // 立即执行,不等

第一阶段:回调函数(Callback)

最原始的异步方案

回调函数是 JavaScript 最早的异步解决方案。

思路很简单:既然我不知道什么时候完成,那你完成了告诉我就行。

// 模拟一个异步操作:2秒后返回数据
function fetchUser(callback) {
  setTimeout(() => {
    const user = { id: 1, name: '小明' };
    callback(user);  // 完成后调用回调函数
  }, 2000);
}

// 使用
console.log('开始请求...');
fetchUser((user) => {
  console.log('收到数据:', user);
});
console.log('请求已发出,继续执行其他代码');

// 输出顺序:
// 开始请求...
// 请求已发出,继续执行其他代码
// (2秒后)收到数据: { id: 1, name: '小明' }

Node.js 的错误优先回调

Node.js 建立了一个约定:回调函数的第一个参数是错误对象。

const fs = require('fs');

// 错误优先回调(Error-First Callback)
fs.readFile('file.txt', 'utf8', (error, data) => {
  if (error) {
    console.error('读取失败:', error);
    return;
  }
  console.log('文件内容:', data);
});

这个约定很重要,因为异步操作可能失败,我们需要一种统一的方式处理错误。

回调地狱(Callback Hell)

回调函数看起来不错,直到你遇到这种情况:

需求:先获取用户,再根据用户 ID 获取订单,再根据订单获取商品详情。

// 回调地狱示例
getUser(userId, (error, user) => {
  if (error) {
    handleError(error);
    return;
  }
  getOrders(user.id, (error, orders) => {
    if (error) {
      handleError(error);
      return;
    }
    getOrderDetail(orders[0].id, (error, detail) => {
      if (error) {
        handleError(error);
        return;
      }
      getProduct(detail.productId, (error, product) => {
        if (error) {
          handleError(error);
          return;
        }
        console.log('商品信息:', product);
        // 还要继续嵌套吗?我已经晕了...
      });
    });
  });
});

看到那个「金字塔」了吗?这就是臭名昭著的回调地狱

问题:

  1. 可读性差:代码不断向右缩进,难以阅读
  2. 难以维护:逻辑散落在各个回调中
  3. 错误处理繁琐:每层都要处理错误
  4. 难以复用:逻辑耦合在一起

小明冷笑话时间:

回调地狱不是地狱,是深渊。 你以为到底了,结果还能继续往下嵌套。


第二阶段:Promise

Promise 是什么?

Promise(承诺)是 ES6 引入的异步解决方案。

你可以把 Promise 想象成一张「期货单」:

  • Pending(进行中):订单已下,商品在路上
  • Fulfilled(已完成):商品到了,可以取货
  • Rejected(已拒绝):商品缺货,订单取消
// 创建一个 Promise
const promise = new Promise((resolve, reject) => {
  // 异步操作
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve('操作成功!');  // 完成
    } else {
      reject('操作失败!');   // 拒绝
    }
  }, 1000);
});

// 使用 Promise
promise
  .then((result) => {
    console.log(result);  // '操作成功!'
  })
  .catch((error) => {
    console.error(error);
  });

Promise 的三种状态

        ┌─────────────┐
        │   Pending   │
        │  (进行中)   │
        └──────┬──────┘
               │
       ┌───────┴───────┐
       │               │
       ▼               ▼
┌─────────────┐ ┌─────────────┐
│  Fulfilled  │ │  Rejected   │
│  (已完成)   │ │  (已拒绝)   │
└─────────────┘ └─────────────┘

注意:状态一旦改变就不可逆。Pending 变成 Fulfilled 后,就不能再变成 Rejected。

链式调用:告别回调地狱

Promise 最强大的特性是链式调用.then() 返回的还是一个 Promise,所以可以继续 .then()

// 回调地狱 → Promise 链
getUser(userId)
  .then((user) => {
    return getOrders(user.id);
  })
  .then((orders) => {
    return getOrderDetail(orders[0].id);
  })
  .then((detail) => {
    return getProduct(detail.productId);
  })
  .then((product) => {
    console.log('商品信息:', product);
  })
  .catch((error) => {
    // 统一处理所有错误
    console.error('出错了:', error);
  });

对比一下:

  • 回调地狱:向右发展,金字塔形状
  • Promise 链:向下发展,线性流程

Promise 的常用方法

then() - 成功回调

promise.then(
  (value) => { /* 成功时执行 */ },
  (error) => { /* 失败时执行(可选)*/ }
);

catch() - 错误捕获

promise
  .then((value) => { /* ... */ })
  .catch((error) => { /* 捕获前面所有的错误 */ });

finally() - 无论成功失败都执行

promise
  .then((value) => { /* ... */ })
  .catch((error) => { /* ... */ })
  .finally(() => { 
    // 清理工作,无论成功失败都会执行
    console.log('请求结束');
  });

Promise 的静态方法

Promise.all() - 全部完成

等待所有 Promise 都完成,如果有一个失败,整体失败。

const p1 = fetch('/api/user');
const p2 = fetch('/api/orders');
const p3 = fetch('/api/products');

Promise.all([p1, p2, p3])
  .then(([user, orders, products]) => {
    // 三个请求都完成了
    console.log('用户:', user);
    console.log('订单:', orders);
    console.log('商品:', products);
  })
  .catch((error) => {
    // 任意一个失败就进这里
    console.error('有请求失败:', error);
  });

适用场景:多个请求相互独立,需要全部数据才能渲染页面。

Promise.race() - 竞速

返回最先完成的那个 Promise 的结果(无论成功还是失败)。

// 实现请求超时
const fetchData = fetch('/api/data');
const timeout = new Promise((_, reject) => {
  setTimeout(() => reject('请求超时'), 5000);
});

Promise.race([fetchData, timeout])
  .then((data) => {
    console.log('请求成功:', data);
  })
  .catch((error) => {
    console.log(error);  // 可能是 '请求超时'
  });

Promise.allSettled() - 全部完成(不管成功失败)

等待所有 Promise 都有结果,不管成功还是失败。

const promises = [
  fetch('/api/user'),
  fetch('/api/maybe-404'),
  fetch('/api/products'),
];

Promise.allSettled(promises).then((results) => {
  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      console.log(`请求 ${index} 成功:`, result.value);
    } else {
      console.log(`请求 ${index} 失败:`, result.reason);
    }
  });
});

适用场景:需要知道每个请求的结果,不希望一个失败影响其他。

Promise.any() - 任意一个成功

返回第一个成功的 Promise,只有全部失败才算失败。

// 从多个镜像源获取资源,哪个快用哪个
const mirrors = [
  fetch('https://mirror1.com/file'),
  fetch('https://mirror2.com/file'),
  fetch('https://mirror3.com/file'),
];

Promise.any(mirrors)
  .then((response) => {
    console.log('从最快的镜像获取到了:', response);
  })
  .catch((error) => {
    console.log('所有镜像都失败了');
  });

Promise 的常见陷阱

陷阱 1:忘记 return

// ❌ 错误:忘记 return
getUser()
  .then((user) => {
    getOrders(user.id);  // 这个 Promise 被「吞掉」了
  })
  .then((orders) => {
    console.log(orders);  // undefined!
  });

// ✅ 正确:记得 return
getUser()
  .then((user) => {
    return getOrders(user.id);  // 返回 Promise
  })
  .then((orders) => {
    console.log(orders);  // 正确获取到 orders
  });

陷阱 2:在 then 里面嵌套 then

// ❌ 错误:Promise 地狱(和回调地狱一样糟糕)
getUser()
  .then((user) => {
    getOrders(user.id)
      .then((orders) => {
        getOrderDetail(orders[0].id)
          .then((detail) => {
            // 又嵌套了...
          });
      });
  });

// ✅ 正确:扁平化链式调用
getUser()
  .then((user) => getOrders(user.id))
  .then((orders) => getOrderDetail(orders[0].id))
  .then((detail) => console.log(detail));

陷阱 3:没有处理错误

// ❌ 危险:没有 catch,错误会被吞掉
getUser()
  .then((user) => {
    throw new Error('出错了');
  });
// 这个错误不会有任何提示

// ✅ 安全:添加 catch
getUser()
  .then((user) => {
    throw new Error('出错了');
  })
  .catch((error) => {
    console.error('捕获到错误:', error);
  });

第三阶段:async/await

语法糖的甜蜜

async/await 是 ES2017 引入的语法,它是 Promise 的语法糖,让异步代码看起来像同步代码。

// Promise 写法
function getData() {
  return getUser()
    .then((user) => getOrders(user.id))
    .then((orders) => getOrderDetail(orders[0].id))
    .then((detail) => getProduct(detail.productId));
}

// async/await 写法
async function getData() {
  const user = await getUser();
  const orders = await getOrders(user.id);
  const detail = await getOrderDetail(orders[0].id);
  const product = await getProduct(detail.productId);
  return product;
}

哪个更好读?不用我说了吧。

async 函数的特点

  1. async 函数总是返回 Promise
async function foo() {
  return 'hello';
}

// 等价于
function foo() {
  return Promise.resolve('hello');
}

foo().then(console.log);  // 'hello'
  1. await 只能在 async 函数内使用
// ❌ 错误:await 在普通函数中
function foo() {
  const data = await fetchData();  // SyntaxError
}

// ✅ 正确:await 在 async 函数中
async function foo() {
  const data = await fetchData();  // OK
}
  1. await 会暂停 async 函数的执行
async function demo() {
  console.log('1. 开始');
  
  const result = await new Promise((resolve) => {
    setTimeout(() => resolve('异步结果'), 1000);
  });
  
  console.log('2. 拿到结果:', result);
  console.log('3. 结束');
}

demo();
console.log('4. async 函数外');

// 输出顺序:
// 1. 开始
// 4. async 函数外
// (1秒后)
// 2. 拿到结果: 异步结果
// 3. 结束

错误处理:try/catch

async/await 的错误处理更加直观:

async function getData() {
  try {
    const user = await getUser();
    const orders = await getOrders(user.id);
    return orders;
  } catch (error) {
    console.error('请求失败:', error);
    // 可以返回默认值或重新抛出
    return [];
  } finally {
    console.log('请求结束');
  }
}

并行执行 vs 串行执行

串行执行(慢)

async function serial() {
  console.time('serial');
  
  const user = await fetchUser();      // 等 1 秒
  const orders = await fetchOrders();  // 再等 1 秒
  const products = await fetchProducts(); // 再等 1 秒
  
  console.timeEnd('serial');  // serial: 3000ms
}

三个请求相互独立,但我们串行执行,浪费了 2 秒。

并行执行(快)

async function parallel() {
  console.time('parallel');
  
  // 同时发起三个请求
  const [user, orders, products] = await Promise.all([
    fetchUser(),
    fetchOrders(),
    fetchProducts(),
  ]);
  
  console.timeEnd('parallel');  // parallel: 1000ms
}

三个请求同时发起,只需要最长的那个请求的时间。

经验法则

  • 如果请求之间有依赖关系(需要前一个的结果),用串行
  • 如果请求相互独立,用 Promise.all() 并行

async/await 的常见陷阱

陷阱 1:循环中的 await

// ❌ 问题:串行执行,很慢
async function processItems(items) {
  for (const item of items) {
    await processItem(item);  // 一个一个处理
  }
}

// ✅ 优化:并行执行,更快
async function processItems(items) {
  await Promise.all(items.map((item) => processItem(item)));
}

// 💡 如果需要限制并发数,可以使用专门的库
// 如 p-limit 或 p-map

陷阱 2:忘记 await

async function foo() {
  // ❌ 忘记 await,getData 返回的是 Promise 对象
  const data = getData();
  console.log(data);  // Promise { <pending> }
  
  // ✅ 正确
  const data = await getData();
  console.log(data);  // 实际数据
}

陷阱 3:在非 async 函数中捕获错误

// ❌ 错误:try/catch 捕获不到异步错误
function foo() {
  try {
    asyncFunction();  // 没有 await
  } catch (error) {
    // 这里捕获不到 asyncFunction 内部的错误
  }
}

// ✅ 正确:使用 async/await
async function foo() {
  try {
    await asyncFunction();
  } catch (error) {
    // 可以捕获到错误
  }
}

// ✅ 或者使用 .catch()
function foo() {
  asyncFunction().catch((error) => {
    // 可以捕获到错误
  });
}

事件循环:理解异步的底层原理

要真正理解异步,你需要了解 JavaScript 的事件循环(Event Loop)

调用栈、任务队列和事件循环

┌─────────────────────────────────────────────────────┐
│                    JavaScript 引擎                   │
│  ┌─────────────┐                                    │
│  │   调用栈    │                                    │
│  │  Call Stack │                                    │
│  │             │                                    │
│  └─────────────┘                                    │
└─────────────────────────────────────────────────────┘
         ↑                          │
         │                          ↓
┌─────────────────────────────────────────────────────┐
│                      事件循环                        │
│                    Event Loop                       │
└─────────────────────────────────────────────────────┘
         ↑                          │
         │                          ↓
┌─────────────────────────────────────────────────────┐
│  ┌─────────────────────┐  ┌─────────────────────┐  │
│  │   宏任务队列         │  │   微任务队列         │  │
│  │ setTimeout/setInterval│ │ Promise.then/catch  │  │
│  │ I/O、DOM 事件        │  │ MutationObserver    │  │
│  └─────────────────────┘  └─────────────────────┘  │
└─────────────────────────────────────────────────────┘

宏任务 vs 微任务

类型举例优先级
微任务(Microtask)Promise.then/catch/finallyqueueMicrotaskMutationObserver
宏任务(Macrotask)setTimeoutsetIntervalI/ODOM 事件requestAnimationFrame

执行顺序

  1. 执行同步代码
  2. 清空微任务队列
  3. 执行一个宏任务
  4. 清空微任务队列
  5. 重复 3-4

经典面试题

console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

Promise.resolve().then(() => {
  console.log('3');
});

console.log('4');

// 输出顺序是什么?

分析:

  1. console.log('1') - 同步,直接执行,输出 1
  2. setTimeout - 宏任务,放入宏任务队列
  3. Promise.then - 微任务,放入微任务队列
  4. console.log('4') - 同步,直接执行,输出 4
  5. 同步代码执行完,清空微任务队列,输出 3
  6. 执行宏任务,输出 2

答案:1 4 3 2

再来一道

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2');
}

console.log('script start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

async1();

new Promise((resolve) => {
  console.log('promise1');
  resolve();
}).then(() => {
  console.log('promise2');
});

console.log('script end');

// 输出顺序是什么?

答案:

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

关键点:

  • await async2() 后面的代码相当于 .then() 回调,是微任务
  • new Promise() 的执行器函数是同步执行的

实战:封装一个好用的请求函数

学了这么多,来个实战:

// utils/request.js

/**
 * 封装 fetch,支持超时、重试、错误处理
 */
async function request(url, options = {}) {
  const {
    timeout = 10000,
    retries = 3,
    retryDelay = 1000,
    ...fetchOptions
  } = options;

  // 创建超时 Promise
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('请求超时')), timeout);
  });

  // 重试逻辑
  for (let i = 0; i < retries; i++) {
    try {
      const response = await Promise.race([
        fetch(url, fetchOptions),
        timeoutPromise,
      ]);

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      return await response.json();
    } catch (error) {
      const isLastRetry = i === retries - 1;
      
      if (isLastRetry) {
        throw error;
      }

      console.warn(`请求失败,${retryDelay}ms 后重试 (${i + 1}/${retries})`);
      await new Promise((resolve) => setTimeout(resolve, retryDelay));
    }
  }
}

// 使用示例
async function loadUserData() {
  try {
    const user = await request('/api/user', {
      timeout: 5000,
      retries: 2,
    });
    console.log('用户数据:', user);
  } catch (error) {
    console.error('获取用户数据失败:', error.message);
  }
}

总结

JavaScript 异步编程的演进:

阶段方案优点缺点
1回调函数简单直接回调地狱、错误处理困难
2Promise链式调用、统一的错误处理还是有点绕
3async/await同步写法、直观易读需要注意并行/串行

实践建议

  1. 优先使用 async/await:代码最清晰
  2. 相互独立的请求用 Promise.all():提高性能
  3. 总是处理错误:try/catch 或 .catch()
  4. 理解事件循环:知其然更要知其所以然

记住这句话:

异步不是魔法,只是把「等待」变成了「预约」。

当你搞懂了 JavaScript 是怎么管理这些「预约」的,异步就再也难不倒你了。


小明冷笑话收尾:

问:为什么 JavaScript 开发者不喜欢等待? 答:因为他们习惯了 await,觉得等待应该是 async 的事,不该是人的事。

问:Promise 和渣男有什么共同点? 答:都会给你一个承诺,但你不知道会 resolve 还是 reject。

「异步是一种思维方式,不只是一种语法。」—— 小明