前言
我又开新坑了,虽然最近烦心事多,但还是要好好学习。这一篇我们来复习浏览器渲染原理和一些优化方法。
进程和线程
先介绍一下线程和进程
- 一个进程就是一个程序的运行实例。启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。进程是操作系统分配资源的基本单位。
- 线程是由进程创建的和管理的,是CPU调度的最小单位
进程和线程有四个特点
- 进程中的任意一线程执行出错,都会导致整个进程的崩溃
- 线程之间共享进程中的数据(线程之间可以对进程的公共数据进行读写操作)
- 当一个进程关闭之后,操作系统会回收进程所占用的内存(即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收)。
- 进程之间的内容相互隔离(进程隔离是为保护操作系统中进程互不干扰的技术,每一个进程只能访问自己占有的数据,也就避免出现进程 A 写入数据到进程 B 的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的)
而浏览器使用的是多进程架构,为什么要使用多进程架构呢
我们可以先了解一下单进程浏览器有什么问题
单进程浏览器
在了解了进程和线程之后,我们再来一起看下单进程浏览器的架构。顾名思义,单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里,这些模块包含了网络、插件、JavaScript 运行环境、渲染引擎和页面等。其实早在 2007 年之前,市面上浏览器都是单进程的。单进程浏览器的架构如下图所示:
如此多的功能模块运行在一个进程里,是导致单进程浏览器不稳定、不流畅和不安全的一个主要因素。下面我就来一一分析下出现这些问题的原因。
不稳定
早期浏览器需要借助于插件来实现诸如 Web 视频、Web 游戏等各种强大的功能,但是插件是最容易出问题的模块,并且还运行在浏览器进程之中,所以一个插件的意外崩溃会引起整个浏览器的崩溃。
除了插件之外,渲染引擎模块也是不稳定的,通常一些复杂的 JavaScript 代码就有可能引起渲染引擎模块的崩溃。和插件一样,渲染引擎的崩溃也会导致整个浏览器的崩溃。
不流畅
从上面的“单进程浏览器架构示意图”可以看出,所有页面的渲染模块、JavaScript 执行环境以及插件都是运行在同一个线程中的,这就意味着同一时刻只能有一个模块可以执行。
比如,下面这个无限循环的脚本:
1 | function freeze() { |
如果让这个脚本运行在一个单进程浏览器的页面里,就会发生很严重的问题,因为这个脚本是无限循环的,所以当其执行时,它会独占整个线程,这样导致其他运行在该线程中的模块就没有机会被执行。因为浏览器中所有的页面都运行在该线程中,所以这些页面都没有机会去执行任务,这样就会导致整个浏览器失去响应,变卡顿。
不安全
这里依然可以从插件和页面脚本两个方面来解释该原因。
插件可以使用 C/C++ 等代码编写,通过插件可以获取到操作系统的任意资源,当你在页面运行一个插件时也就意味着这个插件能完全操作你的电脑。如果是个恶意插件,那么它就可以释放病毒、窃取你的账号密码,引发安全性问题。
至于页面脚本,它可以通过浏览器的漏洞来获取系统权限,这些脚本获取系统权限之后也可以对你的电脑做一些恶意的事情,同样也会引发安全问题。
早期多进程浏览器
这是 2008 年 Chrome 发布时的进程架构。
从图中可以看出,Chrome 的页面是运行在单独的渲染进程中的,同时页面里的插件也是运行在单独的插件进程之中,而进程之间是通过 IPC 机制进行通信(如图中虚线部分)。
我们先看看如何解决不稳定的问题。由于进程是相互隔离的,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面,这就完美地解决了页面或者插件的崩溃会导致整个浏览器崩溃,也就是不稳定的问题。
接下来再来看看不流畅的问题是如何解决的。同样,JavaScript 也是运行在渲染进程中的,所以即使 JavaScript 阻塞了渲染进程,影响到的也只是当前的渲染页面,而并不会影响浏览器和其他页面,因为其他页面的脚本是运行在它们自己的渲染进程中的。所以当我们再在 Chrome 中运行上面那个死循环的脚本时,没有响应的仅仅是当前的页面。对于内存泄漏的解决方法那就更简单了,因为当关闭一个页面时,整个渲染进程也会被关闭,之后该进程所占用的内存都会被系统回收,这样就轻松解决了浏览器页面的内存泄漏问题。
最后我们再来看看上面的两个安全问题是怎么解决的。采用多进程架构的额外好处是可以使用安全沙箱,你可以把沙箱看成是操作系统给进程上了一把锁,沙箱里面的程序可以运行,但是不能在你的硬盘上写入任何数据,也不能在敏感位置读取任何数据,例如你的文档和桌面。Chrome 把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限。
目前多进程架构
相较之前,目前的架构又有了很多新的变化。我们先看看最新的 Chrome 进程架构
从图中可以看出,最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。
下面来介绍这几个进程的功能
- 浏览器进程。启动浏览器就会有这个进程,主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
- 渲染进程。每个页卡都会包含一个,核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。(这个进程里包含GUI渲染线程,JS引擎线程,事件触发线程等)
- GPU 进程。其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
- 网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
- 插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
所以,如果你打开了一个页面,至少会打开四个进程,因为打开 1 个页面至少需要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程,共 4 个;如果打开的页面有运行插件的话,还需要再加上 1 个插件进程。
不过凡事都有两面性,虽然多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样不可避免地带来了一些问题:
- 更高的资源占用
- 更复杂的体系结构
渲染流程
进程调用
从图中可以看出,整个过程需要各个进程之间的配合,所以在开始正式流程之前,我们还是先来快速回顾下浏览器进程、渲染进程和网络进程的主要职责。
- 浏览器进程主要负责用户交互、子进程管理和文件储存等功能。
- 网络进程是面向渲染进程和浏览器进程等提供网络下载功能。
- 渲染进程的主要职责是把从网络下载的 HTML、JavaScript、CSS、图片等资源解析为可以显示和交互的页面。因为渲染进程所有的内容都是通过网络获取的,会存在一些恶意代码利用浏览器漏洞对系统进行攻击,所以运行在渲染进程里面的代码是不被信任的。这也是为什么 Chrome 会让渲染进程运行在安全沙箱里,就是为了保证系统的安全。
这个过程描述如下
- 首先,浏览器进程接收到用户输入的 URL 请求,浏览器进程便将该 URL 转发给网络进程。
- 然后,在网络进程中发起真正的 URL 请求。
- 接着网络进程接收到了响应头数据,便解析响应头数据,并将数据转发给浏览器进程。
- 浏览器进程接收到网络进程的响应头数据之后,发送“提交导航 (CommitNavigation)”消息到渲染进程;
- 渲染进程接收到“提交导航”的消息之后,便开始准备接收 HTML 数据,接收数据的方式是直接和网络进程建立数据管道;
- 最后渲染进程会向浏览器进程“确认提交”,这是告诉浏览器进程:“已经准备好接受和解析页面数据了”。
- 浏览器进程接收到渲染进程“提交文档”的消息之后,便开始移除之前旧的文档,然后更新浏览器进程中的页面状态。
网络请求
DNS查询
在输入一个域名到地址栏后,首先会进行DNS解析,简单来说就是试图把域名转化成IP地址
查询是一个递归查询的过程,顺序如下
1) 浏览器缓存
当用户通过浏览器访问某域名时,浏览器首先会在自己的缓存中查找是否有该域名对应的IP地址(若曾经访问过该域名且没有清空缓存便存在);
2) 系统缓存
当浏览器缓存中无域名对应IP则会自动检查用户计算机系统Hosts文件DNS缓存是否有该域名对应IP;
3) 路由器缓存
当浏览器及系统缓存中均无域名对应IP则进入路由器缓存中检查,以上三步均为客服端的DNS缓存;
4)ISP(互联网服务提供商)DNS缓存
当在用户客服端查找不到域名对应IP地址,则将进入ISP DNS缓存中进行查询。比如你用的是电信的网络,则会进入电信的DNS缓存服务器中进行查找;
5) 根域名服务器
当以上均未完成,则进入根服务器进行查询。全球仅有13台根域名服务器,1个主根域名服务器,其余12为辅根域名服务器。根域名收到请求后会查看区域文件记录,若无则将其管辖范围内顶级域名(如.com)服务器IP告诉本地DNS服务器;
6) 顶级域名服务器
顶级域名服务器收到请求后查看区域文件记录,若无则将其管辖范围内主域名服务器的IP地址告诉本地DNS服务器;
7) 主域名服务器
主域名服务器接受到请求后查询自己的缓存,如果没有则进入下一级域名服务器进行查找,并重复该步骤直至找到正确纪录;
最后服务器和本地会对这个域名进行缓存,以便于以后使用
建立TCP链接
浏览器和服务器之间会通过3次握手建立TCP链接,便于内容传输
内容传输
这里我们使用淘宝作为例子
在最下面的数据反应了该页面的整体加载情况。比如总共有401个请求,3.6M的数据,以及三个时间点,Finish、DOMContentLoaded和Load。
Finish:页面最后一个请求截止的时间,如果页面加载完后,触发了ajax请求,那么该时间会变更。
DOMContentLoaded:dom内容加载并解析完成的时间
Load:页面所有的资源(图片,音频,视频等)加载完成的时间
那什么是dom内容加载完毕呢?我们从打开一个网页说起。当输入一个URL,页面的展示首先是空白的,然后过一会,页面会展示出内容,但是页面的有些资源比如说图片资源还无法看到,此时页面是可以正常的交互,过一段时间后,图片才完成显示在页面。从页面空白到展示出页面内容,会触发DOMContentLoaded事件。而这段时间就是HTML文档被加载和解析完成。
在这里我们可以明确DOMContentLoaded所计算的时间,当文档中没有脚本时,浏览器解析完文档便能触发 DOMContentLoaded 事件;如果文档中包含脚本,则脚本会阻塞文档的解析,而脚本需要等位于脚本前面的css加载完才能执行。在任何情况下,DOMContentLoaded 的触发不需要等待图片等其他资源加载完成。
而在页面上所有的资源(图片,音频,视频等)被加载以后才会触发load事件,简单来说,页面的load事件会在DOMContentLoaded被触发之后才触发。
通俗来讲就是DOMContentLoaded是页面白屏的时间,load是通常所说的页面完全加载完成的时间
在网络请求时,有一个资源优先级的概念
资源的优先级被分为 5 级,不同场景命名略有不同:
- 网络层面, 5 级分别为: Highest 、 Medium 、 Low 、 Lowest 、 Idle ;
- 浏览器内核, 5 级分别为: VeryHigh 、 High 、 Medium 、 Low 、VeryLow ;
- 用户控制台显示, 5 级分别为: Highest 、 High 、 Medium 、 Low 、 Lowest ;
浏览器首先会按照资源默认的优先级确定加载顺序:
- html 、 CSS 、 font 这三种类型的资源优先级最高(Highest);
- 然后是 preload 资源(通过 <link rel=“preload”> 标签预加载)、script 、xhr 请求(High);
- 接着是图片、语音、视频;
- 最低的是prefetch预读取的资源。
然后浏览器会按照如下规则,对优先级进行调整:
- 对于XHR请求资源:将同步 XHR 请求的优先级调整为最高。
- 对于图片资源:会根据图片是否在可见视图之内来改变优先级。图片资源的默认优先级为 Low 。现代浏览器为了提高用户首屏的体验,在渲染时会计算图片资源是否在首屏可见视图之内,在的话,会将这部分视口可见图片 ( Image in viewport ) 资源的优先级提升为 High 。
- 对于脚本资源:浏览器会将根据脚本所处的位置和属性标签分为三类,分别设置优先级。
- 首先,对于添加 defer / async 属性标签的脚本的优先级会全部降为 Low 。
- 然后,对于没有添加该属性的脚本,根据该脚本在文档中的位置是在浏览器展示的第一张图片之前还是之后,又可分为两类。在之前的会被定为High优先级,在之后的会被设置为 Medium 优先级。
我们点开一个请求
这里有几个参数需要关注一下
Queueing
请求排队的时间。因为浏览器与同一个域名建立的TCP连接数是有限制的,chrome是6个,如果说同一时间,发起的同一域名的请求超过了6个,这时候就需要排队了,也就是这个Queueing时间Stalled
是浏览器得到要发出这个请求的指令,到请求可以发出的等待时间,一般是代理协商、以及等待可复用的TCP连接释放的时间,不包括DNS查询、建立TCP连接等时间等DNS Lookup
DNS查询的时间,页面内任何新的域名都需要走一遍 完整的DNS查询过程,已经查询过的则走缓存Initial Connection / Connecting
建立TCP连接的时间,包括TCP的三次握手和SSL的认证SSL
完成ssl认证的时间Request sent/sending
请求第一个字节发出前到最后一个字节发出后的时间,也就是上传时间Waiting
请求发出后,到收到响应的第一个字节所花费的时间(Time To First Byte)Content Download
收到响应的第一个字节,到接受完最后一个字节的时间,就是下载时间
等文档内容下载好了,就可以开始渲染了
内容渲染
按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。
接下来,在介绍每个阶段的过程中,你应该重点关注以下三点内容:
- 开始每个子阶段都有其输入的内容;
- 然后每个子阶段有其处理过程;
- 最终每个子阶段会生成输出内容。
解析HTML,构建DOM树
为什么要构建 DOM 树呢?这是因为浏览器无法直接理解和使用 HTML,所以需要将 HTML 转换为浏览器能够理解的结构——DOM 树
解析html需要先把字符串转化成Token
然后会使用token来生成节点对象,节点对象包含了这个节点的所有属性
在所有token都使用完后,就得到了一颗完整的DOM树
可以在浏览器控制台输入 document
查看网页生成的 DOM 树。
解析CSS,计算样式
把 CSS 转换为浏览器能够理解的结构
浏览器是无法直接理解这些纯文本的 CSS 样式的,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets,可以打开控制台输入 document.styleSheets
查看其结构。
标准化属性值
有一些属性值渲染引擎无法直接理解,那就需要先把值进行一次标准化
比如下面的代码
1 | body { font-size: 2em } |
经过标准化之后会变成这样的内容
计算出 DOM 树中每个节点的具体样式
接下来就需要计算 DOM 树中每个节点的样式属性了
样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程中需要遵守 CSS 的继承和层叠两个规则。这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。
构建布局树
你可能注意到了 DOM 树还含有很多不可见的元素,比如 head 标签,还有使用了 display:none 属性的元素。所以在显示之前,我们还要额外地构建一棵只包含可见元素布局树。
我们结合下图来看看布局树的构造过程:
从上图可以看出,DOM 树中所有不可见的节点都没有包含到布局树中。
浏览器大体上完成了下面这些工作:
- 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中;
- 而不可见的节点会被布局树忽略掉,如 head 标签下面的全部内容,再比如 body.p.span 这个元素,因为它的属性包含 dispaly:none,所以这个元素也没有被包进布局树。
布局计算(Layout)
现在我们有了一棵完整的布局树。那么接下来,就要计算布局树节点的坐标位置了。这一步会计算每个节点的几何信息,即在屏幕上的确切位置和大小,所有相对值都将转换为屏幕上的绝对像素。
分层(Layer)
现在我们有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了?
还没有,我们还需要进行一个分层操作
因为页面中有很多复杂的效果,如一些 3D 变换、页面滚动、Z 轴排序等, 为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树。如果你熟悉 PS,相信你会很容易理解图层的概念,正是这些图层叠加在一起构成了最终的页面图像。
要想直观地理解什么是图层,你可以打开 Chrome 的“开发者工具”,选择“Layers”标签,就可以可视化页面的分层情况,如下图所示:
其中 Compositing Reasons 指出了该图层被独立的原因。
此时我们已经知道,浏览器中的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。图层和布局树节点之间的关系如下图所示:
通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。
那么什么样的节点会创建一个图层呢:
- 拥有层叠上下文属性的元素会被提升为单独的一层(我写过另一篇文章讲层叠上下文)
- 需要剪裁(clip)的地方也会被创建为图层
什么叫需要裁减的地方呢,
1 |
|
此处, div
元素的大小被限制为 200*200,其子元素的内容较多,超过了父元素的大小,需要渲染引擎将文字内容的一部分显示在div区域。 出现裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。
总之,元素有了层叠上下文的属性或者需要被剪裁,满足这任意一点,就会被提升成为单独一层。
PS:这里的分层和合成层的分层(硬件加速)不一样,详情可见浏览器层合成与页面渲染优化
图层绘制(Paint)
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,那么接下来我们看看渲染引擎是怎么实现图层绘制的?
试想一下,如果给你一张纸,让你先把纸的背景涂成蓝色,然后在中间位置画一个红色的圆,最后再在圆上画个绿色三角形。你会怎么操作呢?
通常,你会把你的绘制操作分解为三步:
- 绘制蓝色背景;
- 在中间绘制一个红色的圆;
- 再在圆上绘制绿色三角形。
渲染引擎实现图层的绘制与之类似,会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:
从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。
你也可以打开“开发者工具”的“Layers”标签,选择“document”层,来实际体验下绘制列表,如下图所示:
在该图中,区域 1 就是 document 的绘制列表,拖动区域 2 中的进度条可以重现列表的绘制过程。
绘制阶段其实并不是真正地绘出图片,而是将绘制指令组合成一个列表,比如一个图层要设置的背景为黑色,并且还要在中间画一个圆形,那么绘制过程会生成|Paint BackGroundColor:Black | Paint Circle|这样的绘制指令列表,绘制过程就完成了。
栅格化(Raster)操作
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。
光栅化就是按照绘制列表中的指令生成图片。
你可以结合下图来看下渲染主线程和合成线程之间的关系:
如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程,那么接下来合成线程是怎么工作的呢?
那我们得先来看看什么是视口,你可以参看下图:
通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。
在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。
基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512,如下图所示:
然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:
从图中可以看出,渲染进程把生成图块的指令发送给 GPU,然后在 GPU中执行生成图块的位图,并保存在 GPU 的内存中。
合成和显示
一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。
浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
到这里,经过这一系列的阶段,编写好的 HTML、CSS、JavaScript 等文件,经过浏览器就会显示出漂亮的页面了。
总结
用一张图来总结下这整个渲染流程就是
一个完整的渲染流程大致可总结为如下:
- 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构。
- 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
- 创建布局树,并计算元素的布局信息。
- 对布局树进行分层,并生成分层树。
- 为每个图层生成绘制列表,并将其提交到合成线程。
- 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
- 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。
- 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。
重排重绘和合成的区别
重排
如果你通过 JavaScript 或者 CSS 修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的。
重排会在什么时候发生:
- 修改或删除可见的DOM元素
- 元素位置改变
- 元素尺寸改变(外边距,内边距,边框厚度,宽度,高度等属性改变)
- 内容改变(文本改变,图片被另一个不同尺寸的图片替代)
- 页面渲染器初始化
- 浏览器窗口改变
根据改变的范围和程度,渲染树中或大或小的部分也需要重新计算,而有些改变会触发整个页面的重排,例如浏览器滚动条出现时
事实上,因为每次重排都会产生计算消耗,大多数浏览器通过队列化修改并批量执行来优化重排过程,然而,你可能会(经常不知不觉)强制刷新队列并要求计划任务立刻执行。
一些获取布局信息的操作会导致队列刷新,比如以下方法
- offsetTop,offsetLeft,offsetWidth,offsetHeight
- scrollTop,scrollLeft,scrollWidth,scrollHeight
- clientTop,clientLeft,clientWidth,clientHeight
- getComputedStyle() (currentStyle in IE)
以上属性和方法需要返回最新的布局信息,以此浏览器不得不执行渲染队列中的“待处理变化”并触发重排以返回正确的值
在修改样式的过程中,最好避免使用上面列出的属性。它们都会刷新渲染队列,即使你是在获取最近未改变的或者与最新改变无关的布局信息
如果一定要使用,尽量把访问之后得到的结果缓存起来,尤其避免在循环中使用它们
重绘
接下来,我们再来看看重绘,比如通过 JavaScript 更改某些元素的背景颜色,渲染流水线会怎样调整呢?你可以参考下图:
如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。
合成
那如果你更改一个既不要布局也不要绘制的属性,会发生什么变化呢?渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。具体流程参考下图:
直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。
详细点解释就是:
你可以把一张网页想象成是由很多个图片叠加在一起的,每个图片就对应一个图层,Chrome 合成器最终将这些图层合成了用于显示页面的图片。
如果你熟悉 PhotoShop 的话,就能很好地理解这个过程了,PhotoShop 中一个项目是由很多图层构成的,每个图层都可以是一张单独图片,可以设置透明度、边框阴影,可以旋转或者设置图层的上下位置,将这些图层叠加在一起后,就能呈现出最终的图片了。在这个过程中,将素材分解为多个图层的操作就称为分层,最后将这些图层合并到一起的操作就称为合成。所以,分层和合成通常是一起使用的。
考虑到一个页面被划分为两个层,当进行到下一帧的渲染时,上面的一帧可能需要实现某些变换,如平移、旋转、缩放、阴影或者 Alpha 渐变,这时候合成器只需要将两个层进行相应的变化操作就可以了,显卡处理这些操作驾轻就熟,所以这个合成过程时间非常短。
为什么合成没有重绘阶段,绘制列表不会改变,但是得到的位图是不一样的
首先,能直接在合成线程中完成的任务都不会改变图层的内容,如文字信息的改变,布局的改变,颜色的改变,统统不会涉及,涉及到这些内容的变化就要牵涉到重排或者重绘了。
能直接在合成线程中实现的是整个图层的几何变换,透明度变换,阴影等,这些变换都不会影响到图层的内容。
比如说transform,我们只需要在合成线程里,把图层整体进行转化,然后和其他层合成就可以了,最终生成的图片就可以作为新的一帧来输出了
使用合成优化代码
通过上面的介绍,相信你已经理解了渲染引擎是怎么将布局树转换为漂亮图片的,理解其中原理之后,你就可以利用分层和合成技术来优化代码了。
在写 Web 应用的时候,你可能经常需要对某个元素做几何形状变换、透明度变换或者一些缩放操作,如果使用 JavaScript 来写这些效果,会牵涉到整个渲染流水线,所以 JavaScript 的绘制效率会非常低下。
这时你可以使用 will-change 来告诉渲染引擎你会对该元素做一些特效变换,CSS 代码如下:
1 | .box { |
这段代码就是提前告诉渲染引擎 box 元素将要做几何变换和透明度变换操作,这时候渲染引擎会将该元素单独实现一帧,等这些变换发生时,渲染引擎会通过合成线程直接去处理变换,这些变换并没有涉及到主线程,这样就大大提升了渲染的效率。这也是 CSS 动画比 JavaScript 动画高效的原因。
所以,如果涉及到一些可以使用合成线程来处理 CSS 特效或者动画的情况,就尽量使用 will-change 来提前告诉渲染引擎,让它为该元素准备独立的层。但是凡事都有两面性,每当渲染引擎为一个元素准备一个独立层的时候,它占用的内存也会大大增加,因为从层树开始,后续每个阶段都会多一个层结构,这些都需要额外的内存,所以你需要恰当地使用 will-change。
总结
这三种方式的渲染路径是不同的,通常渲染路径越长,生成图像花费的时间就越多。
重排,它需要重新根据 CSSOM 和 DOM 来计算布局树,它会让整个渲染流水线的每个阶段都执行一遍,如果布局复杂的话,就很难保证渲染的效率了。
重绘因为没有了重新布局的阶段,操作效率稍微高点,但是依然需要重新计算绘制信息,并触发绘制操作之后的一系列操作。
而相较于重排和重绘,合成操作的路径就显得非常短了,并不需要触发布局和绘制两个阶段,如果采用了 GPU,那么合成的效率会非常高。
Demo
1 |
|
在Layers -> Rendering可以看到
中间的盒子没有被绿色标识(使用transform单独提升到了一个层),证明这一块是直接进行合成的
上面的盒子和下面的盒子的字体部分,都被绿色标识了,这两块都需要重新绘制
如果页面中只有中间的盒子,可以观察到动画过程中是没有Layout和Paint的
如果页面中只有下面的盒子,会进行重绘
CSS和JS是对页面加载的影响
我们假设有下面的代码
1 |
|
1 | div { |
那么最后的页面渲染依次是这样的
其中最左边的是之前页面的结果(为了更方便对比我修改了背景颜色和文字)
中间是页面开始重新渲染的样子,可以看到浏览器是边解析边渲染的
右边是CSS解析完,重新生成Layout Tree,页面重绘之后的样子
事实上,
- 浏览器一边下载 HTML 网页,一边开始解析
- CSS加载和解析,不阻塞DOM的解析,但是生成Layout Tree需要等待CSS解析。
- CSS解析不阻塞JS的加载,但是会阻塞JS的执行(JS执行需要依托于前面所有的CSS执行完)。
- JS执行会阻塞DOM的生成,也就是会阻塞页面的渲染(JS执行会让页面渲染和HTML的解析和DOM树的生成)。
- 浏览器是边解析边渲染的,如果CSS放在最后,就可能要导致大规模的重绘
加载外部脚本时,浏览器会暂停页面渲染,等待脚本下载并执行完成后再继续渲染。
所以我们建议,把CSS放在head中,把JS放在body的最底端
当然,我们还可以使用async和defer
如果设置了 async 属性,会并行加载脚本文件并执行,下载时不会阻塞 HTML 的解析,但是脚本执行的时候会阻塞 HTML 的解析。如果没有设置 async 属性,但是设置了 defer 属性,也会并行加载脚本文件,但是会等到页面完成解析再去执行。
另外要注意的是
- defer 和 async 只对外部加载的脚本有效果,
<script>
包含的 JavaScript 代码块无效。 - defer 对 module 脚本是无效的,但是 async 是有效的
- 如果同时设置了 defer 和 async 为 true, 以 defer 为准
优化建议
图片优化
不同的图片格式有不同的特点和优势,我们来了解一下
jpg
:适合色彩丰富的照片、banner图;不适合图形文字、图标(纹理边缘有锯齿),不支持透明度png
:适合纯色、透明、图标,支持半透明;不适合色彩丰富图片,因为无损存储会导致存储体积大gif
:适合动画,可以动的图标;不支持半透明,不适和存储彩色图片webp
:适合半透明图片,可以保证图片质量和较小的体积svg
格式图片:相比于jpg
和jpg
它的体积更小,渲染成本过高,适合小且色彩单一的图标;
那我们平时可以使用什么来优化图片呢
- 避免空
src
的图片(会发送多余的请求) - 减小图片尺寸
img
标签设置alt
属性, 提升图片加载失败时的用户体验- 原生的
loading:lazy
图片懒加载
1 | <img loading="lazy" src="./images/1.jpg" width="300" height="450" /> |
- 不同环境下,加载不同尺寸和像素的图片
1 | <img src="./images/1.jpg" sizes="(max-width:500px) 100px,(max-width:600px) 200px" srcset="./images/1.jpg 100w, ./images/3.jpg 200w"/> |
- 对于较大的图片可以考虑采用渐进式图片
- 采用
base64URL
减少图片请求 - 采用雪碧图合并图标图片等
HTML优化
- 语义化
HTML
: 代码简洁清晰,利于搜索引擎,便于团队开发 - 提前声明字符编码,让浏览器快速确定如何渲染网页内容
- 减少HTML嵌套关系、减少DOM节点数量
- 删除多余空格、空行、注释、及无用的属性等
- HTML减少
iframes
使用 (iframe
会阻塞onload
事件可以动态加载iframe
) - 避免使用table布局
CSS优化
- 减少伪类选择器、减少样式层数、减少使用通配符
- 避免使用
CSS
表达式,CSS
表达式会频繁求值, 当滚动页面,或者移动鼠标时都会重新计算 (IE6,7
) 详情可以看这里 - 删除空行、注释、减少无意义的单位、
css
进行压缩 - 使用外链
css
,可以对CSS
进行缓存 - 添加媒体字段,只加载有效的
css
文件 CSS contain
属性,将元素进行隔离
1 | <link href="index.css" rel="stylesheet" media="screen and (min-width:1024px)" /> |
- 减少@import使用,因为@import采用的是串行加载
JS
通过
async
、defer
异步加载文件减少DOM操作,缓存访问过的元素
操作不直接应用到DOM上,而应用到虚拟DOM上。最后一次性的应用到DOM上。
使用
webworker
,避免在主线程进行大规模运算使用
IntersectionObserver
动态加载DOM或者图片1
2
3
4
5
6
7
8
9
10
11
12
13
14
15const observer = new IntersectionObserver(function(changes) {
changes.forEach(function(element, index) {
if (element.intersectionRatio > 0) {
observer.unobserve(element.target);
element.target.src = element.target.dataset.src;
}
});
});
function initObserver() {
const listItems = document.querySelectorAll('img');
listItems.forEach(function(item) {
observer.observe(item);
});
}
initObserver();使用虚拟列表优化长列表
requestAnimationFrame
、requestIdleCallback
尽量避免使用
eval
, 消耗时间久使用事件委托,减少事件绑定个数。
尽量使用canvas动画、
CSS
动画
其他
- 减少首屏加载的资源个数和大小
- 使用预渲染或者SSR
- 少使用Cookie,减少网络传输的内容