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

浏览器中的主线程

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

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

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

  • 同步模式: 同步模式下,IO 操作是阻塞的,为了实现并发,通常会结合多进程和多线程
  • 异步模式: 异步模式下,IO 操作是非阻塞的。编程模型通常是单线程的。主线程只负责执行代码,而 IO 异步操作则会在后台(底层)完成。
    而很显然浏览器选择了第二种模式,也就是异步模式,因为我们的主线程是单线程的。也就是说主线程只会执行同步任务,这些异步 IO 操作 实际上是由浏览器底层去完成的。

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

事件循环机制

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

  • 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 的执行流程,我们可以用如下伪代码来表示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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>
);
}

正确答案是:

1
2
3
4
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 流程,如下面伪代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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. 执行渲染(如果需要渲染的话)
    好了,利用所学知识,尝试回答下面的问题: 屏幕上最终绘制的是什么?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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(此时有多次渲染-至少两次)