从浏览器原理分析界面性能优化 02---界面渲染
前言
说到性能优化,界面渲染优化是我们要注意的重中之重.想要优化我们的界面渲染,我们首先要明白界面渲染的具体流程.
我们先从几个面试中经常出现的题目来切入这个问题:
- JS 解析会阻塞界面渲染么?
- CSS 的加载和解析会阻塞界面渲染么?
- 简要描述浏览器的重绘和回流
- 什么是 GPU 加速?
- 什么是异步加载和预加载?
- 什么是关键渲染路径(CRP:Critical Rendering Path)?
上面几个问题,大都是和浏览器的界面渲染相关的,我们先不着急去解答这些问题,我们先探究一下浏览器的渲染流程,在回头看看这几个问题.
浏览器的宏观架构
先说一下什么是进程和线程:
进程是 CPU 分配资源的最小单位(能够拥有资源和独立运行的最小单位) 线程是 CPU 调度的最小单位(运行在进程上的一次程序运行单位)
我们再看一下浏览器的架构:
浏览器的架构是多进程架构,这个之前的文章有介绍过,大家有兴趣的可以去看看浏览器内核(浏览器渲染进程)
我们常说的浏览器内核也就是浏览器的渲染进程,它主要包含了 JS 引擎线程、GUI 线程、事件触发线程等,我们先看看各个线程的工作内容
GUI 线程
- GUI 线程主要负责浏览器的界面渲染,HTML 文件、CSS 文件解析,DOM 树、渲染树、分层树的构建等
- GUI 线程也负责我们常说的回流、重绘的过程
- GUI 线程和 JS 线程是互斥的,当 GUI 线程运行的时候 JS 线程会被挂起,反之亦然
JS 引擎线程
- JS 引擎线程即 JS 内核,负责处理 JS 脚本程序,
- JS 线程中也负责 JS 的垃圾回收工作
- JS 也负责等待任务队列中的任务到来,然后进行处理
- JS 线程是单线程的
- JS 线程和 GUI 线程是互斥的,当 JS 线程运行的时候 GUI 线程会被挂起,反之亦然
事件触发线程
- 事件触发线程主要负责维护一个事件队列
- 当我们的操作或者 JS 脚本产生宏任务的时候会添加到事件队列的队尾
定是触发器线程
- 负责处理 setTimeout、setImmediate 等计时器
- 因为我们的 JS 线程执行代码时是先解析代码,然后再一行一行执行,因为 JS 线程出现阻塞时可能影响定时器的准确性.所以这时候需要我们使用一个单独的线程来处理计时器工作
- 注意,在浏览器中 setTimeout 的最低触发时间间隔为 4ms
异步 http 请求线程
- 在 XMLHttpRequest 连接后通过浏览器新开一个线程
- 检测到状态变更时,回调函数会放到异步队列中等待 JS 线程的执行
浏览器的渲染流程
下面我们看一下浏览器界面的渲染流程,这个过程发生在 HTTP 请求接收到数据之后,首先在这里我们要提前建立一个清醒的认知: 我们从 HTTP 请求回来开始,这个过程并非一般想象中的一步做完再做下一步,而是一条流水线 有了这个认知,我们接上一篇文章
然后,看看浏览器之后的操作:
准备渲染进程
当我们的网络请求的响应头信息的Content-Type为text/html时,浏览器则将继续进行导航流程,创建我们的渲染进程. 默认情况下浏览器会为每个标签页分配一个渲染进程,但在某些情况下浏览器也会将同属于一个域名下的多个界面分配到同一个渲染进程中.多个页面分配到同一进程中的操作叫做同一站点策略. 在这里我们可以先理解一下什么是同源站点:
我们将同一站点称为根域名(demo.com)和协议(https 或者 http),还包含了该根域名下的所有子域名和不同的端口.例如:
- www.demo.com
- time.demo.com
- www.demo.com:8081 他们都是同一站点,因为他们的协议都是 https,根域名都是 demo.com
渲染进程策略
当我们打开新界面采用的渲染进程策略就是:
- 正常打开新的界面,就会创建新的渲染进程
- 如果新打开的界面和之前存在的界面是同一站点,那么就会复用之前的渲染进程
- 如果新打开的界面和之前的界面不属于同源站点,则创建新的进程
提交文档
在该阶段,浏览器将网络进程接收到的 html 数据提交给渲染进程:
- 浏览器进程接收到网络进程的响应头数据之后,向渲染进程发送提交文档的消息
- 渲染进程,接收到提交文档的消息之后,与网络进程建立传输数据管道
- 文档数据传输完成之后,渲染进程会提交发送确认提交的消息给浏览器进程
- 浏览器进程接收到确认提交的消息之后会更新界面,包括地址栏 URL、回退栏状态、Web 界面等
PS:注意我们之前说过,渲染过程是一个流水线的状态.所以我们上面只是为了描述方便简化了提交文档的流程
渲染阶段
当渲染进程接收到提交文档的消息后,便开始界面解析的过程,下面我们先总结一下大概的过程:
- 解析 HTML 文件,构建 DOM 树,同时下载解析 CSS
- CSS 解析成 CSS Tree 后和 DOM Tree 合成为 Layout Tree
- Layout Tree 进行界面元素的尺寸位置信息的确认(回流过程发生在这里),和元素的像素信息的绘制(重绘流程发生在这里)
- Layout Tree 得到界面个元素信息后会生成分层树
- 分层树对各层进行分块处理后开始位图的绘制(这步在 GPU 中完成)
- 位图绘制完成后通知浏览器进程,将 Web 界面展示出来
解析 HTML 生成 DOM 树
首先,说一下为什么要把 HTML 构建为 DOM 树,因为 HTML 在浏览器中是以字符串的形式存在的,并不能被浏览器识别,所以需要将其转换为浏览器能够理解的结:DOM 树
我们来先看一下 DOM 树是什么样的结构,这里无耻的用了一下网上其他文章的图片(侵删):
上面的过程大概是一个 HTML 文件通过 HTML 解析器转换为 DOM 树的过程. 我们都知道,HTML 被称为“超文本标记语言”,以下面的代码为例:
<p>内容</p>
代码中的“p”标签被称为标记,,“内容”被称为文本,也就是说HTML 是由文本和标记组成的. PS:这里再次强调一下之前提到的一点:浏览器的渲染是一个流水线的状态,所以我们的 HTML 解析也是从网络进程那里接收到多少数据便解析多少数据(便接收边解析). 下面我们简要来探讨一下 HTML 生成 DOM 树的过程:
先上一张我们的一个大致流程图如下:
字节流转换为 token
该阶段起主要作用的是分词器,分词器会将字节流转换成一个个的 token,我们可以将其简单分为 Tag Token 和文本 Token,例如我们下面这段代码:
<html>
<body>
<div>test</div>
</body>
</html>
我们可以通过分词器将上面代码分为如下 Token
上图中我们可以看出来,Start Tag Token 和 End Tag Token 一一对应,同时文本 Token 是一个单独的 Tag.
Token 转换为 DOM 节点,DOM 节点添加到 DOM 树上
这其实是两步,分别为 Token 转换为 DOM 节点,DOM 节点添加到 DOM 树中,不过正像我们之前提到的那样,这是一个流水线式的过程,而且为了方便理解我们将其合并在一起. Token 转换为 DOM 树,需要维护一个 Token 栈来完成这一过程,体转换流程如下
- 最开始的时候生成一个 document 跟节点,同时将一个 document Start Token 压入栈
- 然后处理我们分词器解析出来的 Token
- 如果是一个 Start Token 则入栈,创建一个 DOM 节点挂载到 DOM 树上
- 如果是一个 End Token,同时栈顶是一个对应的 End Token,那么则栈顶的 Token 出栈
- 如果分词器解析出来的是一个文本 Token,那么会生成一个文本节点挂载到 DOM 树中(注意:文本 Token 不会入栈,他的父节点就是当前挂载的 DOM 节点)
- 当我们所有的字节流通过分词器解析完成,同时 Token 栈清空的时候,我们的 DOM 树则构建完成
样式计算
DOM 树的生成是需要 CSS 和 HTML 共同作用的,也就是说样式计算发生在 DOM 树完全生成之前.
样式计算主要是解析 CSS,计算出 DOM 节点的具体样式,该阶段可以大致分为三个阶段
转换 CSS
众所周知,我们的 CSS 样式来源主要有下面三个方面:
- 通过 link 标签外部引入
- style 标签的内联样式
- 元素的内嵌行内样式
这些样式在解析之前也是以字节流的形式存储在浏览器中的,渲染引擎接收到 CSS 文本时,会先执行转换操作,将字节流转换成浏览器能够识别的结构:StyleSheets.
标准化属性值
这步的主要目的是将各种 CSS 样式,转换为渲染引擎能够理解的、标准化的值.例如:
这个过程就称为属性值标准化
计算出 DOM 节点的具体样式
这步主要是计算出 DOM 节点的样式然后附加到对应的节点上. 这里样式计算用到了继承规则和层叠规则:
- 继承规则:每个 DOM 节点都包含父节点的样式(这个地方可以类比我们平常说的样式继承)
- 层叠规则:样式层叠规定了如何合并合并来自多个源的属性值的算法.感兴趣的同学可以去查一下层叠样式表
布局阶段
我们现在有了 DOM 树和 DOM 节点的对应样式,但是这个时候浏览器还缺少关键的一点:每个元素的位置信息,接下来渲染引擎需要计算出每个可见元素的几何位置,这个过程称为布局.
这个阶段需要两个步骤:创建布局树和布局计算
创建布局树(Layout Tree)
布局树的主要作用就是将可见元素构建为一棵树.大致包含下面两个内容:
- 遍历 DOM 树的所有节点,然后将可见的节点添加到布局树中
- 不可见的节点会被忽略掉
布局计算
这个阶段的主要作用是计算每个元素的几何位置信息,然后保存在布局树中.
分层
分层阶段是在布局树的基础上完成的.界面中有很多复杂的效果,为了方便这些效果的实现,渲染引擎需要为特定的节点生成特殊的图层,并生成一棵对应的图层树. 这里的图层树我们可以类比我们学过的层级上下文的概念.
从上面的描述我们可以看出来,浏览器的页面其实分成好多图层,这些图层叠加后形成了最终的界面.
PS:不是每个节点都归属一个图层,如果一个节点没有对应的图层,那么他会属于父节点的图层.
满足下面的规则会提升为单独的图层:
- 拥有层叠上下文属性的元素会提升为单独的图层
- 需要裁剪的地方也会被创建为图层
图层绘制
图层树构建完成后,渲染引擎会对每个图层进行绘制.这个过程其实很简单.假设一种情况:我们在一个黑色的背景上绘制一个红色的矩形,矩形里面有一个黄色的圆. 那么我们的绘制过程可以大概描述为:先绘制一个黑色的背景,然后再根据位置信息绘制一个矩形,然后在根据圆的位置信息绘制一个黄色的圆. 渲染引擎的绘制过程与上面的过程类似,它会把图层的绘制拆分成一个个绘制指令,然后这些绘制指令会组合成一个绘制列表.
绘制列表只是用来描述图层元素的绘制信息,但是真正完成绘制操作的是有渲染引擎的合成线程来完成.
分块
绘制列表完成后,交给合成线程来进行绘制. 这里我们首先要明确一个概念,一个网页的页面可能会很大,但是我们用户看到的只是其中一部分,这部分我们称之为视口. 基于上述原因,合成线程会将界面进行分块.图块的大小通常是 256256 或者 512512.合成线程会优先将视口附近的图块生成位图.
光栅化
实际生成位图的操作由栅格化来执行也称为光栅化,图块是执行栅格化操作的最小单位.栅格化的操作都是在栅格化线程池中完成,渲染引擎会维护一个栅格化线程池.
通常情况下,栅格化的操作都会使用 GPU 加速来完成,GPU 生成位图的操作又叫做快速栅格化或者GPU 栅格化,生成的位图会保存在 GPU 中. PS:记得我们之前讲过,浏览器分为渲染进程和GPU 进程,也就是说我们的快速栅格化的过程涉及到了跨进程通信
合成和显示
所有的图块都光栅化处理完成后,合成线程就会生成一个绘制图块的命令:“DrawQuad”,然后将该命令提交到浏览器进程. 浏览器中有一个叫做 viz 的组件接收合成线程的“DrawQuad”命令,然后根据该命令将相应的命令将内容绘制到内存中,最后将内存中的界面显示到屏幕上.
到这里,经过一系列的阶段,我们终于能在浏览器上看到了我们的界面了.
题解
1、JS 解析会阻塞界面的渲染么
首先,我们要回忆一下之前的一个知识点:JS 线程和渲染线程是互斥的,当一方执行,另一方会被暂时挂起等待对方执行完毕后再执行. 由此我们可以得出结论:JS 解析会阻塞界面的渲染 JS 的加载情况比较复杂,下面我们来分别讨论一下:
首先,在 html 中间插入一段 JS 脚本:
<html>
<body>
<div id="test">test1</div>
<script>
let div1 = document.getElementsById('test')
div1.innerText = '测试'
</script>
<div>test2</div>
</body>
</html>
前面的 html 解析和我们之前提到过的是相同的,但是到了 script 标签之后,渲染引擎会被暂时挂起,然后 JS 脚本开始执行,脚本执行完以后 id 为“test”的 div 的内容就会改为“测试”. 然后,渲染引擎恢复执行渲染剩余 html 文本. 理解了上面的渲染流程后,我们在看一个类似的:
内敛 JS 脚本换成 JS 引入的文件
<html>
<body>
<div>1</div>
<script type="text/javascript" src="foo.js"></script>
<div>test</div>
</body>
</html>
上面把 script 的内敛脚本换成了一个外部引入的文件,但是内容完全相同.但是和之前不同的是 JS 外部脚本需要下载,这个JS 下载过程也会阻塞 DOM 解析. 但是,浏览器在这里做了优化,即预解析操作:当浏览器接收到 html 字节流的时候,会开启一个预解析线程,用来解析其中包含的 JS 和 CSS 文件,解析到相关文件后预解析线程会提前下载这些文件. 所以,JS 的下载和解析会阻塞 DOM 的解析,我们可以通过利用 CDN 加速、async/defer、preload 等策略来降低阻塞时间.
第三种情况
下面看一下 CSS 加入后产生的变化:
<html>
<head>
<style src="theme.css"></style>
<style>
div {
color: blue;
}
</style>
</head>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang' //需要DOM
div1.style.color = 'red' //需要CSSOM
</script>
<div>test</div>
</body>
</html>
我们可以看到 JS 脚本中操作了 CSS 样式,所以在 JS 脚本执行前需要等待 CSS 加载完毕并解析成 Style Sheets 之后才能执行.所以这种情况下CSS 会阻塞 JS 脚本的执行.
2、CSS 的加载和解析会阻塞界面渲染么
从上述浏览器渲染流程我们可以看出,Layout Tree 的构建是由 HTML DOM 和 Style Sheets 共同构建完成的. 而且我们的 CSS 加载过程是一个异步的过程,所以CSS 的加载不会阻塞 DOM 的构建. 但是,Layout Tree 的构建是由 HTML DOM 和 Style Sheets 共同完成的,所以CSS 的加载会阻塞界面的渲染.
同时,我们知道 JS 是可以操作界面的 DOM 和 CSS 样式的,所以浏览器在解析 JS 的时候需要 CSS 加载解析完成,同时JS 线程和渲染线程是互斥的. 也就是说,当我们解析 JS 的时候会阻塞 DOM 的渲染,同时 JS 的解析也需要 CSS 的样式表.也就是说CSS 可以通过阻塞 JS 的解析来变相的阻塞 DOM 的渲染
3、简要描述浏览器的重绘和回流
通过上面的描述我们可以知道,浏览器的渲染流程的布局阶段包括了
- Layout Tree 构建
- 确定元素的几何位置信息(会触回流发该过程)
- 确定元素的像素信息(重绘会触发该过程)
因此,我们可以有一个清醒的认知:回流必引发重绘,重绘不会引发回流
触发回流和重绘的操作有很多,大致可以分为下面两类:
- 影响元素在文档流中的位置和影响元素的几何属性的会产生回流操作
- 影响元素自身像素信息的操作会产生重绘操作
PS:当我们获取元素的几何或者位置信息时,因为浏览器要返回给我们精确的信息,所以也会引起回流操作
4、什么是 GPU 加速
从上面的浏览器渲染流程我们可知,GPU 加速是直接跳过回流和重绘的过程,执行图层绘制和复合的操作. 因此,想使用 GPU 加速,需要将我们的元素变化操作提升到合成层,提升成合成层的操作有很多,这里简单介绍几种:
- CSS 中的 transform 属性
- 对 opacity 做 CSS 动画
- CSS 的 will-change(慎用,因为浏览器的优化有很多,而该方法并不能提升多少性能)
- CSS 的隐式合成
5、什么是异步加载和预加载
异步加载:async/defer
异步加载可以将我们的 JS 脚本的下载改为异步下载,也就是说 JS 的下载过程不会阻塞我们的 DOM 渲染. 但是这里要注意async 和 defer 的区别:
- async 是无序的,JS 脚本下载完以后会立即执行
- defer 是有序的,JS 脚本下载完以后,会在 DOMContentLoaded 之前去按照加载顺序执行脚本
从上面的区别可以看出,因为 JS 脚本模块化的特性,对无序的 async 的依赖并不大,
预加载
预加载 preload/prefetch,两者都是 link 标签中的 rel 属性,不同的是
- preload 提供声明式的命令,让浏览器提前加载资源,等到需要的时候再去执行
- prefetch 是告诉浏览器下一个页面才会用到的资源,也就是说 prefetch 是为了下一个界面的加速而存在的
简单来说就是:preload 告诉浏览器必须提前加载的资源,prefetch 告诉浏览器可以提前加载的资源,两者preload 的优先级高于 prefetch
6、什么是关键渲染路径
关键渲染路径(Critical Rendering Path)即 CRP,关键渲染路径说的是我们上面讲过的浏览器首屏渲染所经历的一系列的步骤. 它包含下面三部分内容:
- 关键资源数量:即会阻塞首屏渲染的资源
- 关键路径长度:获取所有关键资源所需要的往返的请求时间
- 关键字节:首屏渲染所需要的总字节数,等同于所有关键资源文件的总和
前端优化
上面讲了那么多,终于绕回我们的主题了:前端性能优化. 想必同学们看到这里可能有点累了.因此,我们在大致总结一下浏览器的渲染流程:
- 解析 HTML 文件,构建 DOM 树,同时下载解析 CSS
- CSS 解析成 CSS Tree 后和 DOM Tree 合成为 Layout Tree
- Layout Tree 进行界面元素的尺寸位置信息的确认(回流过程发生在这里),和元素的像素信息的绘制(重绘流程发生在这里)
- Layout Tree 得到界面个元素信息后会生成分层树
- 分层树对各层进行分块处理后开始位图的绘制(这步在 GPU 中完成)
- 位图绘制完成后通知浏览器进程,将 Web 界面展示出来
我们的前端性能优化可以顺着上面的步骤来进行:
CSS 优化
关于 CSS 的优化,我们首先要明确一点:CSS 的匹配是从右向左进行匹配的,例如:
#list li {
}
按照我们一贯的思路肯定是以为渲染引擎先匹配到“#list”,然后在去它下面查找“li”标签,但实际上正好相反:渲染引擎必须遍历每个 li 元素,然后再查找它的父元素的 id 是不是“list”,这是一个相当耗时的操作. 我们在看一个之前用过的:
* {
}
之前我们可能用通配符来清除样式,但是通配符会遍历所有的元素,因此通配符造成的开销是非常大的,所以尽量避免使用通配符
综上,所述我们平常在书写 CSS 的时候可以有以下几点性能优化方案:
- 尽量避免使用通配符
- 尽量避免使用标签选择器,因为标签选择器会遍历文档下所有对应的标签
- 如果能使用继承的样式,尽量使用继承,避免多次计算
- 尽量减少不必要的嵌套,因为嵌套会造成多次遍历,增大了开销
- id 和 class 选择器的使用要尽量简洁,尽量不要和其他标签搭配使用
渲染阻塞优化
通过之前的介绍我们可以知道,在页面渲染的过程中,不合理的 JS、CSS 加载是会阻塞界面渲染的. 因此我们要做的就是合理安排脚本的加载顺序,避免界面渲染的时候被阻塞.主要有以下几点:
- CSS 是阻塞渲染的资源,我们要尽可能早的下载,例如将 CSS 放到 head 标签中,启动 CDN 静态资源的加载速度优化
- JS 脚本要滞后加载,因为对于首屏来说,即便没有 JS,我们的界面呈现也不会受太大影响,所以我们可以将 JS 脚本放在文档的最后,或者使用 async/defer 对其进行异步加载
减少 DOM 修改次数
这是一个关于线程间通讯的问题,通过上面的介绍我们知道了,JS 线程和渲染线程是不同的线程,所以当我们通过 JS 修改 DOM 的时候是需要进行线程间通讯的时候也会造成额外的开销. 为了避免这些无谓的开销,我们可以将多次 DOM 操作合并为一次,举一个最典型的例子:
let wrapper = document.getElementsById('demo')
let fragment = document.createDocumentFragment()
for (let i = 0; i < 10; i++) {
let p = document.createElement('p')
p.innerText = `测试${i}`
fragment.appendChild(p)
}
wrapper.appendChild(fragment)
上面的操作是利用Fragment的特性,将十次更新 DOM 的操作合并为了一次
回流与重绘的优化
通过上面的介绍我们知道,在我们操作 DOM 的时候如果引发回流和重绘,那么浏览器的开销是非常大的,那么在这种情况下我们要做的就应该是避免产生回流和重绘的操作. 如何避免,首先我们要看一下,都有那些操作可能会引发回流和重绘:
引发回流的操作
- 改变 DOM 的几何属性
- 改变 DOM 树的结构(即改变元素在文档流中的位置)
- 获取一些特定的属性值.如 offsetTop、offsetLeft、 offsetWidth、offsetHeight 等.
这里额外提一下,浏览器在回流操作这里做了优化,大意就是维护了一个更新队列,这个更新队列在一定时间后或者当我们获取元素的几何位置属性时会被清空,执行 DOM 操作.所以我们要避免频繁的读写元素的几何位置属性,以免发生布局抖动.
引发重绘的操作
当我们修改元素的像素信息是都会引发重绘的操作,如修改 color,backgroundColor 等属性时.
如何避免回流与重绘
- 避免逐条修改样式,应该使用类名合并样式修改
- 将要修改的 DOM 进行文档流的剥离,我们可以将我们要连续修改多次的 DOM 进行 display:none 属性的修改,这样虽然会造成回流操作,但是避免了多次修改的情况下造成的布局抖动
- 直接越过回流和重绘,将元素提升为合成层,该方法可以参考一下上面讲到的GPU 加速部分.
- 巧妙的使用Micro-Task,微任务会在浏览器的下一帧刷新之前执行避免了多次浏览器刷新操作
总结
介绍到这里,大致理清了浏览器渲染的一个大致流程,我们也可以顺着这个流程去进行一些渲染层面的性能优化.因为篇幅的关系,没有办法详细展开,所以只能是提供一些渲染优化的思路. 这也仅仅是起到抛砖引玉的作用,希望看到这里的大神,如果有兴趣的话能提出自己的建议或者意见.帮助本渣弥补知识上的漏洞,不胜感激.