这是我们理解浏览器工作原理系列文章的第二篇,本篇内容主要介绍浏览器的事件循环机制。 如果还没有阅读过第一篇,建议先阅读 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 的执行流程,我们可以用如下伪代码来表示:
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())压入到微任务队列中。
- 当执行完宏任务的回调函数后,会执行微任务队列中的微任务。
这里有几点需要注意的:
- 事件循环机制是基于事件的, 每个事件(比如点击事件)对应一个宏任务
- 事件的所有监听函数属于同一个事件的处理过程,浏览器会在执行这个宏任务时,依次调用所有注册的监听函数。
- 也就是说,事件的分发和监听函数的调用都发生在同一个宏任务中。
渲染
以上事件循环机制,我们并没有涉及到渲染,那么渲染是怎么发生的呢?
渲染过程
一次渲染的过程大致可以分为以下几个步骤:
- DOM 树的构建
- CSSOM 的构建
- 将 DOM 树和 CSSOM 树合并生成渲染树
- 根据渲染树生成布局树
- 根据布局树生成绘制记录: 绘制到不同的层上
- 合成并显示: 将绘制记录提交到合成线程, 合成线程将绘制记录提交到 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 为空,就会执行微任务队列中的微任务,将微任务队列清空。所以在上述伪代码中,你会看到:
- 在执行完宏任务的每个回调后(一个宏任务可能会对应多个回调),会清空当前微任务队列
- 在执行完每个 requestAnimationFrame 回调任务后(因为当前 callstack 为空),会清空当前微任务队列
事件循环的过程概括
每次事件循环的过程大致如下:
- 若当前调用栈为空,从宏任务队列中取出一个宏任务,将其对应的回调函数一一压入调用栈中去执行
- 在当前宏任务对应的每一个回调函数执行完,清空当前回调函数产生的微任务(将微任务队列中的微任务一一压入调用用栈中执行)
- 检查是否是时候渲染一个新帧了(受屏幕刷新率影响),如果需要渲染,则将 requestAnimationFrame 队列中的任务一一压入调用栈中执行
- 在执行每一个 requestAnimationFrame 任务后,清空当前回调函数产生的微任务(将微任务队列中的微任务一一压入调用用栈中执行)
- 执行渲染(如果需要渲染的话)
好了,利用所学知识,尝试回答下面的问题: 屏幕上最终绘制的是什么?
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(此时有多次渲染-至少两次)