16 KiB
Raw Blame History

1. 浏览器渲染流程

本文主要包含以下内容:

  • 浏览器渲染整体流程
  • DOM 树的形成
  • CSSOM 树的形成
  • 生成渲染树
  • 阻塞渲染
  • 重绘和回流
    • 现代浏览器的优化机制
    • 减少回流和重绘的方式
  • 一道常见的面试题

浏览器渲染整体流程

整个页面可以看做是一幅画,这幅画是由浏览器绘制出来的,浏览器绘制这幅画的过程称之为渲染。

渲染是一件复杂的工作,它大致分为以下几个过程:

  1. 解析 HTML,生成 DOM 树,解析 CSS,生成样式规则树
  2. DOM 树和样式规则树结合,生成渲染树( Render Tree
  3. 根据生成的渲染树,确定元素的布局信息(元素的尺寸、位置),这一步称之为 reflow,译作回流或重排
  4. 根据渲染树和布局信息,生成元素的像素信息(元素横纵的像素点,左上角的偏移量、每个像素的颜色等)。这一步称之为 repaint,译作重绘
  5. 将像素信息提交到 GPU 完成屏幕绘制

当元素的布局信息发生变化时,会导致回流。当元素的像素信息发生变化时,会导致重绘。回流一定会导致重绘,因为布局信息的变化一定会导致像素信息的变化。

在实际开发中,获取和设置元素尺寸、位置均会导致回流和重绘,而仅设置元素的外观(比如背景颜色)则只会导致重绘,不会导致回流。

回流是一项繁琐的工作,会降低效率,因此在开发中,应该尽量避免直接获取和设置元素的尺寸、位置,尽量使用变量来保存元素的布局信息。

下面,我们将具体来看一下浏览器在渲染页面时每一个步骤。

DOM 树的形成

当我们打开一个网页时,浏览器都会去请求对应的 HTML 文件。虽然平时我们写代码时都会分为 HTML、CSS、JS 文件,也就是字符串,但是计算机硬件是不理解这些字符串的,所以在网络中传输的内容其实都是 01 这些字节数据。

当浏览器接收到这些字节数据以后,它会将这些字节数据转换为字符串,也就是我们写的代码。

image-20211120171348433

当数据转换为字符串以后,浏览器会先将这些字符串通过词法分析转换为标记( token ),这一过程在词法分析中叫做标记化( tokenization )。

那么什么是标记呢?

这其实属于编译原理这一块的内容了。简单来说,标记还是字符串,是构成代码的最小单位。这一过程会将代码分拆成一块块,并给这些内容打上标记,便于理解这些最小单位的代码是什么意思。

image-20211120171408897

当结束标记化后,这些标记会紧接着转换为 DOM 节点,之后所有的 DOM 节点会根据彼此之间的关系形成一颗 DOM 节点树。

image-20211120171428854

以上就是浏览器从网络中接收到 HTML 文件然后一系列的转换过程。

image-20211120171451336

CSSOM 树的形成

接下来是转换 CSSCSSOM 树的过程。整体流程和上面类似:

image-20211120171510677

在这一过程中,浏览器会确定每一个节点的样式到底是什么,并最终生成一颗样式规则树,这棵树上面记录了每一个 DOM 节点的样式。

image-20211120171529844

CSS 从字节数据转换为 CSSOM 这一过程其实是很消耗资源的。因为样式你可以自行设置给某个节点,也可以通过继承获得。在这一过程中,浏览器需要递归 CSSOM 树,然后确定具体的元素到底是什么样式。

举个例子:

<div>
    <a href="#">
        <span></span>
    </a>
</div>
span {
    color: red;
}

div>a>span {
    color: red;
}

对于第一种设置样式的方式来说,浏览器只需要找到页面中所有的 span 标签然后设置颜色。

但是对于第二种设置样式的方式来说,浏览器首先需要找到所有的 span 标签,然后找到 span 标签上的 a 标签,最后再去找到 div 标签,然后给符合这种条件的 span 标签设置颜色,这样的递归过程就很复杂。

所以我们应该尽可能的避免写过于具体的 CSS 选择器,然后对于 HTML 来说也尽量少的添加无意义标签,保证层级扁平。

生成渲染树

当我们生成 DOM 树和 CSSOM 树以后,就需要将这两棵树组合为渲染树( Render Tree )。

image-20211120171550663

在这一过程中,不是简单的将两者合并就行了。渲染树只会包括需要显示的节点和这些节点的样式信息,如果某个节点是 display: none 的,那么就不会在渲染树中显示。

当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做回流),然后调用 GPU 绘制,合成图层,显示在屏幕上。对于这一部分的内容因为过于底层,还涉及到了硬件相关知识,这里就不再继续展开内容了。

阻塞渲染

首先渲染的前提是已经生成了渲染树( Render Tree )。而生成渲染树的前提是生成了 DOM 树和 CSSOM 样式规则树。

所以如果想要渲染的速度加快,我们就应该降低要渲染的文件的大小,并且 HTML 节点的层级扁平化(没有无意义的标签),优化选择器。

当浏览器在解析到 script 标签时,会暂停构建 DOM,原因很简单,因为 JS 能够修改 DOM 节点,所以浏览器会先执行 JS 代码,当 JS 代码执行完成后才会从暂停的地方重新开始。

也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件,这也是都建议将 script 标签放在 body 标签底部的原因。

另外,在现代浏览器中,为我们提供了新的方式来避免 JS 代码阻塞渲染的情况:

  • async
  • defer
  • prefetch
  • preload

关于这几种方式的区别,我们在另外一篇文章中再具体来看。

重绘和回流

重绘和回流会在我们设置节点样式时频繁出现,同时也会很大程度上影响性能。

  • 重绘:当节点需要更改外观而不会影响布局的,比如改变 color 就叫称为重绘
  • 回流:布局或者几何属性需要改变就称为回流。

回流必定会发生重绘,重绘不一定会引发回流。因此回流所需的成本比重绘高得多,改变父节点里的子节点很可能会导致父节点的一系列回流。

当页面布局和几何信息发生变化的时候,就需要回流。比如以下情况:

  • 添加或删除可见的 DOM 元素

  • 元素的位置发生变化

  • 元素的尺寸发生变化(包括外边距、内边框、边框大小、高度和宽度等)

  • 内容发生变化,比如文本变化或图片被另一个不同尺寸的图片所替代。

  • 页面一开始渲染的时候(这肯定避免不了)

  • 浏览器的窗口尺寸变化(因为回流是根据视口的大小来计算元素的位置和大小的)

现代浏览器的优化机制

现代的浏览器都是很聪明的,由于每次重排都会造成额外的计算消耗,因此大多数浏览器都会通过队列化修改并批量执行来优化重排过程。

浏览器会将修改操作放入到队列里,直到过了一段时间或者操作达到了一个阈值,才清空队列。

但是,当你获取布局信息的操作的时候,会强制队列刷新,比如当你访问以下属性或者使用以下方法:

  • offsetTop、offsetLeft、offsetWidth、offsetHeight

  • scrollTop、scrollLeft、scrollWidth、scrollHeight

  • clientTop、clientLeft、clientWidth、clientHeight

  • getComputedStyle( )

  • getBoundingClientRect

更多会触发回流的属性和方法可以参阅:https://gist.github.com/paulirish/5d52fb081b3570c81e3a

减少回流和重绘的方式

接下来,让我们谈谈如何减少回流和重绘。

1. 最小化重绘和回流

由于重绘和重排可能代价比较昂贵因此最好就是可以减少它的发生次数。为了减少发生次数我们可以合并多次对DOM和样式的修改然后一次处理掉。考虑这个例子

const el = document.getElementById('test');
el.style.padding = '5px';
el.style.borderLeft = '1px';
el.style.borderRight = '2px';

例子中,有三个样式属性被修改了,每一个都会影响元素的几何结构,引起回流。

当然,大部分现代浏览器都对其做了优化,因此,只会触发一次重排。但是如果在旧版的浏览器或者在上面代码执行的时候,有其他代码访问了布局信息(上文中的会触发回流的布局信息),那么就会导致三次重排。

因此,我们可以合并所有的改变然后依次处理,比如我们可以采取以下的方式。

使用 cssText

const el = document.getElementById('test'); 
el.style.cssText += 'border-left: 1px; border-right: 2px; padding: 5px;'; 

将要修改的 CSS 样式写在一个样式类里面,然后通过添加和删除该样式类的方式来改变样式

const el = document.getElementById('test'); 
el.className += ' active'; 

2. 批量修改 DOM

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

  1. 使元素脱离文档流

  2. 对其进行多次修改

  3. 将元素带回到文档中。

该过程的第一步和第三步可能会引起回流,但是经过第一步之后,对 DOM 的所有修改都不会引起回流,因为它已经不在渲染树了。

有三种方式可以让 DOM 脱离文档流:

  • 隐藏元素,应用修改,重新显示

  • 使用文档片段( document fragment )在当前 DOM 之外构建一个子树,再把它拷贝回文档。

  • 将原始元素拷贝到一个脱离文档的节点中,修改节点后,再替换原始的元素。

考虑我们要执行一段批量插入节点的代码:

function appendDataToElement(appendToElement, data) {
    let li;
    for (let i = 0; i < data.length; i++) {
        li = document.createElement('li');
        li.textContent = 'text';
        appendToElement.appendChild(li);
    }
}

const ul = document.getElementById('list');
appendDataToElement(ul, data);

如果我们直接这样执行的话,由于每次循环都会插入一个新的节点,会导致浏览器回流一次。

我们可以使用上面所提到的三种方式进行优化。

1隐藏元素应用修改重新显示

这个会在展示和隐藏节点的时候,产生两次重绘。

function appendDataToElement(appendToElement, data) {
    let li;
    for (let i = 0; i < data.length; i++) {
        li = document.createElement('li');
        li.textContent = 'text';
        appendToElement.appendChild(li);
    }
}
const ul = document.getElementById('list');
ul.style.display = 'none';
appendDataToElement(ul, data);
ul.style.display = 'block';

2使用文档片段 document fragment )在当前 DOM 之外构建一个子树,再把它拷贝回文档

const ul = document.getElementById('list');
const fragment = document.createDocumentFragment();
appendDataToElement(fragment, data);
ul.appendChild(fragment);

3将原始元素拷贝到一个脱离文档的节点中修改节点后再替换原始的元素。

const ul = document.getElementById('list');
const clone = ul.cloneNode(true);
appendDataToElement(clone, data);
ul.parentNode.replaceChild(clone, ul);

3. 避免触发同步布局事件

上文我们说过,当我们访问元素的一些属性的时候,会导致浏览器强制清空队列,进行强制同步布局。

举个例子,比如说我们想将一个 p 标签数组的宽度赋值为一个元素的宽度,我们可能写出这样的代码:

function initP() {
    for (let i = 0; i < paragraphs.length; i++) {
        paragraphs[i].style.width = box.offsetWidth + 'px';
    }
}

这段代码看上去是没有什么问题,可是其实会造成很大的性能问题。

在每次循环的时候,都读取了 box 的一个 offsetWidth 属性值,然后利用它来更新 p 标签的 width 属性。

这就导致了每一次循环的时候,浏览器都必须先使上一次循环中的样式更新操作生效,才能响应本次循环的样式读取操作。每一次循环都会强制浏览器刷新队列,一刷新队列就会引发回流和重绘。

我们可以优化为:

const width = box.offsetWidth;
function initP() {
    for (let i = 0; i < paragraphs.length; i++) {
        paragraphs[i].style.width = width + 'px';
    }
}

4. 复杂动画脱离文档流

对于复杂动画效果,由于会经常的引起回流重绘,因此,我们可以使用绝对定位,让它脱离文档流。否则会引起父元素以及后续元素频繁的回流。

5. CSS3 硬件加速( GPU 加速

比起考虑如何减少回流重绘,我们更期望的是,根本不要回流重绘。

使用 CSS3 硬件加速,可以让 transform、opacity、filters 这些动画不会引起回流重绘。但是对于动画的其它属性,比如 background-color 这些,还是会引起回流重绘的,不过它还是可以提升这些动画的性能。

常见的触发硬件加速的 CSS 属性:

  • transform

  • opacity

  • filters

  • Will-change

一道常见的面试题

至此,你明白了浏览器渲染一张页面的整体流程,每一个步骤是在做什么事情,也知道了经常听到别人口中的回流和重绘是什么意思,并且知道一些能够避免回流和重绘的方法。

最后,我们以一道经常被问到的面试题结束本篇文章。

经典真题:为什么操作 DOM 慢?

参考答案:因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们通过 JS 操作 DOM 的时候,这个操作就必然涉及到了两个线程之间的通信,操作 DOM 次数一多,也就等同于一直在进行线程之间的通信,从而产生性能消耗。另外,操作 DOM 还会带来回流和重绘,这在一定程度上也有性能上的问题。

用我们传统的开发模式,原生 JSJQuery 操作 DOM 时,浏览器会从构建 DOM 树开始从头到尾执行一遍流程。假设在一次操作中,我需要更新 10DOM 节点,浏览器收到第一个 DOM 请求后并不知道还有 9 次更新操作,因此会马上执行流程,最终执行 10 次。例如,第一次计算完,紧接着下一个 DOM 更新请求,这个节点的坐标值就变了,前一次计算为无用功。计算 DOM 节点坐标值等都是白白浪费的性能。即使计算机硬件一直在迭代更新,操作 DOM 的代价仍旧是昂贵的,频繁操作还是会出现页面卡顿,影响用户体验。

Vue、React 这种框架出现后,提出了虚拟 DOM 的概念,虚拟 DOM 就是为了解决浏览器性能问题而被设计出来的。例如,如果一次操作中有 10 次更新 DOM 的动作,虚拟 DOM 不会立即操作 DOM,而是将这 10 次更新的 diff 内容保存到本地一个 JS 对象中,最终将这个 JS 对象一次性 attchDOM 树上,再进行后续操作,避免大量无谓的计算量。所以,用 JS 对象模拟 DOM 节点的好处是,页面的更新可以先全部反映在 JS 对象( 虚拟 DOM )上,操作内存中的 JS 对象的速度显然要更快,等更新完成后,再将最终的 JS 对象映射成真实的 DOM,交由浏览器去绘制。


-EOF-