浏览器渲染原理

浏览器渲染流程

首先,浏览器在渲染页面之前需要先构建DOM(文档对象模型)树和CSSOM(CSS 对象模型)树,然后将 DOM 和 CSSOM 这两个树合并成一个渲染树,浏览器通过 Render 树计算每个节点的信息,最终将各个节点绘制到屏幕上。

DOM 和 CSSOM 怎么来的

事实上浏览器通过网络接收到的不过是一长串比特而已,如何将其构建成对象模型呢?

构建 DOM 和 CSSOM 的步骤分为五步:

Bytes(字节) ➡ Characters(字符) ➡ Tokens(令牌) ➡ Nodes(节点) ➡ DOM/CSSOM(对象模型)

Notice

DOM 和 CSSDOM 是独立的数据结构,因此这两个树不是一起生成的,但生成这两个树的步骤是相同的!

有下面一段代码,看看浏览器是如何转化的:

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="style.css" rel="stylesheet" />
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg" /></div>
  </body>
</html>

用一幅图解释转化的五个步骤:

full-process.png

  1. 字节转换为字符:浏览器从磁盘或网络读取 HTML 原始字节,并根据文件的指定编码(如 UTF-8)将他们转换成各个字符。
  2. Token 化/词法分析:浏览器将字符转换成 W3C HTML5 规定的各种 Token,每一个 Token 都有特殊的含义和规则。
  3. 语法分析:在转化为节点之前,语法分析器会对 Tokens 进行遍历,判断这些组合是否符合 HTML 语法,如果有语法错误就会抛出,停止解析,网页上自然是一片空白。
  4. 从 Token 到节点:将 Token 转化成包含其属性和规则的对象。
  5. DOM 构建:将词法分析后生成的对象生成一个树形结构。

如果没有为页面中的元素赋予样式,浏览器会赋予每个元素默认的样式(User Agent)。

但是通常页面中都会包含 link 或 style 标签,因此浏览器需要将 CSS 字节通过上面几个步骤转换成 CSSOM 树,如下图所示:

cssom-tree.png

Token 是什么

Token 是编译原理里的一个术语,它表示最小的有意义的单元。我们来看看一个非常标准的标签,会如何被拆分。

<p class="a">text</p>

根据规则,上面的代码会解析成:

Token含义
<p"起始标签"开始
class="a"属性
>"起始标签"结束
text文本节点
</p>结束标签

渲染树

知道了 DOM 和 CSSOM 是如何生成的还不够,因为这不是最终的数据,浏览器还会把这两个树合并成一个渲染树。

渲染树只包含了渲染网页所需的节点,如果这个元素样式设置为display:none<head></head>这种不可见的标签是不包含在渲染树中的。

Notice

visibility:hidden 虽然不可见,但该元素仍然占据页面的一部分,所以它包含在渲染树中

浏览器会计算渲染树里每一个元素的 layout(布局),也可以叫做回流,一般情况下仅需执行一次流处理方法便可以计算所有元素的布局(对于 table 元素需要计算多次)

阻塞渲染

DOM 解析和 CSS 解析是两个并行的进程,所以这也解释了为什么 CSS 加载不会阻塞 DOM 的解析。

默认情况下,CSS 被视为阻塞渲染的资源,在 CSSOM 树构建完毕之前,浏览器不会渲染任何内容。

我们常常使用 link 标签引入外部样式,这就会导致渲染阻塞,浏览器会优先处理这些资源,如果网络较慢,很可能会出现一段时间的白屏

在适配设备的时候,我们常常用到媒体类型或媒体查询,为不同的设备匹配不同的样式,这个时候我们不需要一次性将所有样式全部引入:

<link href="style.css" rel="stylesheet" />
<link href="print.css" rel="stylesheet" media="print" />
<link href="other.css" rel="stylesheet" media="(min-width: 40em)" />

如上面代码所示,第一个样式是默认引入的,它始终会阻塞渲染;第二个样式只有在打印的时候才会引入,页面初始加载的时候不会阻塞渲染;第三个样式表明浏览器执行的媒体查询符合条件的时候才会阻塞渲染。

除了 style 和 link,JS 同样可以修改样式,因此 JS 文件或 inline JS 的加载同样会阻塞 DOM 和 CSSOM 的 构建,进而阻塞渲染。

script 脚本在 html 文档中的位置十分重要,HTML 解析器解析到 script 脚本便会暂停 DOM 的构建,将控制权移交给 JavaScript 引擎直到脚本完成执行,再进行 DOM 构建,这延缓了页面的首次渲染。

如果 CSSOM 的构建过程中遇到了 script 脚本,并不会中断 CSSOM 的构建,这是因为 script 可以更改样式,也就是说 script 是可以访问 CSSOM 的,因此必须在构建完成 CSSOM 的情况下再执行 script

如果 JS 文件中,没有对 DOM 节点及其样式进行操作,可以将 script 声明为异步以避免阻塞:

<script src="app.js" async></script>

回流和重绘

回流

当渲染树中部分或全部元素尺寸,结构或某些属性发生过改变时,浏览器重新渲染部分或全部文档的过程称为回流。

会导致回流的操作如下:

  • 页面首次渲染。
  • 浏览器窗口大小发生改变。
  • 元素尺寸或位置发生改变,包括定位属性及浮动也会触发回流。
  • 元素内容变化(文字数量,比如用户在 input 框中输入文字或图片大小等等)。
  • 元素字体大小变化。
  • 添加或者删除可见的 DOM 元素。
  • 激活 CSS 伪类(例如::hover)。
  • 查询某些属性或调用某些方法。

还有一些会导致回流的属性和方法:

  • clientWidthclientHeightclientTopclientLeft
  • offsetWidthoffsetHeightoffsetTopoffsetLeft
  • scrollWidthscrollHeightscrollTopscrollLeft
  • scrollIntoView()scrollIntoViewIfNeeded()
  • getComputedStyle()
  • getBoundingClientRect()
  • scrollTo()

重绘

当页面中元素样式的改变并不影响它在文档流中的位置时(例如:colorbackground-colorvisibility 等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。

透明度不会触发重绘

需要注意的是,透明度改变后,GPU 在绘画时只是简单的降低之前已经画好的纹理的 alpha 值来达到效果,并不需要整体的重绘。不过这个前提是这个被修改 opacity 本身必须是一个图层,如果图层下还有其他节点,GPU 也会将他们透明化。

浏览器所做的优化

浏览器会维护一个渲染队列,把所有引起回流和重绘的操作放入队列中,如果队列中的任务数量或者时间间隔达到一个阈值的,浏览器就会将队列清空,进行一次批处理,这样可以把多次回流和重绘变成一次。

当你访问以下属性或方法时,浏览器会立刻清空队列:

  • clientWidthclientHeightclientTopclientLeft
  • offsetWidthoffsetHeightoffsetTopoffsetLeft
  • scrollWidthscrollHeightscrollTopscrollLeft
  • widthheight
  • getComputedStyle()
  • getBoundingClientRect()

因为队列中可能会有影响到这些属性或方法返回值的操作,即使你希望获取的信息与队列中操作引发的改变无关,浏览器也会强行清空队列,确保你拿到的值是最精确的。

训练

下列代码触发了几次回流和重绘?

let el = document.getElementById('app')
el.style.width = el.offsetWidth + 1 + 'px'
el.style.width = 1 + 'px'

只会触发一次回流和重绘,因为在执行 el.offsetWidth + 1 的时候,渲染队列本身就是空的,不需要清空,因此也就不需要进行重绘和回流。

所以说只有在代码执行完毕后才会进行一次回流和重绘。

css3 硬件加速(GPU 加速)

使用 css3 硬件加速,可以让 transformopacityfilters 这些动画不会引起回流重绘。

对于动画的其它属性,比如 background-color 这些,还是会引起回流重绘的,不过它还是可以提升这些动画的性能。

图层

浏览器在渲染一个页面时,会将页面分为很多个图层,图层有大有小,每个图层上有一个或多个节点。

在渲染 DOM 的时候,浏览器所做的工作实际上是:

  1. 获取 DOM 后分割为多个图层
  2. 对每个图层的节点计算样式结果(Recalculate style--样式重计算)
  3. 为每个节点生成图形和位置(Layout--回流和重布局)
  4. 将每个节点绘制填充到图层位图中(Paint Setup 和 Paint--重绘)
  5. 图层作为纹理上传至 GPU
  6. 符合多个图层到页面上生成最终屏幕图像(Composite Layers--图层重组)

渲染层合并

渲染层合并(Composite layouts)发生在回流与重绘之后,对页面中 DOM 元素的绘制是在多个层上进行的

在 DOM 树中每个节点都会对应一个 LayoutObject,当他们的 LayoutObject 处于相同的坐标空间时,就会形成一个 RenderLayers ,也就是渲染层。在每个层上完成绘制过程之后,浏览器会将所有层按照合理的顺序合并成一个图层,然后显示在屏幕上。对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常。

某些特殊的渲染层会被认为是合成层(Compositing Layers),合成层拥有单独的 GraphicsLayer(图形层),而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 父层共用一个。

满足下列任意情况,元素将会获得自己的层:

  • 3D 或透视变换(perspective transform) CSS 属性。
  • 使用加速视频解码的 <video> 元素 拥有 3D。
  • (WebGL) 上下文或加速的 2D 上下文的 <canvas> 元素。
  • 混合插件(如 Flash)。
  • opacity 属性。
  • 对自己的 opacity 做 CSS 动画或使用一个动画变换的元素。
  • 拥有 filtermask 的元素。
  • 它具有明确定义为位置属性(positiontransform)。
  • 元素有一个包含复合层的后代节点(换句话说,就是一个元素拥有一个子元素,该子元素在自己的层里)。
  • 元素有一个 z-index 较低且包含一个复合层的兄弟元素(换句话说就是该元素在复合层上面渲染)。
  • 具有 will-change 属性。
  • overflow 不为 visible

DOM 节点和渲染对象是一一对应的,满足以上条件的渲染对象就能拥有独立的渲染层。当然这里的独立是不完全准确的,并不代表着它们完全独享了渲染层,由于不满足上述条件的渲染对象将会与其第一个拥有渲染层的父元素共用同一个渲染层,因此实际上,这些渲染对象会与它的部分子元素共用这个渲染层。

一旦 RenderLayer 提升为了合成层就会有自己的绘图上下文,并且会开启硬件加速,有利于性能提升。

那么一个渲染层如何提升为合成层呢?有如下几个条件:

  • 3D transforms: translate3dtranslateZ等。
  • videocanvasiframe 等元素。
  • 通过 Element.animate() 实现的 opacity 动画转换。
  • 通过 СSS 动画实现的 opacity 动画转换。
  • position: fixed
  • 具有 will-change 属性。
  • opacitytransformfilterbackdrop-filter 应用了 animation 或者 transition

元素提升为合成层后,transformopacity 才不会触发重绘,如果不是合成层,则其依然会触发重绘。

在 Blink 和 WebKit 内核的浏览器中,对于应用了 transition 或者 animationopacity 元素,浏览器会将渲染层提升为合成层。也可以使用 translateZ(0) 或者 translate3d(0,0,0) 来人为地强制性地创建一个合成层。

隐式合成

在浏览器的 Composite 阶段,还存在一种隐式合成,部分渲染层在一些特定场景下,会被默认提升为合成层。一个或多个非合成元素应出现在堆叠顺序上的合成元素之上,被提升到合成层。

比如:两个 absolute 定位的 div 在屏幕上交叠了,根据 z-index 的关系,其中一个 div 就会"盖在"了另外一个上边。

这个时候,如果下层的 div 被加上了 translateZ(0) 等会使它提升为合成层的属性,那么合成层会位于原本图层的上方,如果不把与之相层叠的 div 也提升为合成层,那么层级关系就会出现混乱。这就叫做隐式合成。

层级压缩

我们知道了隐式合成,那么来思考一个问题,如果一个 div1 被提升为合成层,有一个 div2 和它重叠,隐式地变为合成层,然后又有一个元素 div3 叠在 div2 上,也被提升为合成层。。。以此类推,是不是会导致许多的合成层呢?

其实不会,浏览器的层压缩机制,会将隐式合成的多个渲染层压缩到同一个 GraphicsLayer 中进行渲染,也就是说,上方的几个 div 最终会处于同一个合成层中,这就是浏览器的层压缩。

渲染流程

因此浏览器渲染的大致流程可以分为以下几个步骤:

  1. 解析 html 代码建立 DOM 树。
  2. 解析 css 代码建立 CSSOM 树。
  3. DOM 与 CSSOM 合并成 Render 树。
  4. Layout/Reflow 负责各元素尺寸,位置的计算。
  5. Paint 绘制 Render 树。
  6. Composite Layers 浏览器把生成的 BitMap(位图)传输到 GPU,GPU 会将各层合成,显示在屏幕上,渲染完毕就执行 load 事件了。

如何优化渲染速度

减少页面首次渲染

  1. 使用 async 或 defer 将一些不必要的 script 延迟执行
<script src="app1.js" async></script>
<script src="app2.js" defer></script>

使用 async 可以将脚本延迟执行(仅适用于外部脚本),但注意:如果引入了多个异步脚本,不要认为这些脚本会按照加载的顺序执行,顺序是随机的。

defer 属性如果是多个脚本,可以确保脚本按照加载顺序执行。

deferasync 的区别在于脚本需要等到完全被加载和解析完成之后 (DOMContentLoaded 事件被触发前)执行,而 async 脚本的执行和文档解析是同步的,当然,这只是在 script 脚本下载的时候不会阻塞 DOM 构建,在代码执行的时候照样会阻塞,看看这张图就可以彻底明白了:

2151798436-59da4801c6772_articlex.png

  1. 如果 css 涉及到屏幕适配,在 link 标签内添加媒体查询以避免引入不必要的样式
<link href="style.css" rel="stylesheet" />
<link href="print.css" rel="stylesheet" media="print" />
<link href="other.css" rel="stylesheet" media="(min-width: 40em)" />
  1. 降低 CSS 选择器的复杂性

不要过多的使用伪类:

.box:nth-last-child(-n + 1) .title {
  /* styles */
}

浏览器在解析的时候需要询问:“是否有一个 title 类元素,它的父元素正好是负第 N+1 个并且类名为 box 的元素”,应该知道的是:要想知道元素是否为最后一个元素,必须先知道其他元素的所有情况,如果可以使用一个类名或者 id 代替,就不要使用过于这些复杂的伪类,浏览器可能要花大量的时间去计算。

减少回流和重绘

  • 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局)。
  • 不要频繁的设置节点 style 属性值(因为这会涉及到计算,所以会引发回流)。
  • 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局(对于 table,可能要进行多次计算才能够完成布局)。
  • CSS 选择符从右往左匹配查找,避免节点层级过多。
  • 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
  • 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点。比如对于 video 标签来说,浏览器会自动将该节点变为图层。
  • lefttop 会触发回流,修改时的代价相当大。取而代之的更好方法是使用 translate,这个不会触发回流。

当我们需要对 DOM 做一系列修改的时候,可以通过以下步骤减少回流次数:

  1. 使元素脱离文档流
  2. 对其进行多次修改。
  3. 将元素带回到文档中。

该过程的第一和第三步可能会引发回流,但第二步,在元素已经脱离文档流的情况下,对 DOM 的所有修改都不会引起其他元素的回流重绘。

Notice

需要注意的是,我说的是不会引起其他元素的回流重绘,通过查看该例子open in new window可以看到,position:absolute 仍然会引发回流,但由于它已经脱离文档流,因此只会对元素本身进行回流

有三种方式使元素脱离文档流:

  • 隐藏元素,应用修改,重新显示。
  • 使用文档片段(document fragment)在当前 DOM 之外构建一个子树,再把它拷贝回文档。
  • 将原始元素拷贝到一个脱离文档的节点中,修改节点后,再替换原始的元素。

对于复杂的动画效果,我们可以为元素设置 absolute/fixed 定位来让其脱离文档流。

除了上面说的几种优化方案,还有一些 CSS 属性可以很好的进行优化:

contain

contain 属性允许我们指定特定的 DOM 元素和它的子元素,让它们能够独立于整个 DOM 树结构之外。目的是能够让浏览器有能力只对部分元素进行重绘、重排,而不必每次都针对整个页面。

.selector {
  /* 表示元素将正常渲染,没有包含规则。 */
  contain: none;
  /* 表示这个元素的尺寸计算不依赖于它的子孙元素的尺寸。 */
  contain: size;
  /* 表示元素外部无法影响元素内部的布局,反之亦然。 */
  contain: layout;
  /* 表示那些同时会影响这个元素和其子孙元素的属性,都在这个元素的包含范围内。 */
  contain: style;
  /* 表示这个元素的子孙节点不会在它边缘外显示。如果一个元素在视窗外或因其他原因导致不可见,则同样保证它的子孙节点不会被显示。 */
  contain: paint;
  /* 表示除了 style 外的所有的包含规则应用于这个元素。等价于 contain: size layout paint。 */
  contain: strict;
  /* 表示这个元素上有除了 size 和 style 外的所有包含规则。等价于 contain: layout paint。 */
  contain: content;
}

对于每个属性的详细作用,请看:CSS 新特性 contain,控制页面的重绘与重排open in new window

will-change

will-change 为 web 开发者提供了一种告知浏览器该元素会有哪些变化的方法,这样浏览器可以在元素属性真正发生变化之前提前做好对应的优化准备工作。 这种优化可以将一部分复杂的计算工作提前准备好,使页面的反应更为快速灵敏。

浏览器对渲染的优化

浏览器本身会尽可能地减少回流和重绘的次数,只更改必要的元素。比如 absolute/fixed positioned 元素的大小更改仅仅影响元素本身及其后代,而 static 元素的更改会触发所有后续元素的回流。

在执行一段 js 代码时,浏览器会缓存所做的更改,并在代码运行后一次通过这些更改:

var $body = $('body')
$body.css('padding', '1px') // 回流,重绘
$body.css('color', 'red') // 重绘
$body.css('margin', '2px') // 回流,重绘

浏览器不会在每次改变样式的时候就进行重绘和回流,而是在代码执行后,因此只会执行一次回流和重绘。

在下面这种情况下浏览器会进行两次回流:

var $body = $('body')
$body.css('padding', '1px')
$body.css('padding') // 读取元素属性,触发强制回流
$body.css('color', 'red')
$body.css('margin', '2px')

在获取元素 padding 属性的时候,浏览器需要进行一次回流以保证数值的精确性,我们其实可以利用这个特性实现一些功能。

定义如下两个 div

<div id="div1" class="transition"></div>
<div id="div2" class="transition"></div>

定义样式:

div {
  width: 100px;
  height: 50px;
  background-color: cornflowerblue;
  margin-top: 10px;
}
.transition {
  transition: width 0.5s ease;
}

然后为两个 div 添加鼠标移入移出事件:

const $ = (str) => document.querySelector(str)
$('#div1').onmouseenter = function () {
  this.className = ''
  this.style.width = '150px'
  this.className = 'transition'
  this.style.width = '80px'
  this.onmouseleave = function () {
    this.style.width = '100px'
  }
}
$('#div2').onmouseenter = function () {
  this.className = ''
  this.style.width = '150px'
  console.log(this.offsetHeight) // 获取offsetHeight,触发强制回流
  this.className = 'transition'
  this.style.width = '80px'
  this.onmouseleave = function () {
    this.style.width = '100px'
  }
}

效果如图:

GIF.gif

我们可以看到 div2 由于触发了强制回流,浏览器将 width = '150px' 效果渲染了出来,而 div1 则没有。

我们再来看例子 2:

:::: tabs ::: tab HTML

<div class="div">div</div>
<button id="btn">出现</button>

::: ::: tab CSS

.div {
  width: 200px;
  height: 200px;
  background: red;
  display: none;
}

::: ::::

我们现在想实现一个点击按钮,然后显示 div,并把 div 使用动画向右边移动 200px

const elBtn = document.querySelector('button')
const elDiv = document.querySelector('div')

elBtn.addEventListener('click', (e) => {
  elDiv.style.display = 'block'
  elDiv.style.transition = 'transform 0.3s ease'
  elDiv.style.transform = 'translateX(200px)'
})

但我们既然已经了解了浏览器的渲染原理就知道这样是不可行的,因为浏览器会将 display: blocktransform: translateX(200px) 这些任务一起渲染,因此根本不会产生过渡效果,div 是直接被渲染到离默认位置 200px 处的:

因此我们需要访问一个会导致渲染队列清空的 API:

const elBtn = document.querySelector('button')
const elDiv = document.querySelector('div')

elBtn.addEventListener('click', (e) => {
  elDiv.style.display = 'block'
  elDiv.style.transition = 'transform 0.3s ease'
  console.log(elDiv.offsetWidth)
  elDiv.style.transform = 'translateX(200px)'
})






 


就实现过渡效果啦:

参考文章