前言
最近想深入学习下浏览器渲染相关的问题,于是去拜读了下相关的文档。因为其中涉及到事件循环,所以一并写了一下,这里把自己的理解分享给大家。因为博主只是前端萌新,英语阅读水平也有限,如有错误,敬请斧正。
事件循环
值得一提的是,很多人把事件循环叫做javascript事件循环,但是事件循环并不是javascript engine比如v8之类的提供,而是由runtime提供的,比如浏览器,node.js,下面写的也是以浏览器为参考的。整个循环还是很复杂的,这里为了服务中心只写了一些重要的点。
定义
事件循环: 为了协调事件、用户交互、脚本、渲染、网络等,用户代理必须使用事件循环。每个代理都有一个关联的事件循环,该循环对于该代理是唯一的。
task queue(s): 任务队列,可能有一个或多个,里面是一些task(一般称为宏任务)。
注意: task queues虽然带着queues但其实是sets(集合),而不是queues(队列),因为事件循环处理模型的步骤 1 是从所选队列中抓取第一个可运行的queue,而不一定是第一个queue,但queue的task是顺序执行的。
microtask queue: 微任务队列,该队列是最初为空的微任务队列。通过task创建的microtask会被推入到里面。
rendering opportunity: 决定是否要渲染的一个变量。它考虑了硬件刷新率限制,出于性能原因的用户代理页面是否可见等等。渲染机会应该定期发生。(即每次渲染时间间隔应该大体相同,比如16.6ms或33.3ms,而不应该出现丢帧,比如多次16.6ms 突然33.3ms)
流程
-
首先看task queue(s)是非存在有可执行的task的task queue,有就取出一个task queue执行里面的task,如果没有,跳过此步到microtask queue的执行。
-
接着看microtask queue是否为空,非空就执行,中途如果产生了新的microtask,那么也一并执行。
-
更新
now
这个值为current high resolution time
。(猜测在这里更新了performance.now
的返回值) -
通过定义中的描述设置
rendering opportunity
-
如果
rendering opportunity
存在为true 并且下面条件至少成立一个 1.浏览器判断更新渲染会带来视觉上的改变 2.map of animation frame callbacks
不为空(可以通过requestAnimationFrame添加) 则进行下面几步,否则跳过本步骤- 如果窗口的大小发生了变化,执行监听的
resize
方法。 - 如果页面发生了滚动,执行
scroll
方法。 - 执行
requestAnimationFrame
的回调,并传入now
作为时间戳。 - 执行
IntersectionObserver
的回调,并传入now
作为时间戳。 - 重新渲染绘制用户界面。
- 如果窗口的大小发生了变化,执行监听的
-
判断是否调用
requestIdleCallback
回调,一般在浏览器空闲时会调用。
Task源与Microtask源
-
Task源
- 整个JS文件可以看做第一个宏任务。
- DOM 操作
- 用户交互,例如键盘或鼠标输入。
- 网络任务
- 历史记录遍历,如调用 history.back()和类似的 API。
-
Microtask源
- process.nextTick
- Promise
- Async/Await
- MutationObserver
浏览器渲染
如果上面判定确实要渲染了,那么我们介绍下渲染的步骤。
大体步骤
-
generic DOM Tree 生成DOM树
- 通过词法分析文法分析等,生成一棵DOM树。
- HTML的文法不同于大部分编程语言,它不是二型文法(上下文无关文法),而是一型文法(上下文有关的),因为在产生式中左部并非一定是单个非终结符,比如两个Form嵌套时,解析出来只有一层,说明存在一个和Form相关的产生式,它的左部至少两个符号,这样与之前状态有关了,也就是上下文有关。
-
calculate style 计算样式
-
格式化:这些文本浏览器并不懂,因此要把各种CSS生成一个对象,这个对象通过
document.styleSheets
来查看。 -
标准化:将rem,em,vw等等转化为px 把颜色转化为#xxxxxx等
-
继承和重叠:最后通过继承和重叠,计算最后样式属性,继承就是一部分属性可能从父元素继承应用到自身,下面介绍下所谓的重叠
-
先找到某个元素可以匹配的所有规则
-
按显式权重和来源排序
从大到小依次为:读者的!impotant声明 > 作者的!important声明 > 作者的常规声明 > 读者的常规声明 > 用户代理默认的样式声明
-
按特指度排序,特指度形如 0 0 0 0,从高位开始比,遇到不同的较大者胜出
行内 1 0 0 0 id选择器每个 0 1 0 0 类选择器,属性选择器,伪类选择器每个 0 0 1 0 元素选择器,伪元素选择器每个 0 0 0 1 *是 0 0 0 0 继承来的则是无特指度
-
如果上面还不能确定,那么靠后出现的声明胜出
-
-
-
update layout tree 更新布局树
- 遍历生成的 DOM 树节点,并把他们添加到布局树。
- 计算布局树节点的坐标位置。
-
updata layer tree 更新图层树
是的,有了位置,样式和DOM后还不能渲染,因为我们不知道图层间的顺序,因此还要构建图层树
-
paint 绘制
这一步就是把页面的像素信息绘制出来
-
composite layers 合成图层
把各个图层进行合成
-
rasterize paint 栅格化图形
用栅格化线程池栅格化上述数据为位图
-
display 展示
将位图数据信息传给显卡,显示到显示器上
reflow和repaint
那么我们样式改变以后,画面就会改变,但是它们究竟会经历上面那些流程呢?
reflow 回流(重排)
当DOM树中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流。
会导致回流的操作:
- 页面首次渲染
- 浏览器窗口大小发生改变
- 元素尺寸或位置发生改变
- 元素内容变化(文字数量或图片大小等等)
- 元素字体大小变化
- 添加或者删除可见的
DOM
元素 - 激活
CSS
伪类(例如::hover
) - 查询某些属性或调用某些方法
一些常用且会导致回流的属性和方法:
- client族:
clientWidth
、clientHeight
、clientTop
、clientLeft
- offset族:
offsetWidth
、offsetHeight
、offsetTop
、offsetLeft
- scroll族:
scrollWidth
、scrollHeight
、scrollTop
、scrollLeft
scrollIntoView()
、scrollIntoViewIfNeeded()
getComputedStyle()
getBoundingClientRect()
scrollTo()
repaint 重绘
当页面中元素样式的改变并不影响它在文档流中的位置时(例如:color
、background-color
、visibility
等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。
比较
reflow的代价要比repaint的代价要大,因为reflow显然要进行上面所有步骤,而repaint因为位置没有变,自然会跳过 update layout tree
这个步骤,因此如果一定要进行reflow操作时,有必要情况下要进行节流操作。
function throttle(func, wait) {
let previous = 0;
return function () {
const now = Date.now();
const context = this;
const args = arguments;
if (now - previous > wait) {
func.apply(context, args);
previous = now;
}
}
}
复制代码
下面我们简单弄一个可以动的画面来看看是不是如上面的步骤一样
上面performance展示的,通过margin移动
<style>
#move {
width: 20px;
height: 20px;
background-color: aqua;
}
</style>
<div id="move"></div>
<script>
let move = document.querySelector("#move");
let i = 0;
setInterval(() => {
move.style['margin-top'] = i++ * 0.1 + 'px';
}, 10)
</script>
下面performance展示的,通过tranform移动
<style>
#move {
width: 20px;
height: 20px;
background-color: aqua;
}
</style>
<div id="move"></div>
<script>
let move = document.querySelector("#move");
let i = 0;
setInterval(() => {
move.style['transform'] = `translateY(${i++*0.1}px)`;
}, 1)
</script>
复制代码
可以看到repaint并没有进行layout这个过程。
如何避免
CSS
- 避免使用
table
布局。 - 尽可能在
DOM
树的最末端改变class
。 - 避免设置多层内联样式。
- 将动画效果应用到
position
属性为absolute
或fixed
的元素上。 - 避免使用
CSS
表达式(例如:calc()
)。
JavaScript
- 避免频繁操作样式,最好一次性重写
style
属性,或者将样式列表定义为class
并一次性更改class
属性。 - 避免频繁操作
DOM
,创建一个documentFragment
,在它上面应用所有DOM操作
,最后再把它添加到文档中。 - 也可以先为元素设置
display: none
,操作结束后再把它显示出来。因为在display
属性为none
的元素上进行的DOM
操作不会引发回流和重绘。 - 避免频繁读取会引发回流/重绘的属性,如果确实需要多次使用,就用一个变量缓存起来。
- 对具有复杂动画的元素使用绝对定位,使它脱离文档流,否则会引起父元素及后续元素频繁回流。
requestAnimationFrame/requestIdleCallback
具体调用时机,参考事件循环。
requestAnimationFrame
window.requestAnimationFrame()
告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
实际上,虽然它的名字带有animation但是它并不一定进行与动画相关的操作,我们也可以用它进行节流,那么它的回调函数就可以保证每帧最多只会进行一次。
function throttle(func, wait) {
let flag = false;
return function() {
if (flag) return;
flag = true;
const context = this;
const args = arguments;
requestAnimationFrame(() => {
flag = false;
func.apply(context, args);
})
}
}
复制代码
requestIdleCallback
window.requestIdleCallback()
方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout
,则有可能为了在超时前执行函数而打乱执行顺序。
参考
HTML Standard (whatwg.org):html.spec.whatwg.org/multipage/w…