前言
随着网页应用的越来越复杂, 页面的性能优化也变得越来越重要。在 JavaScript 中,回流(Reflow)和重绘(Repaint)是两个常见的性能瓶颈。它们就像一对孪生兄弟,常常一起出现,拖慢我们的网页应用的速度。下面呢我们就从浏览器的渲染开始,看看这两个孪生兄弟是什么并且解决它俩造成的负担。
1. 浏览器的渲染
大家在日常打开网页的时候,就比如掘金,大家可能就是感觉一点链接就能出现这个页面这个过程呢我们操作起来是非常简单的,但是成功的背后离不开浏览器(黑奴)的努力,这个打开页面渲染的过程是一个非常复杂的过程,它主要可以分为下面几个步骤:
- 解析数据包得到 html,css 文件 (将二进制的字节数据解析成字符串)
- html 处理过程:字节数据 => 字符串 => Token => Node 节点 => 构建DOM树
- css 文件转成 CSSOM 树
- DOM + CSSOM => render树(不可见的元素是不会出现在 render 树)
- 计算布局 (回流) —————— 很耗费性能,就类似于计算每一块元素容器在浏览器中的位置之类的
- GPU 绘制 (重绘) ———————— 就类似于绘制浏览器画面
在上面这个过程中,首先我们是得到了返回给我们的数据包,然后对其解析。解析完成之后再对html和css进行处理分别处理成DOM树和CSSOM树,然后将两个树进行合并,最后再进行回流和重绘,在简单了解完了这个浏览器的渲染过程之后,我们下面来聊聊这两个压轴的孪生兄弟———回流和重绘。
2. 回流
2.1 回流概念
回流这小兄弟呢有点像工地的工人,每次页面中元素布局位置发生变化的时候,它就会进行操作重新规划页面布局,这个过程呢就叫回流
回流:浏览器重新计算页面元素的位置和尺寸的过程。每当页面元素的位置或尺寸发生变化时,浏览器都会进行回流计算,以确保页面元素的正确显示。
根据对回流的定义我们来看一段代码(只放js部分):
<script>
let box = document.querySelector('.box')
box.addEventListener('click', () => {
box.style.width = 200 + 'px'
})
</script>
上面代码中的box是一个div本来的宽度是50px,我们通过点击事件让其宽度变成了200px,在这个过程中我们修改的这个元素的宽度,从而导致页面的布局发生了改变,这个过程就是我们上面所说的回流,这样讲可能有点抽象下面我们来看看浏览器中的回流:
上面呢这个图片有点模糊,但是我们也能够看出,在我们打开控制台后,页面中的百度网页可视窗口变小了,然后这个网页中的元素也随着窗口大小的变化进行了重新布局,这个就是我们所说的回流。
2.2 回流的发生
通过上文对回流的定义,我们可以知道当窗口大小改变的时候会进行回流,那浏览器中还有什么时候会进行回流呢?大家可以想想,比如我们页面初次打开的时候是不是得计算完布局然后渲染,下面我们来看几种会发生回流的行为:
- 页面初次渲染
- 窗口大小改变
- 增加或者删除可见的 DOM 元素 (不可见的元素增加或删除不会造成回流)
- 改变元素的几何信息(像bgc这种改变就不会造成回流)———— 注意:改变文字大小不会回流!
当我们每次进行上面几种操作之后,浏览器会自动进行一次回流。而回流是计算各种元素比如标签的定位大小等,计算完了之后才能为我们渲染好页面,而进行回流之后的过程就是它的另一个好兄弟重绘。
3. 重绘
3.1 重绘概念
重绘这个回流的孪生兄弟总是压轴出场,因为只有回流重新弄好布局之后,重绘才能进行绘画渲染,而这个浏览器重新绘制页面元素的过程就是重绘。
重绘:浏览器重新绘制页面元素的过程。每当页面元素的样式发生变化时,浏览器都会进行重绘,以确保页面元素的正确显示。
3.2 重绘的发生
顾名思义,当进行绘制页面元素就会重绘,那我们对浏览器中的某个元素进行修改背景这种行为,就会进行重绘。所以发生重绘的情况如下:
- 改变元素的非几何信息(像color这种改变)—————— 注意:改变文字大小算重绘!
4. 回流重绘一定一起发生吗?
前面说这俩是好兄弟,但是好兄弟一定会一起走吗?答案是不一定的,毕竟每个人的人生道路不一样嘛。
我们可以想想,当我们对页面元素的宽高进行修改时,浏览器进行回流,那回流之后我们是不是得重绘。如果不重绘的话,图片可能本来刚刚好,但是回流之后就可能大了,所以回流的话一定会重绘。
当我们浏览器不进行回流的时候,只进行重绘,就比如只改变元素的颜色或者透明度之类的,这个时候,页面中的布局并没有改变,这个时候,浏览器就没必要回流(也得让黑奴休息会儿吧),因为回流也是那个样,这时候我们只需要重绘就行。
根据上面的两个场景我们可以总结为以下一句话:
- 只要回流一定会重绘,但是重绘不一定会回流
5. 浏览器的优化
5.1 渲染队列
经过面的学习我们知道浏览器在渲染的时候会发生回流和重绘这两步操作,但是我们在对元素进行操作的过程中是进行了几次回流呢?下面我们来对这个问题探讨一下,首先我们来了解一下浏览器的优化——渲染队列
渲染队列:浏览器内置了渲染队列,当浏览器执行到一个需要回流的操作之后,会先将该操作放入队列, 浏览器继续往下执行,如果下面还有导致回流的行为,就一直入队列,直到队列达到阈值或者后面没有新的回流行为,才会将渲染队列中的行为一次性执行,并一次性修改样式。
tips:老版本浏览器中并没有渲染队列,只有新版本有
在我们了解完了渲染队列的基本概念之后,我们来看看下面代码会执行几次回流:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.box{
width: 100px;
height: 100px;
background-color: #000;
}
</style>
</head>
<body>
<div class="box"></div>
<script>
let box = document.querySelector('.box')
box.addEventListener('click', () => {
box.style.width = 200 + 'px'
box.style.width = 300 + 'px'
box.style.width = 400 + 'px'
box.style.width = 50 + 'px'
})
</script>
</body>
</html>
上面这段代码如果在老版本的浏览器没有渲染队列的话上面操作会执行四次回流,因为我们修改了四次box的宽度,而这个回流的过程是需要耗费性能的,这就大大产生了浪费,但是如果我们是新版本的有了渲染队列的浏览器就不一样了。
在新版本的浏览器中上面代码只会执行一次回流,我们下面来画一下渲染队列中的情况给大家看看:
上面代码
addEventListener中对宽度进行了四次修改,代码从上到下执行,我们依次将其放入队列中,这时候并没有到达阈值并且后面没有了回流行为,然后对其进行一次性修改成最后的50px并且全部出队清空渲染队列。
在经过了上面的小测试后,下面我们再来看一段代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.box{
width: 100px;
height: 100px;
background-color: #000;
}
</style>
</head>
<body>
<div class="box"></div>
<script>
let box = document.querySelector('.box')
box.addEventListener('click', () => {
box.style.width = 200 + 'px'
box.style.width = 300 + 'px'
console.log(box.offsetWidth);// 读取容器宽度包括边框
box.style.width = 50 + 'px'
})
</script>
</body>
</html>
大家可以猜一下上面代码要回流几次,有同学可能会说还是一次,其实这里是两次,在上面的代码中我们中途在控制台打印输出了box.offsetWidth,这个操作会将当前的渲染队列强制清空,从而回流一次。所以我们清空之后,再次修改box的width然后又回流了一次,加起来一共是两次。
面试官:给我讲讲回流几次
在简单了解完了offsetWidth的作用之后,下面我们来看一道面试官可能会问到的问题,下面代码回流几次(只放js部分,假设我们的修改id为app元素的宽度)?
<script>
let el = document.getElementById('app')
el.style.width = (el.offsetWidth + 1) + 'px'
el.style.width = 1 + 'px'
</script>
上面这段代码在执行过程中呢,首先我们想要让width的值进行修改,这时候我们调用了offsetWidth这个属性获得width,此时它会将渲染队列清空一次,由于此时渲染队列中没有回流行为,所以接着往下执行不进行回流。
然后前后将width的值修改了两次,由于这两次导致回流,所以将这两个操作都放入渲染队列,最后一次性执行修改width。所以最后只回流一次。
5.2 强制执行渲染队列的属性
在我们了解上面一个会强制清空并执行渲染队列的属性之后,其实在 JavaScript 中还有很多这样的属性,大家在平常使用的时候得注意一下,下面给大家一一列举:
offsetxxx,例如:offsetWidth、offsetHeight、offsetLeft、offsetTop、offsetParent
clientxxx,例如:clientWidth、clientHeight、clientLeft、clientTop
scrollxxx,例如:scrollWidth、scrollHeight、scrollLeft、scrollTop
6. 减少回流
在本文的开头我们提到回流很耗费性能,想想都知道回流不仅仅是回流,回流后还会进行重绘。我们在日常开发的时候肯定是能省则省,秉持着这个原则我们接下来应该思考一下如何解决回流这个性能杀手。
根据我们之前所提到的一些会进行回流的操作,那么我们减少与之对应的操作不就能减少回流了嘛,接下来为大家提供几种减少回流的操作:
先让元素脱离文档,再修改几何属性,再放回文档流(这种操作当数据量有点大时会有性能问题)
使用虚拟文档(document.createDocumentFragment()),将元素放入一个虚拟的文档中,再修改几何属性,再放回原文档流
使用 克隆dom 的方式,先克隆一个元素,再修改几何属性,再放回原文档流
接下来的代码只放js部分,body中只有一个ul标签class='list',我们会在其中用for循环放入li这样会进行回流,下面用上面三种方法给大家展示如何减少回流。
6.1 让元素脱离文档
在前文中提到删除或者增加可见的元素会进行回流,那么我们先让元素变成不可见脱离文档再对其进行修改,最后放入本来的位置中的话这样只会进行两次回流,下面来看代码实现:
let ul = document.querySelector('.list')
const items = ['apple', 'banana', 'orange', 'pear', 'grape', 'watermelon', 'peach']
ul.style.display = 'none'//回流一次
items.forEach(item => {
const li = document.createElement('li')
li.innerText = item
ul.appendChild(li)
})
ul.style.display = 'block'//回流两次
使用这种方法会进行两次回流,这种方法虽然只需要修改两次样式,很简单,但是有利就有弊。这种方法当数据量有点大的时候会有性能问题,比如数据很大,我们遍历的时候是需要时间的,这时候别的边框之类都加载好了,就这个加载不出来。接下来我们看看另外两种方法。
6.2 使用虚拟文档(document.createDocumentFragment())
这种方法和上面的方法本质上都是差不多的都是先让会进行回流的行为先脱离文档,操作完了再放入,接下来我们直接看代码:
let ul = document.querySelector('.list')
const items = ['apple', 'banana', 'orange', 'pear', 'grape', 'watermelon', 'peach']
// 文档碎片 ———— 虚拟文档片段
let frg = document.createDocumentFragment()// 用来创建虚拟文档片段,放在浏览器上也不会加载到内存中,减少回流
ul.style.display = 'none'
items.forEach(item => {
const li = document.createElement('li')
li.innerText = item
frg.appendChild(li)
})
ul.appendChild(frg)// 将虚拟文档片段添加到页面中,这样只会回流一次
上面的方法我们先用frg来存放一个虚拟文档document.createDocumentFragment(),这个文档中我们拿来存放后续加入的li,最后再将虚拟文档入ul之中,这样的话页面布局只改变了一次,所以只用回流一次即可。
6.3 克隆dom
接下来讲的这种方法可能会有点反人类,这种方法呢是先创建一个克隆人,然后让克隆人复制自己的一切,再让克隆人把自己给替换了,下面我们来看看这个不当人的写法:
let ul = document.querySelector('.list')
const items = ['apple', 'banana', 'orange', 'pear', 'grape', 'watermelon', 'peach']
// 克隆
const cloneUL = ul.cloneNode(true)// 克隆ul
ul.style.display = 'none'
items.forEach(item => {
const li = document.createElement('li')
li.innerText = item
cloneUL.appendChild(li)// 往克隆体中放li
})
ul.parentNode.replaceChild(cloneUL, ul)
// 先获取ul的父节点,然后将克隆的ul替换掉原来的ul,这样只会回流一次
这种方法呢与使用虚拟文档的本质差不多,只不过有点不人道,它先创建了一个克隆体const cloneUL = ul.cloneNode(true),然后让这个克隆体克隆ul,再把本体ul给隐藏了进行复制,最后呢先获取ul父节点的信任然后直接上位把原来的ul给替换了,这样的话就导致两次回流。
结语
回流和重绘两兄弟是网页渲染中的两个重要概念,理解它们的工作原理和优化技巧对于提高网页性能至关重要,对我们以后的面试也会有所帮助。在实际开发中,尽量减少回流和重绘的发生是提高网页性能的关键。最后,希望这篇文章能够为您提供有关回流和重绘的基础知识和优化技巧,让大佬们能够更好地理解和优化网页渲染过程。
感谢各位大佬的观看,喜欢的话请三连一下,谢谢!