🧠异步编程,从回调到Promise、async/await 的进化之路✍️

208 阅读6分钟

前言

在写 JavaScript 代码时,我们常常会遇到一些“慢动作”任务,比如从服务器获取数据、读取文件或者等待用户操作,这些任务不能立刻完成,但又不能让整个程序停下来等它们——这就是 异步编程 要解决的问题。

JavaScript 是单线程语言,它天生就擅长处理异步操作。但随着需求越来越复杂,异步的写法也经历了几次演变:

从最开始的 回调函数,到后来的 Promise,再到如今广泛使用的 async/await

下面我们来一步步了解它们是怎么演进的,以及为什么我们现在更喜欢用 async/await 来写异步代码。

1 同步与异步

同步代码:按顺序执行,每一步都必须等上一步完成。

console.log("A");
console.log("B");
// 输出顺序一定是 A → B

异步代码:某些操作可以“先发起”,不用立刻等待结果,继续执行其他任务。

console.log("A");
setTimeout(() => {
  console.log("B");
}, 1000);
console.log("C");
// 输出顺序是 A → C → B(1秒后)

2 早期处理异步操作 —— 回调函数嵌套(callback)

异步操作看起来有点不符合我们的思考方式,为了让开发者看起来更轻松,JS开发者想了办法:模拟顺序执行,让多个异步操作 按顺序执行,看起来像是同步一样一步一步。

fs.readFile('file1.txt', 'utf8', function(err, data1) {
  if (err) return console.error(err);
  fs.readFile(data1, 'utf8', function(err, data2) {
    if (err) return console.error(err);
    fs.readFile(data2, 'utf8', function(err, data3) {
      if (err) return console.error(err);
      console.log(data3);
    });
  });
});

虽然每个任务都是异步操作,但是它们好像是 “同步”代码 一样,先执行第一个 readFile,等他执行完毕后在执行第二个 readFile,等他执行完毕后再执行最后一个 readFile。这样就实现了多个异步操作的顺序执行

但是这样有很大弊端,如果有100个异步操作怎么办?一层层嵌套到眼花!

这就是所谓的:回调地狱

还有,每个异步操作都要手动判断 if (err),然后处理错误,非常不利于统一管理,使代码混乱臃肿。

3 Promise的引入

为了避免回调地狱,ES6 引入了 Promise,它是对异步操作的封装

Promise 的三种状态:

  1. pending(进行中)
  2. fulfilled(成功)
  3. rejected(失败)

基本用法:

const promise = new Promise((resolve, reject) => {
  // 模拟异步操作
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve("操作成功!");
    } else {
      reject("出错了!");
    }
  }, 1000);
});

promise
  .then(result => console.log(result))   // 成功处理
  .catch(error => console.error(error)); // 失败处理

链式调用(Chaining):

readFile('file1.txt')
  .then(data1 => readFile(data1))
  .then(data2 => readFile(data2))
  .then(data3 => console.log(data3))
  .catch(err => console.error(err));

要记住 每个 .then() 是前一个的结果

且 then方法里面实质是执行 回调函数。

为了更好理解 resolve 和 reject,我们简要看一下Promise 函数的底层代码 (.then方法未写) :

function MyPromise(executor) {
  const self = this;
  self.status = 'pending';   // 当前状态
  self.value = undefined;    // 成功的值
  self.reason = undefined;   // 失败的原因
  self.onFulfilledCallbacks = []; // 存储成功的回调
  self.onRejectedCallbacks = [];  // 存储失败的回调

  function resolve(value) {
    if (self.status === 'pending') {
      self.status = 'fulfilled';
      self.value = value;
      // 执行所有成功回调
      self.onFulfilledCallbacks.forEach(fn => fn());
    }
  }

  function reject(reason) {
    if (self.status === 'pending') {
      self.status = 'rejected';
      self.reason = reason;
      // 执行所有失败回调
      self.onRejectedCallbacks.forEach(fn => fn());
    }
  }

  try {
    executor(resolve, reject); // 执行用户传入的函数
  } catch (e) {
    reject(e); // 捕获执行器中的错误
  }
}

fetch API

fetch() 是浏览器内置的一个用于发起网络请求的方法,返回一个 Promise,常用于从服务器获取数据(如 JSON)。

基本用法:

fetch('https://jsonplaceholder.typicode.com/posts/1')
  .then(response => response.json()) // 将响应体转为 JSON
  .then(data => console.log(data))   // 打印数据
  .catch(error => console.error('Error:', error));

注意事项: fetch 不会自动抛出错误(即使 HTTP 状态码是 4xx 或 5xx),需要手动判断:

fetch('https://example.com/data')
 .then(res => {
   if (!res.ok) throw new Error("HTTP 错误");
   return res.json();
 })
 .then(data => console.log(data))
 .catch(err => console.error(err));

4 async / await

ES8引入了 asyncawait,让异步代码看起来更像同步代码,极大提升了可读性和可维护性。

async function getData() {
  try {
    const user = await fetchUser(1);
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);

    console.log(comments);
  } catch (error) {
    console.error("获取数据失败:", error);
  }
}

getData();

这段代码看起来就像同步函数一样,逻辑清晰,易于调试和测试。

await 的作用到底是什么?

await 关键字只能出现在 async 函数内部,它的作用是:

暂停当前函数的执行,直到右边的 Promise 被解决(resolved),然后返回 Promise 的结果值。

例如:

const result = await someAsyncFunction();
console.log(result); // 这里拿到的是 Promise 成功后的返回值

虽然 JS 是单线程语言,但 await 并不会“卡住”整个程序,只是“暂停”当前 async 函数的执行,主线程可以继续运行其他任务。

有几个点需要非常注意:

  1. await 只能在 async 函数内部使用
  2. await 会暂停当前函数执行,直到右边的 Promise 被解决

类比生活中的例子

想象你在餐厅点了一份牛排和一杯咖啡:

  • 回调方式:你点完餐就去做别的事,服务员端上来时通知你;
  • Promise 方式:你点餐后说:“等我牛排好了再上甜点”,然后继续做别的;
  • async/await 方式:你说:“等我吃完牛排再点甜点。” —— 你不需要关心背后是不是异步,只要在逻辑上顺序写下来就行

5 多个异步操作的 【同步感】

什么是同步感?

传统的异步写法(比如回调函数或 .then() 链式调用)会让代码看起来不像是顺序执行的。

async/await 的出现,让我们可以用类似同步的方式去写异步代码 —— 就像下面这样:

async function getUserData() {
  const user = await fetchUser(1);        // 等待用户数据
  const posts = await fetchPosts(user.id); // 等待文章数据
  const comments = await fetchComments(posts[0].id); // 等待评论数据

  console.log(comments);
}

这段代码从上到下依次执行:

  • 先获取用户信息;
  • 再根据用户 ID 获取文章;
  • 最后根据文章 ID 获取评论;

虽然这些操作都是异步的,但我们在写法上可以像同步一样按顺序书写和阅读,这就是所谓的 “具有同步感”

注意: .then() 本身是同步的,它只是为异步操作注册回调。真正异步的是 Promise 的 resolve 过程。

.then() 链让我们看起来像是按顺序执行多个异步任务,这和 await 的效果其实是一样的。

总结一下:

特性同步代码异步代码(传统)async/await
执行顺序顺序执行不确定(依赖回调触发顺序执行(视觉上
错误处理try/catch每个回调都要判断 err统一 try/catch 处理异常
可读性
编写体验直观易嵌套、难维护更接近自然语言顺序
是否阻塞主线程不会不会(只是暂停 async 函数)

这也是为什么后来 JavaScript 社区逐渐转向了 Promise,以及更进一步的 async/await,它们不仅保留了“顺序感”,还极大地提升了代码的可读性、可维护性和开发体验