创建DOM树
DOM(文档对象模型)是 HTML 文档的对象表示,同时也是外部内容(例如 JavaScript)与 HTML 元素之间的接口。
HTML 解析器将 HTML文件解析为 DOM树,DOM 树的根节点是 Document
对象。
DOM 与 HTML 之间几乎是一一对应的关系,但 DOM 是保存在内存中树状结构,可以通过 JavaScript 来查询或修改,HTML 本质上就是字符串。
样式计算
样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式(计算样式)
需要完成的步骤:
-
把 CSS 转换为浏览器能够理解的结构,便于后序的查询和修改(可以称其为 CSSOM)
通过
document.styleSheets
可以得到页面上所有link
和style
所定义的样式表(styleSheets
类型) -
将样式表中的属性值标准化,由于 CSS 中的写法多样,所以需要转换为统一的值。
-
使用 CSS 规则计算出 DOM 树中每个节点的具体样式,通过 DevTools 的
computed
部分可以看到具体信息
布局
有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置,这个计算过程叫做布局,布局是一个寻找元素几何形状的过程。
Chrome 在布局阶段需要完成两个任务:创建布局树和布局计算。
布局树
主线程遍历 DOM 树和每个节点的计算样式创建布局树,其中包含坐标和边界框大小等信息。
布局树可能与 DOM 树的结构相似,但是布局树和 DOM 树中的元素并不一一对应,布局树只包含和页面可见有关的内容。
- 布局树中不包含非可视化元素和
display: none
的元素,但是包含visibility: hidden
的元素 - 具有内容的伪类
p::before{content:"Hi!"}
会包含在布局树中,但是不在 DOM 树中
布局计算
拥有一棵完整的布局树之后就要计算布局树节点的坐标位置,布局的计算过程非常复杂。
布局计算就是读取布局树中的内容,并计算机布局信息重新写入布局树。
在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。
渲染树
渲染树是16年之前的东西了,现在的代码完全重构了,可以把布局树看成是渲染树,不过和之前的渲染树还是有差别的。
渲染树是由可视化元素按照其显示顺序而组成的树,也是文档的可视化表示,它的作用是让浏览器按照正确的顺序绘制内容。
Firefox 将渲染树中的元素称为 frames
。WebKit 使用的术语是 renderer
或 render object
。
renderer
知道如何布局并将自身及其子元素绘制出来。
分层
为了提高每一帧的渲染效率,Chrome 引入了分层和合成的机制。
现在的网页中具有很多复杂的效果(例如 3D变换、z-index
以及页面滚动),实际上页面被分成了很多图层,这些图层叠加后合成了最终的页面。
合成的概念
合成是一种将页面的各个部分分成多个层、单独光栅化它们并在合成线程中合成为一个页面的技术。
光栅化: 将元素信息转换为屏幕上的像素,将元素的信息转换为位图。
举个例子就是:如果发生滚动,因为图层已经被光栅化,它所要做的就是合成一个新的帧,动画可以通过移动图层并合成新帧以相同的方式实现。
分层和合成的好处就是无需触发重排重绘,直接合成即可完成动画:
通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。但不管怎样,最终每一个节点都会直接或者间接地从属于一个层。
- 拥有层叠上下文属性的元素会被提升为单独的一层
- 需要剪裁(clip)的地方也会被创建为图层
- 滚动条也会被提升为单独的层
- 通过
will-change
可以告诉浏览器将某个元素提升到单独的层,使用这个可以优化动画 - 不能滥用分层,在过多的图层上进行合成可能会导致操作更慢
图层
在 Chrome 的 DevTools 的 Layers 中可以很清楚的看到一个网页的分层情况。
图层树
为了找出哪些元素要在哪些层中,主线程遍历布局树以创建图层树,这部分在 DevTools 性能面板中称为“更新层树”。
图层绘制
绘制是填充像素的过程,它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。
图层的绘制分为两个阶段:创建绘图调用的列表、填充像素。
生成绘制列表
渲染进程主线程会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表。
绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染进程中的合成线程来完成的,一旦创建了层树并确定了绘制顺序,主线程就会将该信息提交给合成线程。
在开发者工具的 Layers 部分可以清楚的看到一个页面的绘制列表:
光栅化
当图层的绘制列表准备好之后,主线程会把该绘制列表提交给合成线程,合成线程会光栅化(根据绘制列表)每一个图层。
一个图层往往很大,合成线程会将图层划分为若干个图块(大小通常是 256×256 或者 512×512),并将每个图块发送到光栅线程,光栅线程会光栅化每个图块并将它们存储到 GPU 内存中。
-
渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行
-
合成线程会将视口附近的图块优先执行栅格化
-
通常栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化
合成显示
一旦所有图块都被光栅化,合成线程就会收集图块信息(DrawQuad)创建合成帧,合成线程将合成帧通过 IPC 传递给浏览器进程,接着合称帧会被传递到 CPU 中显示到页面上。
如果出现滚动事件,合成器线程会创建另一个合成器帧以发送到 GPU 而无需主线程的参与。
渲染流程概述
一个完整的渲染流程大致可总结为:
- 渲染引擎将 HTML 内容转换为 DOM 树
- 渲染引擎将 CSS 样式表转化为
StyleSheets
,计算出 DOM 节点的样式 - 创建布局树,并计算元素的布局信息
- 对布局树进行分层,并生成分层树
- 为每个图层生成绘制列表,并将其提交到合成线程
- 合成线程将图层分成图块,并在栅化线程池中将图块转换成位图
- 合成线程将图块合称为合称帧,并发送给 GPU 显示到屏幕上
回流和重绘
重排/回流
通过 JavaScript 或者 CSS 修改元素的几何位置属性,浏览器会触发重新布局之后的一系列阶段,这个过程就叫回流。
回流需要更新完整的渲染流水线,所以开销是最大的。
重绘
当更新了元素的绘制属性(如:背景颜色),浏览器不会重新布局,直接从绘制阶段开始执行之后的一系列阶段。
直接合成
更改一个既不要布局也不要绘制的属性,渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。
使用了 CSS 的 transform
来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。
因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。
减少回流和重绘
- 使用
transform
替代top
- 使用
visibility
替换display: none
,因为前者只会引起重绘,后者会引发回流 - 不要把节点的属性值放在一个循环里当成循环里的变量
- 不要使用
table
布局,可能很小的一个小改动会造成整个table
的重新布局 - 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用
requestAnimationFrame
- CSS 选择符从右往左匹配查找,避免节点层级过多
- 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点。比如浏览器会自动将
video
节点变为图层。
页面事件
load
window
的 load
事件:页面中所有资源全部加载完毕之后触发
DCL
document
的 DOMContentLoaded
:DOM树构建完成后发生,且只能采用 DOM2 的方式注册(addEventListener
)
readystate
loading
interactive
complete
interactive
时DOM树构建完毕,触发 DOMContentLoaded
事件。complete
时页面加载完毕,触发 window
的 load
事件
脚本和样式表
脚本
当浏览器遇到 <script>
时会立即加载并执行脚本,此时DOM树的构建将暂停,直到该脚本执行完毕。
一般脚本放在 body
的最底部,避免阻塞页面解析。如果脚本中没有操作 DOM 相关代码,就可以将该脚本设置为异步加载。
异步加载方案:
defer
:要等到 DOM 全部解析完(DCL事件之前)才会被执行,不会阻塞async
:加载完就异步执行,不会阻塞
样式表
解析样式表不会更改 DOM 树,所以请求样式表无需停止文档解析,可以并行处理。
CSS 不会阻塞 DOM树的生成,只有一种情况:
<html>
<head>
<style type="text/css" src = "theme.css" />
</head>
<body>
<p>xxxxxxx</p>
<script>
const p = document.getElementsByTagName('p')[0]
p.style.color = 'blue'
</script>
<p>xxxxxxx</p>
<p>xxxxxxx</p>
</body>
</html>
复制代码
JavaScript中访问了某个元素的样式,当时还没有加载和解析样式,就需要等待样式的加载和解析完毕。所以在这种情况下,CSS也会阻塞DOM的解析。
预解析
网络进程接收数据之后,会和渲染进程之间会建立一个共享数据的管道,网络进程将接收到数据(HTML文件)往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据给 HTML 解析器,HTML 解析器动态接收字节流,并将其解析为 DOM。
一般来说,当DOM的解析遇到了脚本会暂停整个 DOM 的解析,加载并执行脚本之后才会继续解析。
不过 Chrome 浏览器做了很多优化,其中一个主要的优化是预解析操作。当渲染线程收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,会提前下载这些文件。
交互阶段优化
交互阶段优化的原则是:尽量减少一帧的生成时间
- 减少 JavaScript 脚本占用主线程的事件,可以分解为多个任务
- 避免强制同步布局,强制同步布局就是提前在执行脚本的过程中提前布局
- 正常获取
offsetWidth
等属性的值用的是上一帧的缓存值 - 但是在获取之前先修改 DOM 样式再获取,浏览器也会强行重新布局,因为需要确保这些值是实时的
- 即使是在
requestAnimationFrame
中也会造成这个后果
- 正常获取
- 避免布局抖动,布局抖动是指在一次 JavaScript 执行过程中,多次执行强制布局和抖动操作
- 尽量使用 CSS 动画,不占用主线程
参考文章