今天我们来聊聊关于浏览器的回流与重绘。
这是一个什么样的概念呢?当我们在浏览器中输入一段网址时,浏览器就会给我们展示网址所对应的页面。这个过程其实发生了很多事情,我们从浏览器的渲染过程讲起。当我们的电脑成功与服务器建立起了连接,服务器就会发送我们想要的数据包。
我们今天来聊的就是我们的电脑接收到了这个数据包之后进行的操作,也叫浏览器渲染的过程。
1. 浏览器的渲染
浏览器的渲染,简单来说就是浏览器读懂了html代码,css代码和js代码,然后将页面绘制出来给我们看。
当我们的浏览器接收到了数据包后,就开始将数据包解析成html文件和css文件,这个数据包是二进制的数据格式,浏览器会将它们解析成字符串。html文件最后会转换成DOM树,css文件最后会转换成CSSOM树。这两个数其实是一种数据结构,就是树形结构。树形结构是用对象模拟的一种结构,就相当于我们会用数组模拟栈一样。树形结构里面就是许多对象的嵌套,里面存放着html文件和css文件的信息,比如是一个什么样的容器,容器的样式是什么之类的。生成两个数结构之后,浏览器会将DOM树和CSSOM树结合形成render树,也叫渲染树。这一步的目的就是为了将html文件和css文件结合起来,到时候一起渲染,HTML代码和css代码是同步执行的。这里有一个小细节,不可见的元素是不会出现在render树中的,什么叫不可见的元素呢?比如它的display属性为none。
然后就是我们今天的重头戏,当render树生成之后,就开始进行布局的计算,比如这个容器的长宽高是多少,在屏幕中处于哪个位置,外边距内边距是多少之类的,这个过程就叫回流,这个过程是很耗费性能的。
回流执行完毕后,浏览器有一个东西叫GPU,也叫呈像处理器,它的作用就是进行绘制页面,将页面展示出来给我们看。这个过程就叫做重绘。
浏览器总结来说就是:
- 解析数据包得到 html ,css文件 (将二进制的字节数据解析成字符串)
- 字节数据 =》 字符串 =》 Token =》 Node 节点 =》构建DOM 树
- css 文件转成 CSSOM 树
- 将DOM树 和 CSSOM树 结合形成 render树(不可见的元素是不会出现在render树中)
- 计算布局(回流) ---- 很耗费性能的
- GPU 绘制 (重绘)
2. 回流与重绘
我们今天要聊的重点就是回流与重绘。我们需要搞清楚浏览器什么时候进行回流与重绘。
那哪些情况会导致浏览器进行回流呢?就是重新进行页面的加载。
当我们初次打开页面时,是不是一定会进行一次回流。当我们改变页面窗口的大小,也会进行回流。还有,当我们删除页面中某一个可见的容器时,页面也会进行回流,它可能会影响其它容器的布局。当我们改变一个容器的长宽高时,也就是改变元素的几何元素时,也会进行回流。
所以会导致浏览器回流的操作有:
- 页面初次渲染
- 窗口大小改变
- 增加或删除可见的 DOM 元素
- 改变元素的几何信息
那什么情况会导致页面的重绘呢?回流一定会导致重绘。当这个页面的布局发生了改变,浏览器一定要再次绘制出来给你看。还有如果改变一个容器的背景颜色,也会发生重绘,也就是改变元素的非几何信息。
所以会导致浏览器重绘的操作有:
- 改变元素的非几何信息
- 发生了回流
有一个小细节:改变文字的颜色不叫改变了几何属性,改变文字的大小也不叫改变了几何属性。因为改变文字的大小并不是去改变文字的长宽高,而是改变了像素大小,这不叫改变了几何属性。请注意这一点。
还有一点:回流一定会重绘,重绘不一定回流。这应该很好接受。
了解完了浏览器的回流与重绘操作,接下来我们就来聊聊浏览器什么时候进行回流与重绘。
我们以一道字节面试题来看,这道面试题仅用三行代码就能判断你是否对回流了解的足够深,该说不说还得是大厂。
<body>
<script>
let el = document.getElementById('app')
el.style.width = (el.offsetWidth + 1) + 'px'
el.style.width = 1 + 'px'
</script>
</body>
问:这段代码让浏览器进行了几次回流?
这段代码先是获取了id为‘app’的盒子,offsetWidth属性能读取盒子的宽,然后将它的宽增加了1px,紧接着又将宽改为了1px。
按我们现在目前为止所了解到的,这段代码应该是进行了两次回流,因为我们改变了两次元素的非几何信息。
但其实不对,因为我们还有细节没聊完,那就是浏览器的优化。
3. 浏览器的优化
什么是浏览器的优化呢?我来给你看一个例子:
<style>
.box {
width: 100px;
height: 100px;
background-color: black;
}
</style>
<body>
<div class="box"></div>
<script>
let box = document.querySelector('.box')
box.addEventListener('click', function () {
box.style.width = 200 + 'px';
box.style.width = 300 + 'px';
box.style.width = 400 + 'px';
box.style.width = 50 + 'px';
})
</script>
</body>
我们在页面中有一个类名为‘box’的盒子,我们给它绑定了一个点击事件。当我们点击了一下它,会将它的宽度从100改为了200再改为了300再改为了400再改为了50。我们看看它是怎么进行改变的。
注意看,右边的盒子宽度,当我们一点击盒子时,出现的那一刻就是50px。这就是浏览器的优化。
因为浏览器最后只需要将50px的容器展示给用户看就行了,它不需要展示从100变为200变为300变为400再变为50。
浏览器内置了渲染队列,当浏览器执行到一个需要回流的操作之后,会先将该操作放入队列,浏览器继续往下执行,如果下面还有会导致回流的行为,就一直入队列,直到队列达到阈值或者后面没有新的回流行为,才会将渲染队列中的行为一次性执行,并一次性修改样式。
所以这段代码本来应该进行四次回流,实际上只会进行一次回流。浏览器会将这些操作放入渲染队列中,当后续不再有新的操作进入队列,它就一次性执行掉。所以我们最后看见的是宽度一下子变为了50px。
了解完这一点后,我们再来看这道字节面试题,它发生几次回流?
let el = document.getElementById('app')
el.style.width = (el.offsetWidth + 1) + 'px'
el.style.width = 1 + 'px'
是不是就只进行一次回流了呀。因为它会将这两个操作先存入渲染队列再一次性执行。这样回答可能只能得到一半的分,因为进行一次回流的根本原因不是这样。我们继续往下聊。
<style>
.box {
width: 100px;
height: 100px;
background-color: black;
}
</style>
<body>
<div class="box"></div>
<script>
let box = document.querySelector('.box')
box.addEventListener('click', function () {
box.style.width = 200 + 'px';
console.log(box.offsetWidth);
box.style.width = 300 + 'px';
console.log(box.offsetWidth);
box.style.width = 400 + 'px';
console.log(box.offsetWidth);
box.style.width = 50 + 'px';
console.log(box.offsetWidth);
})
</script>
</body>
我们还是回到那个例子上来讲。我在每一次修改宽度之后都打印输出它的宽度。我们说过offsetWidth属性能读取盒子的宽度。
这时,回流次数就要另当别论了。你想想看,如果这时还将改变回流的操作存入渲染队列中最后一次行执行,那输出语句输出多少?难道输出50吗?这可能吗?那代码不就异步执行了,但这都是同步代码呀,所以每一次输出都有它自己的值。
这是因为offset开头的属性会强制清空渲染队列。只要浏览器读到了这种属性,它就会将已经存入渲染队列中的操作全部拿出来执行。所以这段代码进行了四次回流,每存进去一个操作,紧接着就拿出来执行掉了。
所以有这样一些强制执行渲染队列的属性:
- offsetxxx
- clientxxx
- scrollxxx
这样写的意思是以这些前缀开头的属性都会导致浏览器回流。
所以我们再次回到字节的这道面试题:
let el = document.getElementById('app')
el.style.width = (el.offsetWidth + 1) + 'px'
el.style.width = 1 + 'px'
这道题的满分回答应该是什么呢?
当读到el.offsetWidth语句时,浏览器会将已经存入渲染队列的操作拿出来一次性执行,所以本来应该进行一次回流,但渲染队列中此时并没有存入操作,所以不进行回流。然后进行了两次更改宽度的操作,先将这两次操作存入渲染队列中,之后没有新的操作进入队列,就一次性执行掉。所以最后只执行一次回流。
你看,是不是3行代码就将回流的知识点问清楚了。
3. 怎么减少回流
此时问到这里,面试官一定还会接着问你:那怎样才能减少回流的次数呢?
因为我们说过回流是很消耗性能的,所以尽可能的减少回流次数也算性能优化。
那我们来看看有哪几种方法能减少回流次数。
- 先让元素脱离文档,再修改几何属性,再放回文档流
- 使用虚拟文档
- 使用克隆 dom
减少回流次数有以上几种方法,我们一一来看一下。
元素脱离文档
我们可以先让一个元素从文档中消失,再去进行会导致回流的操作。等会导致回流的操作执行完毕后,再让元素出现。
你说如果对一个不存在的元素进行几何信息的修改,它会影响布局吗?这肯定不会呀,它都不存在了,自然不会影响别的容器。
还是那个修改宽度的例子,为了减少回流,我们可以这样写:
<style>
.box {
width: 100px;
height: 100px;
background-color: black;
}
</style>
<body>
<div class="box"></div>
<script>
let box = document.querySelector('.box')
box.addEventListener('click', function () {
box.style.display = 'none';
box.style.width = 200 + 'px';
console.log(box.offsetWidth);
box.style.width = 300 + 'px';
console.log(box.offsetWidth);
box.style.width = 400 + 'px';
console.log(box.offsetWidth);
box.style.width = 50 + 'px';
console.log(box.offsetWidth);
box.style.display = 'block';
})
</script>
</body>
我们在更改几何信息操作之前加上这样一条语句:box.style.display = 'none' 。这样box盒子就从页面中消失了,接下来不管我们对box盒子进行什么操作,它都不会发生回流,因为它已经不存在于页面中了。
等操作结束后,我们再让它出现。 box.style.display = 'block'。这样整段代码就只执行了两次回流:从页面上消失的一次;从页面上出现的一次。
使用虚拟文档
我们还可以使用虚拟文档减少回流次数。
比如:
<body>
<ul class="list">
</ul>
<script>
const ul = document.querySelector('.list')
const items = ['a', 'b', 'c', 'd', 'e',]
// 文档碎片
let frg = document.createDocumentFragment()
for (let i = 0; i < items.length; i++) {
const li = document.createElement('li')
li.textContent = items[i]
frg.appendChild(li)
}
ul.appendChild(frg)
</script>
</body>
假设这是一段从后端获取数据然后展示在页面中的代码。我们可以使用createDocumentFragment方法,它会创建一个虚拟文档frg。然后每次循环我们都往虚拟标签上添加li,最后再把这个虚拟标签frg添加到ul里面去。
往虚拟标签上进行的回流操作并不会导致浏览器回流,最后只会执行将frg添加到ul里面去这么一次回流。
这就是使用虚拟文档法。
使用克隆 dom
还有一种方法,和使用虚拟文档法差不多,叫克隆dom法。
<body>
<ul class="list">
</ul>
<script>
const ul = document.querySelector('.list')
const items = ['a', 'b', 'c', 'd', 'e',]
// 克隆
const cloneUl = ul.cloneNode(true)
for (let i = 0; i < items.length; i++) {
const li = document.createElement('li')
li.textContent = items[i]
cloneUl.appendChild(li)
}
ul.parentNode.replaceChild(cloneUl, ul)
</script>
</body>
我们先创建一个ul的克隆体cloneUl,可以使用cloneNode方法。然后往这个克隆体上添加li,因为克隆体并没有在页面中出现,所以不会导致回流。最后我们再将克隆体cloneUl替代ul。用parentNode获取ul的父容器也就是body,再用replaceChild替换body的子容器。所以ul就被cloneUl替代了。所以最后也会执行一次回流。
4.总结
本篇文章我们一起学习了一下浏览器的渲染过程,什么是回流和重绘,什么时候进行回流和重绘还有减少回流次数的一些方法。如果对你有帮助的话不妨点个赞吧。