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