Back to topics

This article does not have an English body yet. Showing the Chinese version below.

把事件循环彻底讲清楚:从进程线程到一道经典面试题

如果你写过 JavaScript,一定听过"事件循环"这个词。它几乎是前端面试的必考题,但很多人的理解停留在"宏任务、微任务、执行顺序"这几个关键词上,背完答案就忘。

这篇文章换一个角度:从"浏览器为什么要这样设计"开始,把事件循环的来龙去脉彻底讲透。


先分清进程和线程

这是两个经常被混用的词:

  • 进程(Process):操作系统分配资源的单位。打开 Chrome 浏览器,Chrome 就是一个进程;打开 VS Code,VS Code 是另一个进程。进程之间相互隔离,一个崩了不影响另一个。
  • 线程(Thread):进程里实际干活的单位。一个进程至少有一个线程,也可以有多个线程共享同一块内存。

打个比方:进程像一栋办公楼,线程是里面的工位。一栋楼可以有很多工位,但所有工位共享这栋楼的水电网。


浏览器开了哪些进程?

Chrome 这类现代浏览器,不是只有一个进程。主要分三种:

  1. 浏览器进程:管界面(地址栏、前进后退、书签栏),也管子进程的创建和销毁。它自己不渲染页面。
  2. 网络进程:专门负责网络请求。加载 HTML、CSS、JS、图片都是它在干。
  3. 渲染进程:这才是前端的主战场。每个标签页默认开一个独立的渲染进程,这样 A 标签页崩了不会拖 B 标签页下水。

渲染进程启动后,会开一个渲染主线程,负责下面这些事情:

  • 解析 HTML、解析 CSS
  • 计算样式、布局
  • 处理图层
  • 每秒把页面往屏幕上画 60 次
  • 执行全局 JS 代码
  • 执行事件处理函数(点击、滚动、键盘)
  • 执行定时器回调
  • ……等等

问题来了:这么多事情,全交给一条主线程,怎么调度才能不打架?


事件循环:一条永不停歇的流水线

浏览器的答案是:事件循环(Event Loop)。可以理解成一条不停转动的流水线:

  1. 渲染主线程进入一个无限循环
  2. 每次循环,先看一眼"消息队列"里有没有任务
  3. 有就取一个出来执行,执行完进入下一次循环
  4. 没有就等着,等有新任务加入队列

关键的是第 2 步:任务不一定是主线程自己生成的。浏览器进程可以往队列里塞任务(比如用户点了按钮),网络进程可以往队列里塞任务(比如请求回来了),定时器线程也可以往队列里塞任务(计时到了)。

所有线程都可以往消息队列里丢任务,主线程只负责"一个一个取出来执行"。这就是"事件驱动"的由来。


举个例子:点按钮发生了什么?

btn.onClick = () => {
  h1.innerText = 'new text';
  sleep(3000); // 一个同步阻塞函数,要跑 3 秒
};

完整过程:

  1. 浏览器有个"交互线程",专门监听用户的点击、滚动等操作
  2. 用户点了按钮 → 交互线程把回调函数包装成"任务对象",丢进消息队列
  3. 主线程当前手头的事做完,从队列里取出这个任务
  4. 执行到 sleep(3000) 时,主线程在这卡 3 秒——这期间页面滚不动、点不动,因为主线程被同步代码占了
  5. sleep 执行完、整个回调函数跑完,主线程才能继续渲染页面

这也是为什么"同步阻塞很可怕"——它会堵住整条流水线,用户的操作全卡在队列里排队。


任务的优先级:不只一条队列

如果所有任务都放一条队列,那就会出现"后面排着十万个任务,新来的用户点击等半天才响应"的情况。

所以浏览器实际上不只一条消息队列。每种任务类型有自己专属的队列,优先级不同。Chrome 目前的实现至少包含:

  • 微队列(Microtask Queue):优先级最高。Promise.then、MutationObserver 的回调在这里
  • 交互队列:用户操作产生的事件处理任务,优先级高——浏览器知道用户等不起
  • 延迟队列:定时器回调(setTimeout、setInterval),优先级中——多等几毫秒用户一般感觉不到

至于"宏队列"这个词——W3C 标准里已经不再使用这种粗粒度的分类。只记住一点就行:微队列的优先级高于其他所有队列。每次主线程执行完一个"普通任务",都会先把微队列里的任务全部清空,才去看下一个队列。


requestAnimationFrame 插在哪?

rAF 有自己独立的队列。简单的理解:在微任务执行完毕后、渲染更新开始前执行。它是专门为"在下一次重绘前更新动画"设计的,所以如果你的动画逻辑放在 rAF 里,浏览器会在绘制前统一处理,避免不必要的重复绘制。


一句话总结

  • 单线程是"为什么需要异步"的原因——一条线程忙不过来
  • 事件循环是"怎么实现异步"的方案——消息队列 + 循环调度

经典面试题:你能答对吗?

console.log('A (同步)');

setTimeout(() => {
  console.log('B (宏任务1)');
  Promise.resolve().then(() => {
    console.log('C (微任务1 - 来自宏任务1)');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('D (微任务1)');
  setTimeout(() => {
    console.log('E (宏任务2)');
    Promise.resolve().then(() => {
      console.log('F (微任务2 - 来自宏任务2)');
    });
  }, 0);
});

(async () => {
  console.log('G (async 同步部分)');
  await Promise.resolve();
  console.log('H (async await 之后 - 微任务)');
  setTimeout(() => {
    console.log('I (宏任务3)');
    Promise.resolve().then(() => {
      console.log('J (微任务3 - 来自宏任务3)');
    });
  }, 0);
})();

Promise.resolve().then(() => {
  console.log('K (微任务2)');
});

答案:A → G → D → H → K → B → C → E → F → I → J

逐帧拆解:

  1. 同步代码从上往下跑:先打 A,再打 G(async 函数里 await 之前都是同步的)
  2. 同步代码跑完,微任务队列清空:DH(await 后面的部分本质是 Promise.then)、K 都在微任务队列里,按注册顺序执行
  3. 微任务清完,取下一个宏任务。此时延迟队列里有 B(第一个 setTimeout)和 E(在 D 里注册的 setTimeout)、I(在 H 里注册的 setTimeout)。先入先出,执行 B → 打 B
  4. B 执行中往微任务里塞了 C → 清微任务 → 打 C
  5. 下一个宏任务 E → 打 E,塞 F 到微任务 → 清微任务 → 打 F
  6. 下一个宏任务 I → 打 I,塞 J 到微任务 → 清微任务 → 打 J

如果你理解了这个顺序,事件循环的调度逻辑你就真的懂了。