十二:JavaScript 异步编程

一、为什么异步是 JavaScript 的必修课

假设你打开一个网页,点击“加载更多”按钮。如果浏览器必须等服务器返回数据之后才能响应你的下一次点击、滚动或输入,那么在等待的那几秒钟里,整个页面就会完全卡死——按钮点不动,文字选不了,滚动条拖不动。

这就是同步阻塞的后果。JavaScript 从诞生之初就面临这个问题:它是单线程语言,同一时间只能做一件事。如果这件事是读取文件、请求网络、定时等待等耗时操作,单线程模型就会暴露出致命的弱点——用户界面完全冻结。

JavaScript 的解决方案不是“变成多线程”,而是异步编程:把耗时操作交给浏览器的其他线程(网络线程、定时器线程、I/O 线程)去处理,主线程继续响应用户交互。等耗时操作完成了,再通过回调机制通知主线程处理结果。

这个模型看似简单,但它衍生出了一整套复杂的机制:回调函数、事件循环、任务队列、Promise、生成器、async/await。这一篇,我们将从底层的事件循环开始,一直讲到最现代的异步语法,把整条演进脉络彻底理清。

二、事件循环:JavaScript 异步的底层发动机

事件循环(Event Loop)是 JavaScript 运行时的心脏。理解它,你就理解了所有异步行为的调度规则。

2.1 为什么会有事件循环

JavaScript 引擎本身只做两件事:执行代码和管理内存(堆和调用栈)。它没有定时器、没有网络请求能力、没有文件读写能力。这些能力是宿主环境(浏览器或 Node.js)提供的。

宿主环境通过事件循环来协调 JS 引擎和这些外部能力:当 JS 引擎执行完当前任务后,事件循环从任务队列中取出下一个待执行的任务,交给 JS 引擎执行。如此往复,形成“循环”。

2.2 宏任务与微任务:两个队列的优先级之争

任务队列并不是单一的。规范中定义了两种任务源,它们进入不同的队列,优先级不同:

宏任务 微任务
包含哪些 script(整体脚本)、setTimeoutsetInterval、I/O、UI 渲染、postMessage Promise.then/catch/finallyMutationObserverqueueMicrotask
队列 宏任务队列(可以有多个) 微任务队列(只有一个)
执行时机 每个宏任务执行完后,下一个宏任务执行前 当前宏任务执行完毕后,立即清空整个微任务队列
优先级感受

2.3 事件循环的完整流程

浏览器中每一轮事件循环的简化流程如下:

  1. 宏任务队列中取出一个宏任务执行(首次执行时,整个 <script> 就是一个宏任务)。
  2. 执行过程中如果遇到微任务(如 Promise.then),将其加入微任务队列
  3. 当前宏任务执行完毕后,立即依次执行并清空微任务队列中的所有微任务
  4. 如果微任务执行过程中又产生了新的微任务,它们也会在这一轮被清空,直到微任务队列为空。
  5. 判断是否需要渲染更新(浏览器会根据刷新率决定,通常 60fps 即每 16.6ms 一次)。
  6. 开始下一轮事件循环,取下一个宏任务执行。

2.4 用代码验证宏任务与微任务的执行顺序

console.log('1: script start');

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

Promise.resolve()
  .then(() => {
    console.log('3: promise then 1');
  })
  .then(() => {
    console.log('4: promise then 2');
  });

console.log('5: script end');

输出顺序:

1: script start
5: script end
3: promise then 1
4: promise then 2
2: setTimeout

逐行解析:

  • console.log('1'):同步代码,立即执行。输出 1
  • setTimeout:将回调放入宏任务队列(即使延时是 0ms)。
  • Promise.resolve().then():将回调放入微任务队列
  • console.log('5'):同步代码,立即执行。输出 5
  • 当前宏任务(整个 script)执行完毕。
  • 开始清空微任务队列:执行第一个 .then,输出 3;这个 .then 又返回一个 Promise,其后续 .then 再次进入微任务队列;继续清空,输出 4
  • 微任务队列清空后,下一轮事件循环开始,从宏任务队列取出 setTimeout 回调执行,输出 2

核心结论:即使 setTimeout 的延时是 0ms,它的回调也必须等当前宏任务和所有微任务执行完毕后才能执行。Promise 的 .then 属于微任务,所以总是比同轮的 setTimeout 先执行。

三、回调函数:异步的最初形态

在 Promise 出现之前,JavaScript 处理异步的唯一方式是回调函数:把“异步操作完成后要做什么”封装成一个函数,作为参数传给异步 API。

// 读取文件(Node.js 环境)
fs.readFile('/path/to/file', 'utf-8', function(err, data) {
  if (err) {
    console.error('读取失败', err);
    return;
  }
  console.log('读取成功', data);
});

这种模式直观但有一个致命缺陷:当多个异步操作有依赖关系时(第一个请求的结果是第二个请求的参数,第二个请求的结果是第三个请求的参数……),代码会陷入层层嵌套,形如金字塔。这就是臭名昭著的回调地狱

getUser(userId, function(user) {
  getPosts(user.id, function(posts) {
    getComments(posts[0].id, function(comments) {
      getReplies(comments[0].id, function(replies) {
        console.log(replies);
        // 越来越深的缩进,越来越难以阅读和维护
      });
    });
  });
});

回调地狱带来的问题不只是格式上的丑陋:

  • 错误处理割裂:每个回调都需要单独处理错误,无法统一捕获。
  • 流程控制困难:并行执行多个异步操作、按顺序执行、竞速处理——这些在回调模式下需要自己写复杂的控制逻辑。
  • 代码不可组合:回调函数彼此独立,无法像同步代码那样用 return 传递结果或用 try...catch 捕获异常。

这些痛点直接催生了 Promise 的诞生。

四、Promise:异步流程控制的第一次统一

Promise 是 ES6(2015)正式引入的标准,它提供了一个可组合、可链式调用、统一错误处理的异步模型。

4.1 Promise 的三种状态

一个 Promise 对象在任何时刻必然处于以下三种状态之一:

  • pending(进行中):初始状态,异步操作尚未完成。
  • fulfilled(已成功):异步操作成功完成,有了结果值。
  • rejected(已失败):异步操作失败,有了失败原因。

关键规则:状态只能从 pending 变为 fulfilled(不可逆),或从 pending 变为 rejected(不可逆)。一旦状态确定,就永远凝固,不能再改变。这意味着一个 Promise 最多只会被决议一次

4.2 基本用法:创建与消费

// 创建 Promise
let promise = new Promise(function(resolve, reject) {
  // 异步操作
  setTimeout(() => {
    let success = true;  // 模拟成功或失败
    if (success) {
      resolve('数据获取成功');  // 将状态改为 fulfilled,并传递结果值
    } else {
      reject(new Error('数据获取失败'));  // 将状态改为 rejected,并传递错误原因
    }
  }, 1000);
});

// 消费 Promise
promise
  .then(function(result) {
    console.log('成功:', result);    // resolve 的值会传给 then 的第一个回调
    return result + ' + 额外处理';    // return 的值会被包装成新的 Promise,传给下一个 then
  })
  .then(function(processedResult) {
    console.log('进一步处理:', processedResult);
  })
  .catch(function(error) {
    console.error('失败:', error.message);  // reject 的值或任何中间抛出的异常会传到 catch
  })
  .finally(function() {
    console.log('无论成功还是失败都会执行');  // 清理操作,如隐藏 loading 动画
  });

then 的链式调用机制.then() 总是返回一个新的 Promise。这个新 Promise 的决议值取决于回调函数的返回值:

  • 如果回调返回一个普通值,新 Promise 立即以该值 fulfilled
  • 如果回调返回一个 Promise,新 Promise 会“跟随”它,等它决议后采用相同的状态和值。
  • 如果回调抛出了异常,新 Promise 会以该异常 rejected

这套规则使得 Promise 可以像管道一样串联异步操作,每个 .then 都是管道上的一个处理节点。

4.3 Promise 的静态方法:编排多个异步操作

Promise.all(iterable):等待所有 Promise 都成功,返回它们的结果数组。只要有一个失败,立即失败。

let p1 = fetch('/api/user');
let p2 = fetch('/api/posts');
let p3 = fetch('/api/comments');

Promise.all([p1, p2, p3])
  .then(([user, posts, comments]) => {
    console.log('三个请求都成功', user, posts, comments);
  })
  .catch(error => {
    console.error('至少一个请求失败', error);
  });

Promise.allSettled(iterable)(ES2020):等待所有 Promise 都完成(无论成功或失败),返回每个结果的状态和值。

Promise.allSettled([p1, p2, p3])
  .then(results => {
    results.forEach(r => {
      if (r.status === 'fulfilled') {
        console.log('成功', r.value);
      } else {
        console.log('失败', r.reason);
      }
    });
  });

Promise.race(iterable):竞速——返回第一个完成的 Promise 的结果(无论成功还是失败)。

let timeout = new Promise((_, reject) =>
  setTimeout(() => reject(new Error('请求超时')), 5000)
);

Promise.race([fetch('/api/data'), timeout])
  .then(response => response.json())
  .catch(error => console.error(error));

Promise.any(iterable)(ES2021):返回第一个成功的 Promise。如果全部失败,则返回一个聚合错误。

Promise.resolve(value)Promise.reject(reason):快速创建已决议的 Promise。

Promise.resolve('立即成功').then(console.log);   // 输出 '立即成功'
Promise.reject('立即失败').catch(console.error);  // 输出 '立即失败'

4.4 手写 Promise:理解其内核

实现一个符合 Promises/A+ 规范的简易 Promise,是检验你对异步和闭包理解程度的试金石。

class MyPromise {
  constructor(executor) {
    this.state = 'pending';      // 当前状态
    this.value = undefined;      // 成功值或失败原因
    this.onFulfilledCallbacks = []; // 成功回调队列
    this.onRejectedCallbacks = [];  // 失败回调队列

    const resolve = (value) => {
      if (this.state !== 'pending') return;
      this.state = 'fulfilled';
      this.value = value;
      // 异步执行所有成功回调(微任务)
      this.onFulfilledCallbacks.forEach(fn => fn());
    };

    const reject = (reason) => {
      if (this.state !== 'pending') return;
      this.state = 'rejected';
      this.value = reason;
      // 异步执行所有失败回调(微任务)
      this.onRejectedCallbacks.forEach(fn => fn());
    };

    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }

  then(onFulfilled, onRejected) {
    // 值穿透:如果未传回调,则传递值
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
    onRejected = typeof onRejected === 'function' ? onRejected : e => { throw e; };

    return new MyPromise((resolve, reject) => {
      const handleFulfilled = () => {
        queueMicrotask(() => {
          try {
            let result = onFulfilled(this.value);
            result instanceof MyPromise ? result.then(resolve, reject) : resolve(result);
          } catch (error) {
            reject(error);
          }
        });
      };

      const handleRejected = () => {
        queueMicrotask(() => {
          try {
            let result = onRejected(this.value);
            result instanceof MyPromise ? result.then(resolve, reject) : resolve(result);
          } catch (error) {
            reject(error);
          }
        });
      };

      if (this.state === 'fulfilled') {
        handleFulfilled();
      } else if (this.state === 'rejected') {
        handleRejected();
      } else {
        // pending 状态,将回调存入队列
        this.onFulfilledCallbacks.push(handleFulfilled);
        this.onRejectedCallbacks.push(handleRejected);
      }
    });
  }

  catch(onRejected) {
    return this.then(null, onRejected);
  }

  finally(onFinally) {
    return this.then(
      value => MyPromise.resolve(onFinally()).then(() => value),
      reason => MyPromise.resolve(onFinally()).then(() => { throw reason; })
    );
  }

  static resolve(value) {
    if (value instanceof MyPromise) return value;
    return new MyPromise(resolve => resolve(value));
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }
}

核心设计要点

  • 状态机state 确保 Promise 只能决议一次。
  • 回调队列:当 executor 是异步操作时,then 可能在 resolve 之前就被调用。此时需要将回调存入队列,等 resolvereject 执行时再触发。
  • queueMicrotask:确保 then 的回调以微任务形式执行,符合规范。
  • 链式调用then 返回新的 Promise,根据回调的返回值决定新 Promise 的状态,支持嵌套 Promise 的自动展开。

五、async/await:异步代码的同步写法

ES2017 引入的 async/await 是 Promise 的语法糖。它让你用同步风格的代码编写异步逻辑,彻底消除了 .then 链的视觉噪音。

5.1 基本语法

// async 函数总是返回一个 Promise
async function fetchUser(id) {
  // await 暂停函数执行,等待 Promise 决议后取出结果值
  let response = await fetch('/api/user/' + id);
  let user = await response.json();
  return user;  // 等价于 return Promise.resolve(user)
}

// 调用 async 函数,像使用 Promise 一样
fetchUser(1)
  .then(user => console.log(user))
  .catch(error => console.error(error));

5.2 错误处理:try...catch 回来了

Promise 的错误需要通过 .catch() 处理,而 async/await 恢复了 try...catch 的使用:

async function loadData() {
  try {
    let user = await fetchUser(1);
    let posts = await fetchPosts(user.id);
    console.log(posts);
  } catch (error) {
    // 统一捕获任何一个 await 抛出的异常
    console.error('加载失败:', error.message);
  } finally {
    console.log('清理工作');
  }
}

如果不用 try...catch,也可以在调用 async 函数的地方用 .catch(),因为 async 函数本身返回 Promise,内部抛出的异常会被转换为 Promise 的 rejected 状态。

5.3 串行与并行:性能的关键

串行执行(一个等一个,总耗时累加):

// 总耗时 = fetchUser 耗时 + fetchPosts 耗时
let user = await fetchUser(1);
let posts = await fetchPosts(user.id);

并行执行(同时发起,总耗时取最长的那个):

// 总耗时 = max(fetchUser 耗时, fetchPosts 耗时)
let [user, config] = await Promise.all([
  fetchUser(1),
  fetchConfig()
]);

当多个异步操作彼此独立、没有依赖关系时,应该用 Promise.all 让它们并行执行,而不是无意义地串行等待。

5.4 async/await 的底层本质

async/await 并不是什么全新的机制,它是生成器 + Promise + 自动执行器的语法封装。理解这一点,能帮你穿透语法糖的迷雾。

下面这段生成器代码在行为上等价于一个 async 函数:

// 用生成器模拟 async/await
function* fetchUserGenerator(id) {
  let response = yield fetch('/api/user/' + id);
  let user = yield response.json();
  return user;
}

// 自动执行器:递归驱动生成器的 yield
function asyncToGenerator(generatorFunc) {
  return function(...args) {
    let generator = generatorFunc.apply(this, args);
    return new Promise((resolve, reject) => {
      function step(key, arg) {
        let result;
        try {
          result = generator[key](arg);  // 执行 next() 或 throw()
        } catch (error) {
          return reject(error);
        }
        let { value, done } = result;
        if (done) {
          return resolve(value);
        } else {
          // 将 yield 出的值包装成 Promise,等待决议后递归
          return Promise.resolve(value).then(
            val => step('next', val),
            err => step('throw', err)
          );
        }
      }
      step('next');
    });
  };
}

// 使用
let fetchUserAsync = asyncToGenerator(fetchUserGenerator);
fetchUserAsync(1).then(user => console.log(user));

这个自动执行器的核心逻辑:

  1. 调用生成器函数,拿到迭代器对象。
  2. 调用 generator.next(),执行到第一个 yield,得到 yield 后面的 Promise。
  3. 等待这个 Promise 决议,然后将决议值作为 next() 的参数传回生成器(这对应了 let response = await fetch(...) 中的赋值)。
  4. 重复步骤 2-3,直到生成器执行完毕(done: true)。
  5. 返回最终结果的 Promise。

async/await 就是把这个自动执行器内建到了 JavaScript 引擎中,让开发者无需手动编写生成器和执行器。

六、综合演示:从回调到 async/await 的四种写法对比

假设有一个需求:获取用户信息 → 根据用户 ID 获取其文章列表 → 获取第一篇文章的评论列表。我们用四种方式分别实现:

// 模拟异步 API
function fetchUser(id) {
  return new Promise(resolve => setTimeout(() => resolve({ id, name: '用户' + id }), 1000));
}
function fetchPosts(userId) {
  return new Promise(resolve => setTimeout(() => resolve(['文章1', '文章2', '文章3']), 1000));
}
function fetchComments(postTitle) {
  return new Promise(resolve => setTimeout(() => resolve(['评论A', '评论B']), 1000));
}

// ===== 方式一:回调地狱 =====
function callbackHell(userId) {
  fetchUserCallback(userId, function(user) {
    fetchPostsCallback(user.id, function(posts) {
      fetchCommentsCallback(posts[0], function(comments) {
        console.log('回调地狱结果:', comments);
      });
    });
  });
}

// ===== 方式二:Promise 链 =====
function promiseChain(userId) {
  return fetchUser(userId)
    .then(user => fetchPosts(user.id))
    .then(posts => fetchComments(posts[0]))
    .then(comments => console.log('Promise 链结果:', comments))
    .catch(error => console.error('出错:', error));
}

// ===== 方式三:async/await 串行 =====
async function asyncSerial(userId) {
  try {
    let user = await fetchUser(userId);
    let posts = await fetchPosts(user.id);
    let comments = await fetchComments(posts[0]);
    console.log('async/await 串行结果:', comments);
  } catch (error) {
    console.error('出错:', error);
  }
}

// ===== 方式四:async/await + 并行优化 =====
async function asyncParallel(userId) {
  try {
    let user = await fetchUser(userId);
    // fetchPosts 和 fetchComments 可以并行(如果知道 posts 是固定的,实际场景可能需要先获取 posts)
    let [posts, _] = await Promise.all([
      fetchPosts(user.id),
      fetchConfig() // 假设有一个独立操作
    ]);
    let comments = await fetchComments(posts[0]);
    console.log('async/await 并行结果:', comments);
  } catch (error) {
    console.error('出错:', error);
  }
}

演进的价值一目了然:从嵌套地狱到链式调用,再到同步风格书写,每一步都在提升代码的可读性和可维护性。

七、本篇小结

这一篇我们系统梳理了 JavaScript 异步编程的完整知识体系:

  • 事件循环:JavaScript 单线程模型的异步基础。宏任务(scriptsetTimeout、I/O)和微任务(Promise.thenqueueMicrotask)构成两级任务队列。每轮循环取一个宏任务,执行完毕后清空所有微任务,然后可能渲染 UI,再进入下一轮。
  • 回调函数:异步的最初形态,简单直观但容易陷入“回调地狱”,错误处理和流程控制困难。
  • Promise:三种不可逆状态(pending → fulfilled/rejected),链式调用(then 返回新 Promise),统一错误处理(catch 捕获前面任一环节的异常),以及编排多个异步操作的静态方法(allraceallSettledany)。
  • 手写 Promise:核心在于状态机管理、回调队列的异步执行、以及 then 的链式返回新 Promise 的机制。
  • async/await:Promise 的语法糖,底层是生成器 + 自动执行器。恢复了 try...catch 的异常处理方式。注意区分串行和并行,避免性能浪费。
  • 生成器 + 自动执行器yield 挂起函数,外部通过 next() 注入值并恢复执行,这是 async/await 的底层原理。

异步编程是 JavaScript 的核心竞争力。从浏览器的事件循环到 Node.js 的事件驱动,从前端的用户交互到后端的并发请求,异步无处不在。把这一篇吃透,你就能在任何异步场景下写出正确、高效、可维护的代码。

系列至此小结

至此,《前端圭臬》JS 篇的核心内容已经覆盖:

  • 简史与面向对象:原型链、prototype vs __proto__class 语法糖、this 绑定。
  • 数据类型:栈与堆、typeof 的坑、instanceof 的原理、类型转换规则、浮点数精度。
  • 对象剖析:属性描述符、访问器属性、对象状态控制、属性遍历、浅拷贝与深拷贝。
  • 执行上下文(上下篇):创建阶段与执行阶段、词法环境与变量环境、作用域链、暂时性死区、闭包、witheval 的陷阱。
  • 异步编程:事件循环、宏任务与微任务、回调 → Promise → async/await 的演进、手写 Promise、生成器自动执行器。

这些内容是 JavaScript 语言的“内核”。掌握它们,你不再是“会用框架写页面”的开发者,而是“理解语言本质、能独立分析和解决问题”的工程师。

专栏后续预告

《前端圭臬》专栏后续将继续深入浏览器原理、性能优化、前端工程化等领域。具体选题和更新节奏,敬请关注专栏公告。

前端,每周更新。

© 版权声明
THE END
喜欢就支持一下吧
点赞8 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容