浏览器渲染以及重绘(repaint)和回流(reflow)

865 阅读12分钟

从服务器拿到资源之后,我们怎么进行浏览器渲染,以及怎么把网页渲染出来

一 浏览器渲染

1 浏览器渲染的基本了解

参考从0开始,彻底了解浏览器渲染

从左往右边看,我们可以看到,浏览器渲染过程如下:

1.解析HTML,生成DOM树,解析CSS,生成CSSOM树
(CSS Object Model ,CSS 对象模型,里面有很多api,包括style rules-内部样式表中所有的CSS规则)
2.将DOM树和CSSOM树结合,生成渲染树(Render Tree)
3.Layout(回流):根据生成的渲染树,进行回流(Layout),得到节点的几何信息(位置,大小)
4.Painting(重绘):根据渲染树以及回流得到的几何信息,得到节点的绝对像素
5.display:将像素发送给GPU,展示在页面上。
(这一步其实还有很多内容,比如会在GPU将多个合成层合并为同一个层,并展示在页面中。
而css3硬件加速的原理则是新建合成层)

那么 将DOM树和CSSOM树结合,生成渲染树(Render Tree)是怎么样一个过程呢? 将DOM树和CSSOM树结合,生成渲染树(Render Tree)这一步,浏览器做了什么

1. 从DOM树的根节点开始遍历每个可见节点。
2. 对于每个可见的节点,找到CSSOM树中对应的规则,并应用它们。
3. 根据每个可见节点以及其对应的样式,组合生成渲染树。

既然说到了要遍历可见的节点,那么我们得先知道,什么节点是不可见的。不可见的节点包括:

1.一些不会渲染输出的节点,比如script、meta、link等。
2.一些通过css进行隐藏的节点。比如display:none。
注意,利用visibility和opacity隐藏的节点,还是会显示在渲染树上的。
只有display:none的节点才不会显示在渲染树上。

那么问题出现了,script、meta、link, img这一类都不可见的节点,例如我要引入css <link href="1.css"></link>我是不是永远没法去引入渲染到页面了?那么我们应该去了解浏览器里面的线程相关知识

2 浏览器的线程

线程(英语:thread)

是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务

想要具体了解,看这篇 浅谈浏览器多进程与JS线程

重点知识:

1. 一个进程有一个或多个线程,线程之间共同完成进程分配下来的任务
2. 浏览器每打开一个标签页,就相当于创建了一个独立的浏览器进程

我的理解:

打开一个网页,就是打开了一个进程,但是网页里面可能有页面渲染,轮播图滚动等这些都靠不同的线程去完成的整个进程分配下来的任务

浏览器是多线程的,分为: 图形用户界面GUI渲染线程,JS引擎线程,事件触发线程, 定时触发器线程, 异步HTTP请求线程

每个线程负责哪些:

1.图形用户界面GUI渲染线程
负责渲染浏览器界面,包括解析HTML、CSS、构建DOM树、Render树、布局与绘制等
当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
2.JS引擎线程
JS内核,也称JS引擎,负责处理执行javascript脚本
等待任务队列的任务的到来,然后加以处理,浏览器无论什么时候都只有一个JS引擎在运行JS程序
3.事件触发线程
听起来像JS的执行,但是其实归属于浏览器,而不是JS引擎,用来控制时间循环
(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)
当JS引擎执行代码块如setTimeout时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),
会将对应任务添加到事件线程中当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,
等待JS引擎的处理
注意:由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)
4.定时触发器线程
setIntervalsetTimeout所在线程
定时计时器并不是由JS引擎计时的,因为如果JS引擎是单线程的,如果JS引擎处于堵塞状态,那会影响到计时的准确
当计时完成被触发,事件会被添加到事件队列,等待JS引擎空闲了执行
注意:W3CHTML标准中规定,setTimeout中低与4ms的时间间隔算为4ms
5.异步HTTP请求线程
在XMLHttpRequest在连接后新启动的一个线程
线程如果检测到请求的状态变更,如果设置有回调函数,该线程会把回调函数添加到事件队列,
同理,等待JS引擎空闲了执行

那么html文件加载渲染过程中,到底是哪些东西由哪些线程来负责呢?

3.浏览器的工作机制

参考:浏览器的工作机制

浏览器获得一个html文件时,会'自上而下'加载,并在加载过程中进行解析渲染,css引入执行加载时,程序仍然往下执行,而执行到 <script>脚本则中断线程,待该script脚本执行结束之后程序才继续往下执行

拿到网页资源后,那就能拿到对应的代码,浏览器会在内存中开辟一块栈内存,用来给代码的执行提供环境,同时分配一个线程去一行行执行代码,对于解析HTML、CSS、构建DOM树、Render树、布局与绘制等就是分配的GUI渲染线程,当我们遇到link标签的时候,link里面有src对吧,这时候浏览器对于外部的资源的引入,不再是使用GUI渲染线程, 而是使用http异步请求线程,遇到图片资源,浏览器也会另外发出一个请求(也是在http异步请求线程请求图片的),来获取图片资源。这是异步请求,并不会影响html文档进行加载。那么外部的css回来之后,去task queue去排队,等GUI渲染线程主线程里面东西渲染完之后,去task queue里面去看看有没有东西需要渲染,所以这就是为啥首次渲染只有dom树和除meta、link, img以外的东西.然而遇到srcipt标签呢? html文档会挂起渲染(加载解析渲染同步)的线程,不仅要等待文档中js文件加载完毕(js引擎线程),还要等待解析执行完毕,才可以恢复html文档的渲染线程。因为js是单线程执行的,但是浏览器是多线程的,这个加载script、meta、link等标签不是在渲染线程做的事

为什么script就会中断程序,遇到link却还能继续执行呢?
原因:link是异步请求资源,并不会影响同步的代码,JS有可能会修改DOM,
最为经典的document.write,这意味着,在JS执行完成前,后续所有资源的下载可能是没有必要的,
这是js阻塞后续资源下载的根本原因。
办法:可以将外部引用的js文件放在</body>前。

接下来理解最重要的东西,回流和重绘

二 回流

1.回流基本概念

前面我们通过构造渲染树,我们将可见DOM节点以及它对应的样式结合起来,可是我们还需要计算它们在设备视口(viewport)内的确切位置和大小,这个计算的阶段就是回流

2.什么时候发生回流

回流这一阶段主要是计算节点的位置和几何信息(大小,位置等),那么当页面布局和几何信息发生变化的时候,就需要回流

具体有:

1.添加或删除可见的DOM元素
2.元素的位置发生变化
3.元素的尺寸发生变化(包括外边距、内边框、边框大小、高度和宽度等)
4.内容发生变化,比如文本变化或图片被另一个不同尺寸的图片所替代。
5.页面一开始渲染的时候(这肯定避免不了)
6.浏览器的窗口尺寸变化(因为回流是根据视口的大小来计算元素的位置和大小的)

根据改变的范围和程度,渲染树中或大或小的部分需要重新计算,有些改变会触发整个页面的重排,比如,滚动条出现的时候或者修改了根节点,回流是一定到导致重绘的,每次重绘都会造成额外的计算消耗,也就是影响性能,会导致页面出现耗时,浏览器卡慢,所以我们要尽量的避免回流和重绘

那么什么是重绘呢?

三 重绘

1.重绘基本概念

我们通过构造渲染树和回流阶段,我们知道了哪些节点是可见的,以及可见节点的样式具体的几何信息(位置、大小),那么我们就可以将渲染树的每个节点都转换为屏幕上的实际像素,这个阶段就叫做重绘节点。

说明了回流一定会造成重绘,而重绘不一定是因为回流 那么不是因为回流而导致的重绘有哪些,主要还是样式的改变,下面这些的改变会引起重绘

visibility, color,background-color

重绘会影响性能,所以我们应该尽可能的避免重绘,下面说说如何减少回流与重绘

四 如何避免回流与重绘

如何避免回流与重绘也是性能优化的一个重要的知识点

1. 使用mvvm框架

例如react和vue,里面的虚拟dom,以及diff算法已经对回流和重绘做了更好的封装与优化

2.读写分离

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

offsetTop、offsetLeft、offsetWidth、offsetHeight
scrollTop、scrollLeft、scrollWidth、scrollHeight
clientTop、clientLeft、clientWidth、clientHeight
getComputedStyleLI
getBoundingClientRect

那么刷新队列会有什么影响呢?先看看几个例子

  <div id="test">测试回流与重绘</div>
 let test = document.getElementById('test')
 test.style.width = '200px'
 test.style.height = '100px'
 test.style.marginLeft = '10px'
 console.log(test.clientWidth)

上面的代码你觉得发生了几次回流?

第一行获取test没有做操作,肯定没有触发回流, 第二行修改了width按道理来说触发了一次回流,那么下面三行应该三次对不对?其实并不是,只有一次,之前的老浏览器有的可能是三次,但是现在的浏览器都有批量渲染的机制,比如把你的dom的宽高margin的修改放到一个队列里,在队列所有操作结束后只需要进行一次绘制即可,上面3个的修改只是引发了一次回流,当你执行完第二行,浏览器会等待下自动检验下一行是不是也是在改样式,三行都执行完了,放到一个队列里面,我再统一进行渲染

那么再看看下一个例子

  <div id="test">测试回流与重绘</div>
 let test = document.getElementById('test')
 test.style.width = '200px,
 '  // 第二行
 console.log(test.clientWidth)
 test.style.height = '100px' // 第四行
 test.style.marginLeft = '10px'; // 第五行

上面你觉得几次回流? 答案是2次, 假如我获取执行到第二行,我在队列里面放了test.style.width = '200px' ,但是我下一行遇到了获取布局信息的操作,这时候会强制刷新队列,这时候有一次回流,那么下面的放在新的队列里,引发一次回流

从上面你们就能看出来,分离读写能够减少重绘

3.样式集中改变

比如说上面我可以改写成

  <div id="test">测试回流与重绘</div>
 .test {
    width: 200px;
    height: 100px;
    margin-left: '10px';
 }

让样式集中渲染,变成一个回流,减少渲染次数

4. 缓存处理,其实也就是分离读写

  <div id="test">测试回流与重绘</div>
 test.style.width = test.clientWidth + 10 + 'px'
 test.style.height = test.clientHeight + 10 + 'px'

上面两次重绘,下面一次重绘

const tClientWidth = test.clientWidth
const tClientHeight = test.clientWidth
test.style.width = tClientWidth  + 10 + 'px'
test.style.height = tClientHeight + 10 + 'px'

5.元素批量修改

 <div id="test">测试回流与重绘</div>
for (let i = 0; i < 5; i ++ ) {
  let newLi = document.createElement('li')
  newLi.innerHTML = i
  test.appendChild(newLi)
}

上面发生了5次回流

怎么让他变成一次回流呢?那就是进行批量修改

5.1 使用createDocumentFragment

createdocumentfragment()方法创建了一虚拟的节点对象,节点对象包含所有属性和方法。 当你想提取文档的一部分,改变,增加,或删除某些内容及插入到文档末尾可以使用createDocumentFragment() 方法。 你也可以使用文档的文档对象来执行这些变化,但要防止文件结构被破坏,createDocumentFragment() 方法可以更安全改变文档的结构及节点。

createdocumentfragment()方法是创建文档碎片节点,当需要添加多个dom元素时,如果先将这些元素添加到DocumentFragment中,再统一将DocumentFragment添加到页面,会减少页面渲染dom的次数,效率会明显提升

可以先存到放createdocumentfragment里面,再作为一个整体放进去

    let frg = document.createDocumentFragment()
    for (let i = 0; i < 5; i ++ ) {
      let newLi = document.createElement('li')
      newLi.innerHTML = i
      frg.appendChild(newLi)
    }
    test.appendChild(frg)
    frg = null //用完就立马销毁
5.2 直接字符串拼接
    let str = ''
    for(let i = 0; i < 5; i ++) {
      str += `<li>${i}</li>`
    }
    test.innerHTML = str

6.使用定位元素而不是margin

因为定位元素已经离脱离标准流,即使定位子元素有一些重绘,也是是非标准流影响这个层级的元素,更不是整个大的标准流

7.用css3新样式

平时我们用定位的可以换成transform 用transform和opacity开启硬件加速,不会引起回流与重绘

至于为什么,请看我写的这篇,很重要哦~