前言
我们都知道浏览器中有 “重绘” 和 “重排” 两个概念,但浏览器对于我们是一个黑盒,我们很难真正弄清楚其真实的代码逻辑如何,除非去研究浏览器内核源码。
这里本文我也是结合了一些实践经验后提出自己对 重绘、重排 的运作的新的猜测,如有错误欢迎指正。
怎样理解重排重绘
在本节,我们抛出3个独立的概念:
- js执行
- dom重排和重绘
- UI渲染
先说结论:上述3个概念,对于我们理解重排重绘到底是什么有着至关重要的作用。如上3个概念其实是3个独立的事情。
- js执行是指的运行js代码,当然你还其实可以在其中调用domapi(这种调用虽然会穿透到c++层,但依然跟js执行占用一个线程);
- dom重排和重绘是指的浏览器DOM模型对象内部的一种数据结构变动,可以理解为对
dom render tree
这个数据结构的某个子树的重新生成,但它并不意味着你在视觉上可以看到重排重绘后的样子 - UI渲染才是真正的进行了视觉上的绘制。只有UI渲染后才能肉眼看到dom重排重绘后的结果。
下面,我们来分别解释这些概念。
js执行
js执行我们在网络上很多文章都有学习过了。js的执行是通过js执行引擎线程来执行的。而且这个线程与UI渲染线程互斥,因此,当我们js代码运行期间,是不可能进行UI渲染的。
浏览器给我们js提供了很多dom api,让我们可以去操作浏览器中的 dom元素,例如改变dom元素的宽度属性,改变其颜色样式等等。
dom 对象是浏览器底层 C++ 实现的一个对界面上 UI 元素的抽象。那么,当我们 JavaScript 对所谓的 dom api 进行操作的时候,则实际上每次 api 调用实际上是在修改底层 C++维持的 dom对象的属性。
例如:
ele.style.width = '100px'
或者
ele.style.backgroundColor = 'red'
像上面这种操作,要比我们设置普通的属性如 window.a = 1
要耗时很多。因为修改dom属性会穿透到c++层。
来源:https://www.zhihu.com/questio...
重排和重绘
我们从网络上文章都已经知道重排(回流)和重绘的概念。例如,如下行为会引发回流:
1、添加或者删除可见的DOM元素;
2、元素位置改变;
3、元素尺寸改变——边距、填充、边框、宽度和高度
4、内容改变——比如文本改变或者图片大小改变而引起的计算值宽度和高度改变;
5、页面渲染初始化;
6、浏览器窗口尺寸改变——resize事件发生时;
如下行为会引发重绘:
如:只是影响元素的外观,风格,而不会影响布局的样式,比如background-color。
来源:https://www.jianshu.com/p/b27...
但回流和重绘到底是何时发生的,大家是否有思考过呢?比如这样一段代码:
ele.style.flex = 1 // 假设ele的父元素是 display:flex
那么,你将ele元素的flex样式设置为1,即自适应宽度。那么你在何时可以拿到ele元素的真实渲染宽高呢?假如你同步来拿,如这样写法:
ele.style.flex = 1
console.log(ele.offsetWidth)
这样是否可以立刻拿到ele他真实渲染宽高呢?你在console.log这句代码执行的这一刻,浏览器中是否已经按照flex:1的预期画好了UI界面呢?关于重排重绘到底在线程的什么阶段发生,UI渲染何时发生,我们在后文重点讲解。
UI渲染
所谓UI渲染,就是通过UI线程来将此时dom的最新状态,渲染成浏览器中的可视的样子。
UI渲染线程跟JS执行线程永远是互斥的,即当你js执行时,UI必然不能渲染;当你UI渲染时,js必然也无法
到底何时触发重排重绘
那么,当我们修改完一个元素的“宽度”或“颜色”,浏览器会立刻将修改渲染到页面上吗? 实际上不是的,为了解释js代码执行、重排重绘、以及浏览器到底啥时候往UI上去绘制我们的界面这些步骤,那么我们要搬出一张图了:
首先,浏览器的js引擎负责执行我们的js代码(按照宏任务和微任务的执行规则来执行);而当js引擎线程空闲时,浏览器渲染引擎线程才得以有机会得到执行(即上图中白色方块那个位置,表示js线程此时空闲了)。不过,白色方块右侧的黑色开关也不是一直打开的,他遵循60帧每秒的一个帧率来打开和关闭,即每(1000/60)毫秒打开一次。 因此,我们总结一下浏览器这几个概念的执行步骤:
1、首先,浏览器事件循环会不断的执行 js 代码和 js 宏任务回调,当然如果每次宏任务执行完毕后若微任务队列有任务,则清理微任务队列。
2、事件循环如此往复,必然有短暂空闲时刻(即图中白色方块位置)。每当js主线程空闲时刻,则有机会去按照帧率决定是否切到“UI渲染线程”去绘制界面(规则就是60帧每秒的频率打开该开关)
3、假如,你的js代码在主线程中执行过程中,有对元素尺寸等的api操作(例如修改width),那么,js主线程中这个dom修改会将c++层中的dom对象上width属性改掉,且会"触发"c++对这片渲染树的reflow。
为什么是带引号的“触发”呢,那是因为浏览器会做优化:虽然你改变了dom元素的width,但是你此时js还未执行完,那么UI渲染线程必然无法执行。既然UI渲染都无法执行,所以界面也无法绘制,因此浏览器认为,他也没必要对你的width进行reflow重排。因此,浏览器仅仅把你的width设置给缓存到队列-----等到真正要渲染UI的时候再reflow就好。
4、浏览器做的挺好了,他的思路没毛病。但有一种情况叫做“不可中断的回流” Uninterruptible reflow 。 这种情况回流会同步发生(即浏览器没办法缓存,必须要做完reflow的动作后再往下执行)。来源:https://developer.mozilla.org...
例如你读取了元素的宽:
ele.style.flex = 1
console.log(ele.offsetWidth)
那么,本来上面那一句flex:1的动作浏览器是可以缓存的,这样主线程可以立刻执行下方代码。但由于你直接调用了offsetwidth,那么这一句会立刻触发浏览器dom清空回流操作,即把之前的flex:1实施。因此你的代码会阻塞在 ele.offsetWidth这里等待回流完成,从而拿到正确的offsetwidth值(至于回流是发生在主线程还是ui线程都并不重要了,总之会阻塞你主线程代码)。这也就是回流性能低的原因了。
5、不管你js执行过程中有没有触发上述同步的回流。当你js空闲,那么此时ui线程得到机会渲染时,都会看看回流重绘队列里有没有需要重排重绘的操作,有则执行他们并进行UI绘制。
实例
我们来看一个例子,这个例子会在js代码中对dom尺寸进行修改,且立刻读取其修改后的尺寸。这必然会立刻触发同步的回流操作,但实际上你在浏览器观察不到尺寸的变化(因为我们js线程未结束之前,UI线程还无法进行渲染)。 通过这个例子,我们可以清晰的看出来:页面进行重排重绘是一个dom对象内部的过程和概念,而并不是指的UI渲染到浏览器视觉这个步骤。
代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div style="display: flex; height: 30px;">
<div style="width: 100px;background-color: green;"></div>
<div id="test" style="width: 100px;background-color: red;"></div>
</div>
<script>
setTimeout(() => {
// 2. 接下来setTimeout触发
var ele = document.querySelector('#test')
// 3. 你立刻访问此时的渲染树,拿到ele宽度,结果是100px
console.log('ele原始宽度', ele.offsetWidth)
// 4. 你修改了一下红色块的宽 --- 此时浏览器内部优化了一下:暂时不需要重排,先把flex设置放到队列
ele.style.flex = '1'
// 5. 你要访问ele的宽,此时浏览器迫不得已要重排,于是他进行relayout(即把你之前那个设置flex操作实施----所谓的实施其实就是依然在主线程中去执行dom引擎c++的重排代码逻辑),得到最新渲染树。然后给你返回最新宽度。
// 此时,虽然涉及到了c++调用,但无论js还是c++,其实都在依然占用主执行线程。因此此时仅仅是dom结构理论的offsetWIdth更新了,但渲染线程也无法得到执行,因此你肉眼看到的红色块依然是100px
console.log('ele自适应的宽度', ele.offsetWidth)
// 6. 下面我们写个死循环占主主线程一段时间,从而让我们更清楚看到页面上依然是100px的红色块
const start = Date.now()
while(Date.now() - start < 3000) {
}
// 7. 主线程空闲,此时渲染线程得到执行,于是浏览器肉眼可看到红色块变成自适应。
}, 0)
// 1. 当页面首次加载,还未执行setTimeout回调时,页面首次js进入空闲状态,于是浏览器渲染引擎则把100px的红绿色块渲染。你会首先看到2个100px的块。
</script>
</body>
</html>
到底什么样的代码性能会差?
在网络上,我们经常会听到说,不要频繁操作dom以免降低性能。其实这里有个歧义:即到底是因为什么影响到性能的呢?其实网络上会有一些不适应现代浏览器的错误说法。例如下面这个代码:
const start = Date.now();
for(var i = 0; i < 300000; i++) {
var spanNode = document.createElement("div");
spanNode.innerHTML = "number:" + i;
document.body.appendChild(spanNode);
}
console.log('总耗时', Date.now() - start);
很多人认为:这样的写法是不妥的,因为他在循环内每次循环都要操作 DOM,进而造成每次都触发浏览器的重排。因此要改成如下的写法:
var fragment = document.createDocumentFragment();
const start = Date.now();
for(var i = 0; i < 300000; i++) {
var spanNode = document.createElement("div");
spanNode.innerHTML = "number:" + i;
fragment.appendChild(spanNode);
}
document.body.appendChild(fragment);
console.log('总耗时', Date.now() - start);
但实际上,上述2种写法,经过chrome浏览器测验,其总耗时是一致的。实际上,由于浏览器的优化机制,循环内直接操作dom实际上并不会让浏览器立刻就触发同步的重排(浏览器并没有那么傻)。而浏览器是会在你对内存dom全部操作完毕后,js线程空闲后,需要UI绘制的时候,它才对你的重排命令进行合并触发---很显然这个优化是必要的。
那么,下面我们来看看,到底怎样的写法,才会导致页面性能问题呢。
只操作dom但不重排重绘
大部分的dom操作,其实并不会立刻触发重排重绘,而是被浏览器优化为在最后统一做一次重排重绘,因此这种dom操作对性能损耗并不是很大
试想以下两种代码,哪个对性能影响更大:
// 第一行:
document.a = 1
// 第二行
document.title = 1
答案是第二行影响更大,因为第一行仅仅是修改v8内js对象的一个属性,第二行你修改v8内js对象document.title的同时,他还要穿透到c++层去修改真正dom模型上的title。类似的:我们使用 ele.style.width
进行修改,也一样会造成c++调用的损耗。
但以上这种dom操作的性能损耗还是可以接受的,正是因为浏览器不会立刻触发重排重绘。就比如上文我们提到的那个例子:循环n次,每次都往body append一个元素。其性能还算可以的,因为这并没有触发重排,只是触发了一次c++调用。
不过还是有很多dom操作是立刻就触发重排重绘的,这种行为我们还是要尽可能的避免(例如设置scrollTop属性),以免引起大量的性能损耗。而且有些浏览器可能不会做合并或者延迟重排的优化,那么我们确实也尽量应该避免操作真实的dom树。那么解决办法就是尽可能少操作,或者使用fragment创建好dom之后,再append到真实dom树里面。也可以像vue virtual dom似的,diff找到最小化变更再一起更新。
会导致立刻重排的dom操作
有一些操作是会立刻造成dom重排的(即重新生成那块渲染树)。这个操作影响就比较大了。因为这种操作必然会引发立即重排重绘,那么浏览器的js执行线程就被卡住了。举例来说:
console.log(ele.offsetWidth)
console.log(ele.offsetTop)
这个宽度或距离窗口顶部距离等属性的读取,就会触发立刻重排。毕竟浏览器要把此时dom最新的状态下重排后的坐标等信息确认后,再返回给你结果。不然你拿到的结果就是错的。 更多的api请参考这里:https://gist.github.com/pauli...
因此,这给我们的启示是,不要频繁读取这种触发重排的属性。例如:
for (i = 0; i <100;i++) {
a = ele.offsetWidth + i
}
这时,最好把ele.offsetWidth挪到外面:
const myWidth = ele.offsetWidth
for (i = 0; i <100;i++) {
a = myWidth + i
}
另一个启示是:你操作dom也就罢了,但千万别操作完就读取它。例如上文的这个例子:
const start = Date.now();
for(var i = 0; i < 300000; i++) {
var spanNode = document.createElement("div");
spanNode.innerHTML = "number:" + i;
document.body.appendChild(spanNode);
}
console.log('总耗时', Date.now() - start);
你如果在appendChild那一行下面加一句: a = (spanNode.clientWIdth),
,那性能就千差万别了。
虽然没有冗余的重排(被浏览器优化了),但是往页面里塞了100000个dom,
上面我们说了,性能问题其实并不是出现在操作dom本身,而是出现在某些dom操作会触发重排(尤其是读取操作)。
因此,现代浏览器,我们单纯的append dom到文档中(例如append10000个),一般情况下浏览器也就在我们js空闲后来一次重排。此时用不用fragment都行。 那么,这种情况下,js线程执行时其实并不会卡住。
而我们如果亲自操作以上实验,会发现append 10000个元素还是会导致浏览器卡顿。实际上这里的卡顿,出现在js执行完毕,浏览器的UI渲染线程开始渲染的时刻,因为此时浏览器要把你曾经append的元素进行渲染,则他必然要在此时进行重排,重排完了还要UI绘制到显示器,这个重排和绘制过程就比较耗时了,反过来他又阻塞了我们js的执行。
link标签和style标签对页面重排的影响
在一些前端开发套件的开发模式下我们经常会通过 “动态append样式”的方式,将样式塞到 html 的 head 标签中。例如 vue 的本地开发热加载模式。
那么,假如我们初始化一个 vue 组件,该组件初始化时会立刻将 style 注入到 head(此时样式必然要影响到样式?)。那么请问:我们在vue组件的mounted中,能否拿到样式所影响的元素的最新的尺寸信息(如offset)。
这个问题的答案要分情况:看你的样式是怎样append到head里的。这里有2种方式注入样式:
- style标签注入
- link标签注入
当使用style标签注入时,无论chrome还是safari浏览器,其style注入的那一刻,都把样式树模型进行了修改(毕竟这是同步塞到dom里,且同步更改了样式?),因此我们vue的mounted(即vue组件的模板dom挂载完成)里面你读取元素的offsetWidth会立刻触发重排,故可以拿到最新结果。案例如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<div class="main-part">
<div class="left-part" >left</div>
<div class="right-part" ref="me" >msg: {{msg}}</div>
</div>
</div>
<script src="//cdn.bootcdn.net/ajax/libs/vConsole/3.3.4/vconsole.min.js"></script>
<script src="//cdn.bootcdn.net/ajax/libs/vue/2.6.12/vue.js"></script>
<script>
new VConsole();
new Vue({
el: '#app',
mounted: function() {
this.msg = 'hello'
const el = document.createElement('style')
el.innerHTML = `.main-part {
display: flex; border: 1px solid red;
}
.left-part {
width: 30px; background-color: green;
}
.right-part {
flex: 1; background-color: gray;
}`;
document.head.appendChild(el);
console.log('this.$refs[me]')
console.log(this.$refs['me'])
console.log(window.getComputedStyle(this.$refs['me']).width)
},
data: {
msg: '1111'
}
})
</script>
</body>
</html>
而如果使用link标签,则Safari浏览器会认为这是个外链样式,因此他会假装走一次异步修改样式树的行为。在这个异步空隙里,vue得到初始化和dom挂载并触发mounted。那么此时我们在mounted里面访问时,link标签的load事件还没触发,因此你访问offsetWidth是拿不到最新样式的重排结果的。 不过,chrome内核却解决了link的这个问题,他可能发现我们link的内容并不是网络url,那么他就同步修改样式树了。
案例代码如下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<div class="main-part">
<div class="left-part" >left</div>
<div class="right-part" ref="me" >msg: {{msg}}</div>
</div>
</div>
<script src="//cdn.bootcdn.net/ajax/libs/vConsole/3.3.4/vconsole.min.js"></script>
<script src="//cdn.bootcdn.net/ajax/libs/vue/2.6.12/vue.js"></script>
<script>
new VConsole();
new Vue({
el: '#app',
mounted: function() {
this.msg = 'hello'
node = document.createElement('link')
node.setAttribute('rel', 'stylesheet')
node.setAttribute('type', 'text/css')
document.head.appendChild(node)
const mystyle = `.main-part {
display: flex; border: 1px solid red;
}
.left-part {
width: 30px; background-color: green;
}
.right-part {
flex: 1; background-color: gray;
}`;
const LINK_HEAD = 'data:text/css;charset=utf-8,'
node.setAttribute('t', Math.random())
node.setAttribute('href', LINK_HEAD + encodeURIComponent(mystyle))
node.onload = () => {
console.log('样式 href load 完成', window.getComputedStyle(this.$refs['me']).width)
}
console.log('this.$refs[me]')
console.log('mounted,查看元素宽度')
console.log(window.getComputedStyle(this.$refs['me']).width)
},
data: {
msg: '1111'
}
})
</script>
</body>
</html>
提问
fragment上的元素,我们能否调用 offsetWidth 等属性获取其渲染坐标尺寸呢?
答案:不可以。假如你设置了style.width='100px',你也只能拿到0。
因为 fragment是游离于渲染树的,因此他无法重排。只有你把dom放到真实dom里,才能拿到用于渲染的重排数据。
因此,如果你确实需要访问元素的渲染宽高坐标等,您则必然要耗费很多性能来拿到这个值。这也是本文最重要的启示:我们在这种场景要尽可能的避免那种循环写法,因为“同步重排”的代价太大了
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。