前言
在分析浏览器的事件循环之前,我们先来了解下浏览器是怎么渲染一个页面的。了解一个静态页面是怎么呈现在浏览器上的。在来看一个页面是怎么通过消息队列和事件循环机制“活起来的”。
渲染引擎
浏览器最核心的部分应该就是“渲染引擎”和“JS引擎”。浏览器的渲染引擎我们一般也可以称呼为“浏览器内核”。
主要是负责HTMl、CSS的解析,页面布局、渲染与复合层合成的。浏览器所使用的内核不同所渲染的效果可能也会不同,这也是为什么我们写代码的时候要去兼容的样式原因,因为每个浏览器所使用的内核不同。JS引擎顾名思义就是解析js代码的。
浏览器内核简介
浏览器 |
内核 |
IE |
Trident |
Chrome |
Chromium/Blink |
Safari |
Webkit |
Firefox |
Gecko |
本小节主要是基于Webkit内核来熟悉下渲染的主要流程。chrome浏览器的内核早前也是fork webkit代码的,基于webkit上的二次开发把webkit梳理的更有条理可读性更高,效率提升比较明显。早前由于webkit2和chromium在沙箱设计上的冲突,google和opera联手自研和发布了Blink引擎,逐步脱离了webkit的影响。
浏览器渲染的主要路径
一个简略版渲染机制一般分为以下几个步骤:
- 解析 HTML 并构建 DOM 树。
- 解析 CSS 构建 CSSOM 树。
- 将 DOM 与 CSSOM 合并成一个渲染树。
- 遍历渲染树开始布局,计算每个节点的位置大小信息。
- 调用 GPU 绘制,合成图层,显示在屏幕上。
我们经常会看到一些关于渲染阻塞的面试题,我先上结论具体的为什么可以自行上网查询相关资源学习不在本文的讨论范围。
- css不会阻塞dom的解析,但会阻塞dom的渲染。
- js会阻塞dom的解析
以上是非常简略的渲染流程,这背后其实是有大量的细节,等我悟透了再来写篇详细的渲染流程吧。
事件循环
上文是讲了一个静态页面是怎么呈现在浏览器上的,那现在一个静态页面其实远远满足不了现在的需求,没办法跟用户进行交互。每个有交互的浏览器页面都是通过消息队列和事件循环机制才“活起来的”。
消息队列
消息队列其实是一种数据格式,它符合先进先出的特点,添加任务是添加到队尾,取出任务是从队列的头部去取。浏览器维持着这个队列,每次事件循环都是从这个队列获取任务并执行。消息队列中包含很多种类型:如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、websocket、javascript定时器等。除此之外,消息队列中还包含了很多与页面相关的事件,如javascritp执行、解析dom、样式计算、布局计算、css动画等。
消息队列它只是一个队列,并不知道什么时候任务出队,什么时候入队。其实浏览器有一个额外的线程负责这个。叫webapis,它们通常会处理dom事件、http请求、settimeout的回调函数等。
event loop
上面五部分每个部分拆分开细说都会是很长的篇幅,这次细说下event loop在一次循环中发生了什么。
首先要说明下,event loop是跟js的运行环境相关的机制,与js引擎本身无关。浏览器跟node.js这是两种运行环境,它们实现的方式也是不同,这次主要是讲浏览器的event loop。
看看HTML官方是怎么规范定义evet loop的:
为了协调事件、用户交互、脚本、渲染、网络等等,用户代理(浏览器)必须使用本节所述的事件循环。每个代理都有一个相关的事件循环,这对该代理是唯一的。
一个事件循环有一个或多个任务队列。一个任务队列是一组任务的集合。
任务队列是集合,而不是队列,因为事件循环处理模型的第一步是从选择的队列中抓取第一个可运行的任务,而不是对第一个任务进行去排队。这里分不同的任务队列是为了区分优先级,比如键盘事件和滚动事件这类用户交互的任务优先级是高于其他任务的。
微任务队列不是一个任务队列。
每个事件循环都有一个微任务队列,它是一个微任务队列,最初是空的。微任务是指通过微任务队列算法创建的任务的一种口语化方式。
每个事件循环都有一个performing a microtask checkpoint的变量,该变量最初为false。它被用来防止执行微任务检查点算法的重入调用。
一个事件循环只要存在,就必须不断地通过以下步骤运行:
- 让taskQueue成为事件循环的一个任务队列,以实现定义的方式选择,约束条件是所选的任务队列必须包含至少一个可运行的任务。如果没有这样的任务队列,那么就直接跳到微任务步骤。(这一步不会选择微任务队列,因为它不是一个任务队列,但是可能会选到与微任务任务源相关联的任务队列)
- 让oldestTask成为taskQueue中第一个可运行的任务,并从taskQueue中移除它。
- 将事件循环当前运行的任务设置为oldestTask。
- 让taskStartTime成为当前的高分辨率时间。
- 执行oldestTask的步骤。
- 将事件循环的当前运行任务设为null。
- 执行微任务检查:
- 检查performing a microtask checkpoint值,为true的话return
- 将performing a microtask checkpoint 设置为true
- 只要当前微任务队列不为空
- 把oldestMicrotask设置为微任务队列
- 将事件循环当前运行的任务设置为oldestMicrotask。
- 运行oldestMicrotask。(如果在微任务的执行中又加入了新的微任务,又会重复这个过程,直到所有的微任务执行完)
- 将事件循环的当前运行任务设为null。
- 将performing a microtask checkpoint 设置为false。
- 把hasARenderingOpportunity设置为false。(这里应该跟渲染有关系)。
- 把now成为当前的高分辨率时间。(这里的now应该跟requestAnimationFrame有关系)
- 执行一些步骤报告任务的持续时间。(看不懂在干嘛)
- 更新渲染阶段:
- 处理document
- 这里有一个rendering opportunities(渲染机会)的概念:
- 如果浏览器目前能够向用户展示浏览上下文的内容,那么浏览上下文就有渲染机会,同时考虑到硬件刷新率的限制和用户代理出于性能原因的节流,但考虑到即使内容在视口之外,也可以展示。
- 浏览上下文的渲染机会是根据硬件限制(如显示刷新率)和其他因素(如页面性能或页面是否在后台)决定的。渲染机会通常以固定的时间间隔出现。
- 本规范并没有强制规定选择渲染机会的任何特定模式。但是,举例来说,如果浏览器试图达到60Hz的刷新率,那么渲染机会最多每60秒(约16.7ms)出现一次。如果浏览器发现某个浏览环境无法维持这一速率,它可能会将该浏览环境的渲染机会降至更可持续的每秒30次,而不是偶尔丢掉帧。同样地,如果一个浏览环境不可见,用户代理可能会决定将该页面降至更慢的每秒4次渲染机会,甚至更少。
- 其实就是说不是每轮event loop都会更新渲染,具体看每个浏览器怎么实现的。
- 如果document不是空的,那么将hasARenderingOpportunity设置为true。
- 不必要的渲染。从document中删除所有符合以下两个条件的document对象。
- 更新document的浏览上下文的渲染不会有任何可见的影响。
- 浏览器的动画帧为空,也就是说requestAnimationFrame回调是空。
- 从document中删除所有浏览器认为由于其他原因最好跳过更新渲染的document对象。
- 具体来说,浏览器可能希望将定时器回调合并在一起的,没有中间的渲染更新。
- 处理出要渲染的文档。
- 窗口大小发生了变化,执行resize。
- 页面发生了滚动,执行scroll。
- 评估媒体查询并报告该文档的变化。
- 更新动画并为该document发送事件
- 执行全屏步骤
- 执行requestAnimationFrame回调
- 执行IntersectionObserver回调
- 更新document,重新渲染用户界面
- 满足以下条件执行requestIdCallback的回调:
- 是一个window event loop
- 任务队列中没有任何任务
- 微任务队列是空
- hasARenderingOpportunity是false
requestIdCallback其实是可以传入两个参数的,一个是funciton、一个是object。object可以配置一个timeout,设置一个时间,等时间到了然后浏览器强制执行回调。
一轮的事件循环大概就是执行这些步骤,来看简化版的流程图:
来看几个例子加深一下理解:
new Promise((resolve, reject) => {
resolve(1);
Promise.resolve(2).then(val => console.log(val))
}).then(val => {
console.log(val);
})
setTimeout(() => {
console.log(3);
}, 0);
console.log(4);
test();
function test() {
console.log(5);
}
- 首先建立一个全局执行上下文,压入呼叫堆叠。
- 遇到Promise,Promise构造函数中对象同步执行的。将Promise状态改为resolve,这里要注意它虽然调用了resolve但是它的then回调是还没有注册的,因为还没有执行到哪里。
- 又遇到一个promise,状态立刻resolve,并执行then注册回调,把回调推进微任务队列。
- 继续把第一个pormise的then注册回调,把回调推进微任务队列。
- 遇到一个setTimeout,推进任务队列根据时间来执行回调,但这个时间在浏览器里面其实是不准,因为前面执行的代码中存在很多不确定性,被某段代码给阻塞了这个时间也会推迟。
- 打印出一个4。
- 调用test函数打印出一个5。
- 程序执行完,全局上下文弹出。
- 执行微任务检查。
- 按照微任务队列的注册顺序依次执行,依次打印出2、1。
- 微任务队列为空。
- 判断浏览器是否需要更新渲染,如果不需要开启下一轮事件循环
- 任务队列中取出一个旧任务(这里不一定是这个时候取,例子这样但实际是看你代码运行情况)
- 调出setTimeout回调并执行,打印出3。
- 最终依次输出的是 4、5、2、1、3。
再来看一个复杂点的例子(例子都是在chrome中执行,不同的浏览器打印出的结果可能会不一样):
Promise.resolve().then(() => {
console.log('promise1');
});
requestAnimationFrame(() => {
console.log('requestAnimationFrame');
Promise.resolve().then(() => {
console.log('promise2');
});
});
requestIdleCallback(() => {
console.log('requestIdleCallback2');
});
setTimeout(() => {
console.log('setTimeout');
requestIdleCallback(() => {
console.log('requestIdleCallback1');
});
}, 0);
打印出来是(注意不同浏览器打印出来的结果可能会有不同):
promise1
requestAnimationFrame
promise2
setTimeout
requestIdleCallback2
requestIdleCallback1
- 首先建立一个全局执行上下文,压入呼叫堆叠。
- 遇到一个promise状态是resolve,并注册then回调,回调推进微任务队列。
- 遇到requestAnimationFrame,注册回调
- 遇到requestIdleCallback,注册回调
- 遇到setTimeout,推进任务任务队列。
- 程序执行完,全局上下文弹出。
- 执行微任务检查。
- 按照注册顺序依次执行微任务队列,打印出promise1。
- 此时微任务队列为空,事件到更新渲染阶段。
- 执行requestAnimationFrame回调,打印出requestAnimationFrame。
- 遇到prosime,注册then回调,推进微任务队列
- requestAnimationFrame执行完弹出。
- 执行微任务检查。
- 执行刚刚在requestAnimationFrame注册的then回调,打印出promise2。
- 开启新一轮的事件循环,取出任务队列中的旧任务
- 取出setTimeout回调并执行,打印出setTimeout,遇到requestIdleCallback注册回调。
- 启动空闲周期算法。
- 按照注册的顺序依次执行requestIdleCallback的回调,依次打印出requestIdleCallback2、requestIdleCallback1。
总结
- 消息队列是一种数据格式,可以存各种类型的任务,任务是会分优先级的。
- 一个事件循环可能会有一个或者多个任务队列。
- 每一轮的事件循环并不一定会更新画面,根据浏览器的各种情况来决定。
- 微任务的优先级比宏任务高,并且微任务检查的触发时机相当多(这里并没有展开说,因为还有很多决定的因素)。
- resize和scroll自带节流,因为它是在渲染阶段去执行的。
- 可以使用requestIdleCallback方法执行一些不是很重要的代码,因为浏览器自带空闲周期算法。
看到这里相信读者应该对事件循环有一个比较深入的了解了。写到深夜我也写累了,哈哈哈。晚安