JavaScript 异步编程学习笔记:从回调到 async/await

异步编程是 JavaScript 中最核心的概念之一。不同于 Python、Java 这样的同步语言,JavaScript 从诞生之初就是单线程、事件驱动的。这意味着它必须有一套机制来处理"现在不急、等会儿再说"的操作——比如网络请求、定时器、用户交互。

这篇笔记梳理一下 JS 异步编程的演进过程,从最早的回调函数,到 Promise,再到 async/await,理解每种方案出现的背景和它试图解决的问题。

回调函数:最原始的方案

回调函数是 JavaScript 处理异步最原始的方式。你告诉浏览器"去做这件事,做完之后调用这个函数"。这个机制本身没有问题,问题出在嵌套上。

getUser(function(user) {
  getOrders(user.id, function(orders) {
    getOrderDetail(orders[0].id, function(detail) {
      // 终于拿到详情了...
      console.log(detail);
    });
  });
});

这就是臭名昭著的"回调地狱"(Callback Hell)。每一层嵌套增加一层缩进,代码变成向右生长的三角形。可读性差是一方面,更难的是错误处理——每一层都需要单独处理错误,而且很容易遗漏。

Promise:链式调用的革命

ES6 引入了 Promise。它本质上是一个代表异步操作最终完成(或失败)的对象。Promise 的价值不仅在于它本身,更在于它改变了异步代码的写法——从嵌套变成了链式调用。

getUser()
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetail(orders[0].id))
  .then(detail => console.log(detail))
  .catch(error => console.error(error));

扁平化了,可读性好多了。而且统一的 .catch() 可以捕获链上任何一处的错误。这是巨大的进步。

Promise 有三个状态:pending(进行中)、fulfilled(已完成)、rejected(已失败)。状态一旦改变就不可逆,这个特性让异步逻辑变得可预测。常用的组合方法包括:

  • Promise.all() — 所有 Promise 都完成后才 resolve,适合并行请求
  • Promise.race() — 第一个完成的 Promise 决定最终状态,适合做超时控制
  • Promise.allSettled() — 等所有 Promise 都有结果(无论成败),适合批量操作中需要知道每个的结果
  • Promise.any() — 只要有一个成功就 resolve,适合多源冗余请求

async/await:让异步看起来像同步

ES2017 引入了 async/await,本质上是 Promise 的语法糖,但带来的体验提升是质的飞跃。它让异步代码看起来几乎和同步代码一样,大大降低了心智负担。

async function loadOrderDetail() {
  try {
    const user = await getUser();
    const orders = await getOrders(user.id);
    const detail = await getOrderDetail(orders[0].id);
    console.log(detail);
  } catch (error) {
    console.error('加载失败:', error);
  }
}

和 Promise 链式调用相比,async/await 的优势在于:

  • 代码结构是线性的,从上到下读就行
  • 条件分支更自然——可以用 if/else 来决定下一步
  • 调试更容易——可以像同步代码一样打断点
  • 错误处理用熟悉的 try/catch

常见的使用陷阱

async/await 虽然好用,但也有几个常见的陷阱值得注意。

串行执行导致性能问题

await 是串行的。如果多个异步操作之间没有依赖关系,不应该串行等待:

// 慢:串行执行,总耗时 = A + B + C
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();

// 快:并行执行,总耗时 = max(A, B, C)
const [a, b, c] = await Promise.all([
  fetchA(),
  fetchB(),
  fetchC()
]);

忘记处理错误

很多人用了 async/await 就忘了 try/catch。如果 await 的 Promise 被 reject 但没有被捕获,会产生一个 unhandled rejection,在某些环境下会导致程序退出。建议养成习惯:每个 await 都在 try/catch 块中,或者至少在调用 async 函数的地方做错误处理。

在非 async 函数中使用 await

await 只能在 async 函数内部使用。在普通函数或者全局作用域中直接使用 await 会报错(ES2022 引入了顶层 await,但需要模块环境支持)。

实际项目中的选择

在 2026 年的今天,写异步代码基本都在用 async/await。但理解底层的 Promise 机制仍然很重要——因为 async/await 本质上就是 Promise 的语法糖。当你需要处理复杂的并发场景时(比如动态数量的并行请求),还是得回到 Promise 的 API 来。

async/await 让简单的事情变简单了,但并没有让复杂的事情消失。理解 Promise,才能真正驾驭 async/await。

小结

从回调到 Promise 再到 async/await,JavaScript 异步编程的演进方向始终是:让代码更接近人类的思维方式。嵌套变扁平、链式变线性、隐式变显式。每一步改进都不是推翻重来,而是在前一阶段的基础上的优化。这种渐进式的演进也是 JavaScript 生态的一大特点。

如果你刚接触 JavaScript 的异步编程,建议的学习路径是:先理解回调的概念,再学习 Promise 的机制,最后用 async/await 来写日常代码。三个层次都掌握了,遇到复杂场景才不会慌。