Issac Lau

How browser works part 2: the event loop

May 4, 2025
16 min read
Loading

这是我们理解浏览器工作原理系列文章的第二篇,本篇内容主要介绍浏览器的事件循环机制。 如果还没有阅读过第一篇,建议先阅读 How browser works part 1: 浏览器架构, 这会帮助你更好的理解本篇内容。

浏览器中的主线程

在浏览器中,我们的应用程序运行在渲染进程中,而渲染进程中的主线程是单线程的。 主线程是我们的应用程序的入口,负责执行我们的代码,并且负责页面的渲染。

那么现在问题来了,因为主线程是单线程的,那么当我们的代码中存在一些耗时操作的时候,比如网络请求(fetch 或者 XMLHttpRequest)、或者 设置了一个定时(setTimeout),亦或者是响应用户的操作(比如 点击),如果主线程需要等待这些耗时操作完成才能继续工作的话,我们的页面 将会变得异常卡顿。

所有这些耗时操作,对于我们的应用程序来说就是输入和输出(IO),在编程范式中,为了实现并发,通常有两种模式:

  • 同步模式: 同步模式下,IO 操作是阻塞的,为了实现并发,通常会结合多进程和多线程
  • 异步模式: 异步模式下,IO 操作是非阻塞的。编程模型通常是单线程的。主线程只负责执行代码,而 IO 异步操作则会在后台(底层)完成。

而很显然浏览器选择了第二种模式,也就是异步模式,因为我们的主线程是单线程的。也就是说主线程只会执行同步任务,这些异步 IO 操作 实际上是由浏览器底层去完成的。

而我们接下来的工作就是,弄清楚浏览器提供了哪些组件与机制,来确保我们的主线程能够高效的处理并发任务,并且不会阻塞页面的响应与渲染。

事件循环机制

浏览器的事件循环机制,通过如下组件的协同工作,来确保我们的主线程能够高效的处理并发任务,并且不会阻塞页面的响应与渲染。

Event Loop
  • Call Stack: 调用栈,用于存储我们的同步任务。这是正在执行的函数的存储位置。当我们调用一个函数时, 这个函数会被压入栈中,当函数执行完毕后,这个函数会被从栈中弹出。
  • Web APIs: 浏览器提供的 API,用于处理异步任务(比如 setTimeout、fetch、XMLHttpRequest 等)。这些 API 会在后台完成,不会阻塞主线程。
  • Task Queue: 任务队列,又叫回调队列(Callback Queue),宏任务队列,用于存储我们的异步任务(宏任务)。当异步任务完成时 (比如 setTimeout 设置的定时器到时间后,会产生一个宏任务;),这个任务会被推入到任务队列中。
  • Microtask Queue: 微任务队列,用于存储我们的微任务。微任务的执行时机比宏任务要早,通常用于处理一些需要立即执行(渲染之前,但又不需要同步执行)的任务。
  • Event Loop: 事件循环,这是整个事件循环机制的核心。它负责监视调用栈和任务队列(包括宏任务队列和微任务队列),当调用栈为空时,事件循环会从宏任务队列中取出一个宏任务并执行。

常见的宏任务

  • script 执行 (script 标签中的代码都是相当于一个宏任务)
  • MessageChannel
  • setTimeout callback
  • setInterval callback
  • 用户交互事件(如点击、滚动、键盘输入等)的 callback
  • 网络请求的 callback
  • 文件读写的 callback

常见的微任务

  • Promise callback
  • MutationObserver callback

具体 Event Loop 的执行流程,我们可以用如下伪代码来表示:

function eventLoop() {
  const macroTaskQueue = []; // 宏任务队列
  const microTaskQueue = []; // 微任务队列

  // 清空微任务队列
  function exhaustMicroTaskQueue() {
    while (microTaskQueue.length !== 0) {
      const microTask = microTaskQueue.shift(); // 从微任务队列中取出一个微任务 FIFO
      // 将任务对应的回调函数压入调用栈中执行
      runToCompletion(microTask);
    }
  }

  // 事件循环 会一直执行
  while (true) {
    /**
     * 从宏任务队列中取出一个宏任务
     * 有一个执行的前提:就是Call Stack为空,否则不会取宏任务
     * */
    const macroTaskCallbacks = macroTaskQueue.shift();
    // 一个宏任务可能包含多个回调: 比如一个元素的 click 事件,可能包含多个回调(事件冒泡)
    for (let i = 0; i < macroTaskCallbacks.length; ++i) {
      // 执行宏任务的回调
      const macroTaskCallback = macroTaskCallbacks[i];
      // 将任务对应的回调函数压入调用栈中执行
      runToCompletion(macroTaskCallback);
      // 执行完宏任务的每个回调后,清空当前的微任务队列
      exhaustMicroTaskQueue();
    }
  }
}

如下代码,当我们点击 inner 元素时,控制台会依次输出什么呢?

App.tsx
function App() { useEffect(() => { const outer = document.getElementById("outer"); const inner = document.getElementById("inner"); const handleOuterClick = () => { console.log("outer clicked"); Promise.resolve().then(() => { console.log("outer promise"); }); }; const handleInnerClick = () => { console.log("inner clicked"); Promise.resolve().then(() => { console.log("inner promise"); }); }; if (outer && inner) { outer.addEventListener("click", handleOuterClick); inner.addEventListener("click", handleInnerClick); } return () => { if (outer && inner) { outer.removeEventListener("click", handleOuterClick); inner.removeEventListener("click", handleInnerClick); } }; }, []); return ( <div id="outer"> <div id="inner">click me</div> </div> ); }

正确答案是:

inner clicked
inner promise
outer clicked
outer promise

为什么呢?

  • 我们的代码里,有两个事件监听器,一个监听 outer 元素的 click 事件,一个监听 inner 元素的 click 事件。
  • 当我们调用 addEventListener 时,其实是将回调函数注册到 Web APIs 中,当事件触发时,回调函数对应的 事件 会被推入到宏任务队列中。
  • 当我们点击 inner 元素时,inner 元素的 click 事件会被触发。会将这个点击事件 (事件分发的入口函数-也就是浏览器内部的事件调度机制会把“处理这个点击事件”的任务放入宏任务队列)放入 宏任务队列(Callback Queue) 中(只有一个宏任务)。
  • 事件循环会从宏任务队列中取出一个宏任务,并将这个宏任务对应的回调函数(这里有两个回调函数)一一压入到调用栈中执行。
  • 当执行每一个回调函数时,会将过程中产生的微任务(比如 Promise.resolve().then())压入到微任务队列中。
  • 当执行完宏任务的回调函数后,会执行微任务队列中的微任务。

这里有几点需要注意的:

  1. 事件循环机制是基于事件的, 每个事件(比如点击事件)对应一个宏任务
  2. 事件的所有监听函数属于同一个事件的处理过程,浏览器会在执行这个宏任务时,依次调用所有注册的监听函数。
  3. 也就是说,事件的分发和监听函数的调用都发生在同一个宏任务中。

渲染

以上事件循环机制,我们并没有涉及到渲染,那么渲染是怎么发生的呢?

渲染过程

一次渲染的过程大致可以分为以下几个步骤:

  1. DOM 树的构建
  2. CSSOM 的构建
  3. 将 DOM 树和 CSSOM 树合并生成渲染树
  4. 根据渲染树生成布局树
  5. 根据布局树生成绘制记录: 绘制到不同的层上
  6. 合成并显示: 将绘制记录提交到合成线程, 合成线程将绘制记录提交到 GPU, GPU 将绘制记录绘制到屏幕上

这其中的多数工作(1-5)都发生在主线程上。因此,如果主线程被阻塞,那么渲染过程也会被阻塞。而 event loop 机制的存在,正是为了尽可能得 避免主线程被阻塞,从而保证渲染过程的流畅。

渲染的时机

渲染也是 event loop 的一部分。在 event loop 的每次循环(一次循环也叫一个 tick)结束前,都会检查是否需要进行渲染。而渲染通常需要满足以下条件:

  • 是时候渲染了: 这通常和屏幕的刷新率有关系,比如屏幕的刷新率是 60Hz,那么每 16.67ms 就会有一次刷新,每次 event loop 结束前 都会检查是否是时候渲染一个新帧了。

结合渲染之后的 event loop 流程

结合渲染之后的 event loop 流程,如下面伪代码所示:

function eventLoop() {
  const macroTaskQueue = []; // 宏任务队列
  const microTaskQueue = []; // 微任务队列
  const rAFQueue = []; // requestAnimationFrame 队列

  // 清空微任务队列
  function exhaustMicroTaskQueue() {
    while (microTaskQueue.length !== 0) {
      // 从微任务队列中取出一个微任务 FIFO
      const microTask = microTaskQueue.shift();
      // 将任务对应的回调函数压入调用栈中执行
      runToCompletion(microTask);
    }
  }

  // 事件循环 会一直执行
  while (true) {
    // 从宏任务队列中取出一个宏任务
    const macroTaskCallbacks = macroTaskQueue.shift();
    // 一个宏任务可能包含多个回调: 比如一个元素的 click 事件,可能包含多个回调(事件冒泡)
    for (let i = 0; i < macroTaskCallbacks.length; ++i) {
      const macroTaskCallback = macroTaskCallbacks[i];
      // 将任务对应的回调函数压入调用栈中执行
      runToCompletion(macroTaskCallback);
      // 执行完宏任务的每个回调后,清空当前的微任务队列
      exhaustMicroTaskQueue();
    }

    /**
     * 检查是否是时候渲染一个新帧了
     * 受屏幕刷新率影响,比如屏幕的刷新率是 60Hz,那么每 16.67ms 就会有一次刷新
     * */
    if (isItTimeForNextFrameRender()) {
      // 从 requestAnimationFrame 队列中取出一个 requestAnimationFrame 任务 (可能存在多个,一一执行)
      for (let i = 0; i < rAFQueue.length; ++i) {
        const rAFTask = rAFQueue[i];
        // 将任务对应的回调函数压入调用栈中执行
        runToCompletion(rAFTask);
        // 执行完 requestAnimationFrame 任务后,清空当前的微任务队列
        exhaustMicroTaskQueue();
      }

      // 执行渲染,如果有变化需要渲染的话
      render();
    }
  }
}

** microtask 的执行时机 ** 只要 call stack 为空,就会执行微任务队列中的微任务,将微任务队列清空。所以在上述伪代码中,你会看到:

  1. 在执行完宏任务的每个回调后(一个宏任务可能会对应多个回调),会清空当前微任务队列
  2. 在执行完每个 requestAnimationFrame 回调任务后(因为当前 callstack 为空),会清空当前微任务队列

事件循环的过程概括

每次事件循环的过程大致如下:

  1. 若当前调用栈为空,从宏任务队列中取出一个宏任务,将其对应的回调函数一一压入调用栈中去执行
  2. 在当前宏任务对应的每一个回调函数执行完,清空当前回调函数产生的微任务(将微任务队列中的微任务一一压入调用用栈中执行)
  3. 检查是否是时候渲染一个新帧了(受屏幕刷新率影响),如果需要渲染,则将 requestAnimationFrame 队列中的任务一一压入调用栈中执行
  4. 在执行每一个 requestAnimationFrame 任务后,清空当前回调函数产生的微任务(将微任务队列中的微任务一一压入调用用栈中执行)
  5. 执行渲染(如果需要渲染的话)

好了,利用所学知识,尝试回答下面的问题: 屏幕上最终绘制的是什么?

setTimeout(() => {
  console.log("timeout 1");
  document.body.innerHTML = "A";
});

requestAnimationFrame(() => {
  console.log("requestAnimationFrame");
  document.body.innerHTML = "B";
});

setTimeout(() => {
  console.log("timeout 2");
  document.body.innerHTML = "C";
});

console.log("script");
document.body.innerHTML = "D";

正确答案是: 绝大多数情况下是 B,但是也有可能输出 C。

为什么呢?

  • 因为 requestAnimationFrame 的 callback 会在下一次渲染之前执行
  • 而浏览器渲染是受屏幕刷新率影响的,多数情况下 D、A、C 这些都已经执行了,但是还没有到下一次渲染的时间,因此最终会渲染 B (而且只有这一次渲染)
  • 但是也可能是 C,因为有可能恰好当时时间到了渲染下一帧的时候了,B 就会先出现,然后才是 C(此时有多次渲染-至少两次)

参考资源

评论

Loading