在说 Event Loop 这个机制之前,我们先来使用 Chrome 浏览器的渲染进程引入这个问题。首先我们来了解一下Chrome 的多进程架构。
为什么必须要使用多进程:因为在浏览器中,如果只存在一个进程,那么渲染、事件执行、等等任务只能在一个进程下进行。而一个进程可以拆分成多个线程执行,但是线程之下就不能再继续拆解了。如果有多个进程,可以将其中的一个任务放在一个进程下执行,进程中可以将一个任务拆解成多个线程。
Chrome 的多进程架构主要包括一下四个进程:
- Browser进程(负责地址栏、书签栏、前进后退、网络请求、文件访问等)
- Renderer进程(负责一个Tab内所有和网页渲染有关的所有事情,是最核心的进程)
- GPU进程(负责GPU相关的任务)
- Plugin进程(负责Chrome插件相关的任务)
在上述进程中,和 Event Loop 密切相关的,就是 Renderer 进程,也就是我们的渲染进程。
首先来介绍一下渲染进程,当你在浏览器上打开一个 tab 的时候,渲染进程几乎要完成页面展现的所有任务。这些任务由以下几个线程来完成, 主线程、合成线程、光栅线程、和 Worker 线程。我们下面来看看,在渲染过程中主线程主要做了什么。
1. 解析
浏览器会根据当前的 HTML 结构,构建 DOM 树,并且会同时加载次级资源,比如页面上的 CSS 样式表、图片、和 js 脚本。
在这个过程中,如果遇到了 js 脚本的加载,浏览器会先停止解析 DOM 树而先执行 js 脚本。这是因为 js 中有可能会改变 DOM 树的结构。当然,如果当前的 js 脚本中的执行确定不会对 DOM 树有所改变,浏览器也提供了可以不阻塞的加载方式,可以使用 defer 或者 async 异步加载,也可以使用
<link rel="preload">
进行预加载
2. 样式计算
解析完 DOM 结构之后,主线程会根据 CSS 选择器来进行样式计算,并将样式添加到具体的 DOM 节点上,来呈现出 HTML 应有的样子。就算没有 CSS 样式表,浏览器也会提供一个默认样式。
3. 页面布局
主线程在这个过程中会计算所有的 DOM 节点来计算样式,并且通过元素的边框大小和横纵坐标来进行排版。布局树可能会比 DOM 树的体积稍小一点,但是它只包含当前页面上展现出来的布局。比如说一个元素如果 display: none, 那么它不会在最终的布局树上,但是 DOM 树上会展现它。而 p::before{content:"Hi!"} 这样的代码,会在最终的布局树上呈现,却不会出现在 DOM 树上。
4. 渲染
知道了样式、结构和布局,现在就可以渲染页面了。有了上面这些条件,你还需要知道以怎样的顺序去渲染这些元素。在绘制这一步,主线程会根据布局树来生成一个绘制记录,这个记录的具体过程就是类似于先渲染背景、然后是文字、最后是内容。z-index 这个属性就在此过程中显示的比较重要。
在这整个的过程中,上一步的数据变动都会影响到下一步的数据,比如布局的数据如果发生了变化,那么绘制过程也会被影响,因此每一步的变动都是有代价的。
上面这些渲染的步骤都是运行在主线程的,而 js 是一个单线程语言。也就是说当你在主线程执行 js 的时候,上述过程会被中断。但是有这样大段的卡顿,用户体验一定会非常不好。
所以将 js 执行切成小段,并且每一帧的渲染都使用 requestAnimationFrame() 来执行就可以避免这个问题。
那么在这其中,这个 requestAnimationFrame 作为一个异步任务,就是被 Event Loop 来进行调度和执行的,下面,我们就来谈谈 Event Loop 在 js 中的具体原理和相关知识。
首先,我们来看一下,js 引擎是怎么来执行 js 代码的。
在 js 执行的过程中,执行引擎会提供这样的基础:
调用栈:即正常的 js 语句执行的环境,调用栈中的每一帧都是一个函数,遵循先进后出的原则
heap:js 执行引擎分配的内存
queue: 消息队列,所有的任务消息都会被放入到这个队列中
正常的调用栈是怎么执行的呢
const c = () => {
console.log(3);
};
const b = () => {
c();
console.log(2);
}
const a = () => {
b();
console.log(1)
}
a();
// 输出 3,2,1
在这个过程中,首先 a 被调用时,被当做一个帧,压入栈中,a 调用 b 的时候,b 也被当做一个帧放在 a 上面, 最后被放在最上面的是 c, 所以最后执行的顺序就是 c、b、a。
上面就是正常的调用栈,js 执行的都是同步的任务,并且是单线程执行的。单线程是 js 的一个历史遗留问题。js 当年被发明出来的时候市场上还没有很多的多进程电脑,并且使用 js 的人也很少,所以作者就没有考虑这个问题。而随着时代的发展,越来越多的应用使用 js 来当作开发语言,因此,我们必须要解决 js 单线程对代码的限制。解决这个限制的第一种方法就是使用异步任务,异步任务就是在不占用主线程的情况下去执行一个任务,实现这个异步任务机制的就是 Event Loop。
Event Loop 是 JavaScript 内的一个并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。本文就通过与浏览器相关知识相结合的方式给大家展示一下 Event Loop 的原理。
首先我们先大概讲一下 Event Loop 的基本概念和基本的运行方式。
在 js 引擎中会有很多的 user agent 去执行 js 代码,每一个 user agent 都是由执行上下文、调用栈、主线程、由 Worker 创建的其他线程、一个任务队列和一个微任务队列组成的。每一个代理都是由一个 Event loop 来进行调度的。这个 Event loop 会收集事件消息加入到队列当中,随后执行任务队列中的任务。web 应用都是运行在一个线程上,并且共享一个 Event loop。这就是主线程,除了去运行 js 代码,它还能够运行和派发用户事件并进行页面上的渲染。
Event loop 包含以下三种
-
Window event loop: 全局作用域下的 Event loop, 控制当前域下的事件循环
-
Worker event loop:运行在 Worker 线程中的事件循环,包含标准 Worker、共享 Worker 等等
-
Worklet event loop
Event Loop 的功能是,在页面的加载和浏览过程中,将各种各样的异步 "任务", 比如 js 脚本的执行、用户和页面的交互等等,加入到一个消息队列中。JavaScript 在无任务执行时,Event Loop 会进入一个 sleep 的状态,并且用一个无尽的循环在等待任务,询问当前是否有事件任务进入消息队列,如果有,则将当前的任务的回调函数增加到调用栈中,并执行当前的任务。而且在当前任务执行完之前,不会再执行下一个任务。如果当前的事件队列执行完毕,那么事件循环就会进入一个 sleep 的状态。
整体流程如下:
任务被设置 -> 执行相应的任务 -> 等待任务被添加进来
如果任务被添加进来的时候,当前的引擎还在执行任务,那么这个任务就会被放入消息队列中,这个队列通常被叫作宏任务队列。但是其实在源码中是没有宏任务队列这个概念的。这个队列中常包含的事件类型有脚本执行、用户界面操作和 setTimeout等, 此队列是以栈的形式去保存的,遵循先进后出的原则。
在执行完宏任务队列中的其中一个之后,当前调用栈为空,会立刻检查并执行微任务队列中所有的任务
那么什么是微任务队列呢。微任务队列大多是由 Promise 等异步任务构成的。queueMicrotask 这个函数可以直接执行微任务。所有的微任务必须在当前调用栈为空,并且一次性将所有的队列中的任务执行完清空。这是为了保证在两个微任务的执行过程中执行环境不会发生变化。
在 Chrome 中这个过程可以表示为:
出队并且执行宏任务队列中的任务
执行所有的微任务
执行渲染操作
在微任务执行中间不会有任何的 UI 操作或者网络操作
(此处仅限于 Chrome 的事件循环机制)
整个 Event Loop 的过程中,在执行完一个任务之前,不会执行下一个任务,更不会去执行渲染。只有在当前的任务全部执行完成之后,DOM 节点才会被改变。因此如果一个 JS 任务执行时间过长,它不会去执行其他的任务,而是会继续执行当前的任务,它会在页面上方弹出一个提示框,里面写着“页面响应时间过长”。暗示你通过关闭页面来杀死当前的这个任务。这经常在复杂的计算和引起无限循环的任务里。添加微任务的时候也需要小心,因为 js 在执行完当前微任务队列的时候,不会去执行下一个消息队列的任务。因此,如果微任务被添加了无限递归,就会一直执行下去,直到内存溢出。
因此一个良好的习惯是缩短单个消息的处理时间,并在可能的情况下将一个消息裁剪成多个消息,就像我们在上面说的用 requestAnimationFrame 来裁剪一个大段的渲染任务。而且裁剪后的消息并不会比之前的消息队列执行得慢。
所以说,所有的执行任务就被分为三类,正常的调用栈,微任务,宏任务。而且微任务和宏任务在被执行的时候也是被放入调用栈中执行的。
那么,为什么要有宏任务队列和微任务队列的区别呢?
其实宏任务这个概念就是正常的消息队列的概念。比如说我们的 用户交互的回调函数、setTimeout 的执行、都是宏任务中的概念。而微任务队列则是区别于消息队列的异步任务的存在,主要存放的是比如 ajax 请求、Promise 等等。
我们通常说的宏任务就是 Event Loop 执行的其中的一部分异步任务。但是,我们对于异步任务的执行也是需要区分优先级的,有一些异步任务我们需要先于其他的异步任务执行,这样就有了微任务的诞生。在当前的主线程的栈为空并且 user agent 没有把控制权交还给 Event Loop 的时候,如果当前微任务队列中有任务,那么就会去将微任务的队列处理完毕,然后再将控制权交给事件循环。事件循环会去检查当前的迭代中是否有宏任务,执行完一个宏任务之后再重复上述操作。因此,微任务总是先于宏任务执行的,相当于一个插队操作。
比如下面两段代码就可以很直观地表明这个过程:
setTimeout(() => console.log(2), 0);
setTimeout(() => new Promise((resolve, reject) => resolve()).then(() => console.log(3)), 0);
new Promise((resolve, reject) => resolve()).then(() => console.log(1));
// 输出 1,2,3
那么,在这段代码中,执行过程是怎样的呢。首先,遇到第一个 setTimeout 之后,将它间隔 0 ms 之后放入任务队列中。之后遇到第二个 setTimeout, 同样间隔 0ms 之后放入任务队列中,最后遇到一个 Promise, 在这个 Promise 中有一个回调,将这个回调放入微任务队列中。此时,执行栈为空,但是微任务有一个任务,那么就执行当前的微任务,输出 1。此时执行栈为空,微任务队列也为空,将执行权交给 Event Loop, Event Loop 检查到当前的任务队列中有任务,执行第一个 setTimeout, 输出 2。执行完之后再执行第二个 setTimeout, 执行时在微任务中加入一个 Promise 的 then。当前的执行栈为空,执行 Promise 内部的命令,输出 3。
那么,我们现在把两个 setTimeout 的顺序颠倒之后看看。
setTimeout(() => new Promise((resolve, reject) => resolve()).then(() => console.log(3)), 0);
setTimeout(() => console.log(2), 0);
new Promise((resolve, reject) => resolve()).then(() => console.log(1));
// 输出 1,3, 2
将两个 timeout 颠倒顺序之后,我们会发现第一个 setTimeout 中的 Promise 会先于第二个 setTimeout 执行。我们来捋一下它的执行过程。首先将两个 setTimeout 依照顺序放入任务队列中,之后将 Promise 的回调放入微任务队列中。在将执行权交给 Event Loop 之前,执行微任务队列,输出 1。之后执行任务队列,将第一个 setTimeout 的回调函数放入调入栈中执行,遇到一个 Promise 的回调函数,设置为微任务,放入微任务队列中。执行完之后,当前执行栈为空。检查微任务队列,发现有刚才放进去的微任务,执行微任务,输出 3. 将执行权交给 Event Loop,执行第二个 setTimeout。
小知识:setTimeout 第二个参数是指被放入任务队列的延迟时间,并不是具体的执行时间,setTimeout 在 js 中执行时至少有4ms的延迟。
通过上面的文章,我们对于 Event Loop 有了一个初步的了解。一般来说,任务队列中,我们存放渲染任务、用户的操作、或者 timeout 等。在微任务中,我们一般存放 Promise、Mutation Observer 等。那么为什么要这样区分呢?
首先我们要根据上面的描述想一想,什么时候需要使用微任务。
我们使用微任务的大部分场景,是要晚于正常的 js 代码的调用栈的执行,但是早于用户的操作或者正常的延时执行,而且我们使用微任务是想要维护数据或者动作的一个顺序。那总接下来就是,确保任务顺序的一致性,即便当结果或数据是同步可用的,也要同时减少操作中用户可感知到的延迟而带来的风险。
那么我们经常使用的 Promise 就是能够达到执行完一个不确定的任务之后,比如在远端获取可用的数据之后,继续执行下面的回调函数中的操作。而获取数据和执行操作是有先后顺序,并且对接下来的用户交互有影响的行为。再比如说,Mutation Observer是监听所有的 dom 树的改变,也就是一个批量任务的改变之后,再触发回调函数,而DOM 数的改变很明显是对渲染和用户操作有着很大的影响的,因此也是微任务中的一员。
而正常的消息队列,只是去处理一个动作的回调函数,并且任务之间不需要有先后顺序的保证或者影响。只是一个条件被触发即可,就是普通的异步函数,不需要有优先级的保证。