- 回顾渲染过程中的相关概念
- 优化点分析
- 先要了解一件事:浏览器很聪明
- 我们能做的优化点
- 1)利用“不可见时不影响”的特点 | 操作时让元素不可见(离线化),操作完后恢复显示
- 2)利用副本 | 使用cloneNode方法克隆节点,在节点中操作完成后,直接替换原节点
- 3)操作DocumentFragment对象 | 将多节点当做整体一次性插入目标节点,大大减少重渲染次数
- 4)利用浏览器的“聪明” | 改就一块改,读就一块读,别读改读改读读改改…
- 5) “改要一块改”的最佳实践 | 改样式通过切换类名
- 6)“读要一起读”的最佳实践 | 缓存布局信息
- 7) 让常触发重排的节点脱离文档流 | position属性为absolute或fixed的元素重排开销小
- 8)table标签有毒 | 渲染慢、牵一发动全身
- 9) 启用GPU加速 | 让“直接合成”替代可怕的“重排”
- 10)window.requestAnimationFrame | 专门用来优化网页动画的API
- END
- 参考
回顾渲染过程中的相关概念
【要想重新回顾渲染过程相关知识,可以点这里:05 | 渲染这个大话题】
概念一、重排 | 触发重新布局,即:

某个元素改变宽高或几何位置会使页面其他元素需要调整位置以适应布局,也这就意味着要页面需要重新计算元素位置(重新布局),这种情况称作“重排”。重排操作执行后会触发后面整个流程的更新,所以是一种开销非常大的操作。
以下情况会触发重排
- 页面初始渲染
- 添加/删除可见DOM元素
- 改变可见元素位置
- 改变可见元素尺寸(宽高、边距、边框等)
- 改变可见元素内容(文本、图片等,别忘了DOM树中是有文本节点的)
- 改变浏览器窗口尺寸
- 滑动滚动条(如果你还记得分块和光栅化那块,你会知道这会重排整个页面)
概念二、重绘 | 触发重新绘制,即:

元素的绘制样式发生变化就会触发重绘,从上一篇文章里我们知道绘制阶段是输出待绘制列表的,所以这里的重绘并非包含内容展示,只是输出新的待绘制列表。
如果元素只是样式改了,几何位置没有改变,那我们可以看作单纯的重绘操作,这时渲染流水线中布局的重新计算就显得多此一举了,所以会直接跳过布局阶段,因此单纯重绘的开销比重排小得多;但如果几何位置发生改变,无论样式是否更改,触发重排必然导致后面整个流程的重新执行,而其中也恰恰包含了重绘,所以:若触发重排,则必然导致重绘;重绘不一定需要重排。
以下情况会触发单纯的重绘
主要是更改了以下css属性:color border-style border-radius visibility text-decoration background background-image background-position background-repeat background-size outline-color outline outline-style outline-width box-shadow
概念三、重新渲染 | 重新生成布局和重新绘制,即:
当然这也同样会导致后面整个流程的重新执行。
概念四、直接合成 | 合成时才动手脚,改变合成结果,绘制效率远比重排和重绘高

如果不需要改变元素几何位置和样式,而只是要求合成进程在合成时“有意做些手脚”,比如偏移一下下,这时就需要合成了。合成同样是要重走渲染流水线,但是对于它而言,布局的计算和绘制阶段(产出待绘制列表)是多此一举的,所以也是直接跳过这些步骤。同时,因为合成的操作并不在主线程中执行,所以不会占用主线程的资源,相对于重绘和重排能大大提升绘制效率。
优化点分析
对于网页而言,重排和重绘的操作是不可避免的,但是这些操作十分耗费资源,会导致网页性能低下(响应慢、卡顿、动画不流畅等)。如果一个网页中有大量的重排或重绘操作,那将会丢失大量的用户,这对我们前端人来说是不能接受的,所以我们要做的也就是对症下药,逐步优化页面性能。
归根到底,其实问题就出在重排或重绘太过频繁,而最直接的解决方式也就是减少这类操作的触发,那具体我们该如何减少?
先要了解一件事:浏览器很聪明
对于频繁重新渲染这事,浏览器不可能不知道,所以它自己也准备了优化措施,当它发现你在更改节点的某些样式或属性时,如果发现你一口气改了几个,它会将这些变动集中起来,排成队列,一次性执行更改,最后一次重新渲染搞定,避免了多次重新渲染。
具体处理:浏览器会维护1个队列,把所有会引起回流、重绘的操作放入这个队列,等队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会flush队列,进行一个批处理。这样就会让多次的回流、重绘变成一次回流重绘。但有时候我们写的一些代码可能会强制浏览器提前flush队列,这样浏览器的优化可能就起不到作用了。当你请求向浏览器请求一些 style信息的时候,就会让浏览器flush队列。
为什么你需要知道这个?你等等就知道了,请继续往下看。
我们能做的优化点
1)利用“不可见时不影响”的特点 | 操作时让元素不可见(离线化),操作完后恢复显示
如果你还记得上一篇文章中的布局阶段,你会很容易想起在DOM树构建成布局树时,会将所有不可见元素过滤出去。这也就是告诉了我们所有不可见元素的位置和样式更改都不会影响到后续的阶段进行,也就是不会触发重排和重绘,而这就是优化的一个突破口:
// 先设置元素不可见(离线化)【触发第一次重排】,这时元素内部怎么操作都不会触发重新渲染dom.style.display: 'none'// 然后在不可见的状态下进行添加删除子元素、更改属性之类的操作// 如果不做离线化处理,每一次增删都会触发重新渲染// 操作好后才将元素重新显示【触发第二次重排】dom.style.display: 'block'
按着上面的操作我们能够通过使用两次重新渲染取代可能多次的重新渲染操作,这能大大地优化页面性能。
但在这里,有的小伙伴就有疑问了,能够不通过display: 'none'来优化吗?还真别说,真有!
2)利用副本 | 使用cloneNode方法克隆节点,在节点中操作完成后,直接替换原节点
思路就是将原有节点克隆一份,此时克隆出来的节点还没有append进文档中,所以是不在布局树中的,这时候随便我们怎么操作都不会触发重新渲染,最后将加工完成的克隆节点替换源节点即可。
// 克隆节点const cloneElement = originElement.cloneNode(true)// 移除原节点【触发第一次重排】xxx.removeChild(originElement)// 插入克隆节点【触发第二次重排】xxx.appendChild(cloneElement)
cloneNode
该方法可创建指定的节点的精确拷贝,包括节点属性和值的拷贝。使用方式如下:
const 存储副本的变量 = 原节点.cloneNode(/* 是否递归拷贝子孙节点,默认不填为false */true)
3)操作DocumentFragment对象 | 将多节点当做整体一次性插入目标节点,大大减少重渲染次数
有时候如果仅仅只是为了给某个DOM节点添加内部节点,这时候可以使用这个优化方法,先认识一下DocumentFragment对象:
DocumentFragment对象
文档片段接口,用于存储排好版或未打理好格式的XML片段。前面是官方说明,其实可以看成一个透明容器,你可以将要添加的节点排好格式后放进去,然后将整个容器一次性插入目标节点中,说它是个透明容器,是因为它不会被当成DOM树的一部分。具体了解:链接
它如何优化性能?它将所有要添加的内部节点当一个整体一次性插入目标节点,这个操作只会触发一次重新渲染,大大提高性能。
具体使用
// 创建空的DocumentFragment对象const fragment = document.createDocumentFragment()// 操作DocumentFragment对象fragment.appendChild(...)// 将DocumentFragment对象插入目标节点中【触发一次重排】originElement.appendChild(fragment)
4)利用浏览器的“聪明” | 改就一块改,读就一块读,别读改读改读读改改…
乍一看你不知道我在说什么,没关系,你回想一下上面我让你了解浏览器的“聪明”。
我说浏览器会将你一口气做的更改一次性执行,避免多次重新渲染,这里的“一口气”就是改节点样式、位置或属性时要一次性改,不要中途再去做读节点属性值的操作,为什么要你“一口气”地改,其实就是让你好好利用浏览器的优化措施。
情况一:
// 改变div的高度 【改】div.style.height = '100px'// 读取div的宽度 【读】var width = div.style.width// 改变div的样式 【改】div.style.backgroundColor = 'red'...
上面的例子中第一步改了节点高度后,浏览器会看你有没有继续做更改操作,结果你来了个读值的操作,这么一弄,浏览器不得不赶紧将前一个值对应改了,然后重新渲染后给你读最新的width属性值,后面你又来了个改值操作,又触发了一次重绘操作…
情况二:
// 改变div的高度 【改】div.style.height = '100px'// 改变div的样式 【改】div.style.backgroundColor = 'red'// 读取div的宽度 【读】var width = div.style.width...
这里同样的是先改了节点高度,但不同的是浏览器看到你接着就改了样式,它认为[‘改高度’,’改样式’]是可以集中起来一次性执行的,所以并没有在高度改变后就立即重新渲染,当它发现你停止改的操作时(读取宽度),它才一次性执行更改操作,然后返回最新宽度值。
总结:
你可以看到情况一会多次进行重绘操作,而情况二将更改操作集中一次性执行完了,所以性能会有所提高。其实上面的例子就很清楚地告诉我们了,改要一块改,读要一块读,不要读改读改读读改改…关键就是要好好利用浏览器的“聪明”,别让它的心血喂了狗。
5) “改要一块改”的最佳实践 | 改样式通过切换类名
div.style.color = 'red'div.style.width = '200px'div.style.minHeight = '300px'
改成通过类名切换的方法更加棒,免得你手误又把读改混在一起了。
style {color: red;width: 200px;min-height: 300px;}
div.className = 'style' // 相当一次性给div改了3个属性
6)“读要一起读”的最佳实践 | 缓存布局信息
div.style.left【改】 = div.offsetLeft + 1 + 'px';div.style.top【改】 = div.offsetTop + 1 + 'px';
这里你很容易将其看成是“改要一块改”,其实里面混着“读”,所以这里其实触发了两次重排,如下:
div.style.left【改】 = div.offsetLeft【读】 + 1 + 'px';div.style.top【改】 = div.offsetTop【读】 + 1 + 'px';
所以你可以改成以下形式:
// 变量缓存布局信息var curLeft = div.offsetLeft; 【读】var curTop = div.offsetTop; 【读】div.style.left = curLeft + 1 + 'px';【改】div.style.top = curTop + 1 + 'px'; 【改】
这样的话你能很清晰看到自己真的是符合“改要一块改,读要一块读”的要求了。另外,在需要经常取那些引起浏览器重排的属性值时,最好缓存到变量,免得造成不必要的性能开销。
7) 让常触发重排的节点脱离文档流 | position属性为absolute或fixed的元素重排开销小
将窗体自上而下分成一行一行,并在每行中按从左至右依次排放元素,称为文档流,也称为普通流。所以其中有一个元素几何位置发生变化,将影响其后所有元素的几何位置,布局计算时需要重新进行计算。但对于position属性为absolute或fixed的元素而言,它们是完全脱离文档流的,对文档流后面的元素不会有位置上的影响,所以重排时布局计算将更快,开销更小。
常见的三种脱离文档流方法:
position absolute/fixed
float(特殊,虽其脱离文本流,但会影响周围文本节点,所以并没有什么优化效果)
使用float时,虽然也是脱离文本流,其他盒子会无视这个元素,但其他盒子内的文本依然会为这个元素让出位置,环绕在该元素的周围。如果更改该类元素的几何位置,周围的普通流中文本仍会被影响。
8)table标签有毒 | 渲染慢、牵一发动全身
牵一发动全身
table内的一个小改动可能会使整个table进行重新布局,而div只会影响之后的元素。
渲染慢
总结 可以不用table布局的尽量不用,改用其他布局。
9) 启用GPU加速 | 让“直接合成”替代可怕的“重排”
GPU 硬件加速是指应用 GPU 的图形性能对浏览器中的一些图形操作交给 GPU 来完成,因为 GPU 是专门为处理图形而设计,所以它在速度和能耗上更有效率。
GPU 加速通常包括以下几个部分:Canvas2D,布局合成, CSS3转换(transitions),CSS3 3D变换(transforms),WebGL和视频(video)。
这里的transform是主要要提的,在做网页动画时可以优先考虑使用,它触发的是“直接合成”,这会直接跳过布局和绘制阶段,大大提高性能。不过这里要注意的是,如果多个元素同时启用GPU加速,大量时间会花在图层的合成上。
另外opacity透明度设置也是触发“直接合成”的,可以让元素变透明,visible属性类似,但是visible会触发重绘(开销较高)。两者隐藏后都还占用着空间,但是opacity完全透明后仍支持内部绑定事件(致命)!而visible不支持,所以如果遇到内部没有绑定事件的需要隐藏元素,在opacity和visible的选择中可以选择opacity。
10)window.requestAnimationFrame | 专门用来优化网页动画的API
屏幕刷新频率
平时当我们对着电脑屏幕什么也不做时,显示器也同样会以每秒60次的频率正在不断的更新屏幕上的图像。这里的每秒刷新60次就是我们所说的屏幕刷新率。刷新频率受屏幕分辨率和屏幕尺寸的影响,因此不同设备的屏幕刷新频率可能会不同。
JS对属性的更改是不会立即让屏幕刷新的
JS通过setTimeout对属性的更改只是在内存中对图像属性进行改变,这个变化必须要等到屏幕下次刷新时才会被更新到屏幕上。屏幕不会因为你更改了属性而立即刷新页面。
setTimeout卡顿的原因
从上面我们知道如果要设置动画,要把刷新间隔时间尽可能就控制在16.7ms(1000/60≈16.7),这样能与屏幕刷新的步调保持一致,页面动画效果达到最佳。但当我们使用setTimeout或setInterval来实现网页动画时,尽管将间隔数设在16.7一下仍会出现卡顿、抖动的详细,这种现象的产生原因有两个:
- 异步机制(异步操作会等待主线程任务执行完后才开始执行)导致实际执行实际比设定时间晚。
- 不同设备的屏幕刷新频率不同,如果设置的固定时间间隔和屏幕刷新时间不同,可能会出现丢帧的现象
何为丢帧?
如果setTimeout刷新的频率与屏幕刷新率不一致,就可能会导致中间某一帧的操作被跨越过去,而直接更新下一帧的图像。假设屏幕每隔16.7ms刷新一次,而setTimeout每隔10ms设置图像向左移动1px, 就会出现如下绘制过程(假设不受异步影响):
| 时间点 | 屏幕是否刷新 | setTimeout执行效果 | 屏幕图像状态 |
|---|---|---|---|
| 0ms | 图像初始left = 0px | ||
| 10ms | 设置图像left = 1px | ||
| 16.7ms | 刷新 | 图像向左移动到1px的位置 (0 -> 1) | |
| 20ms | 设置图像left = 2px | ||
| 30ms | 设置图像left = 3px | ||
| 33.4ms | 刷新 | 图像向左移动到3px的位置 (1 -> 3) | |
| … |
相信你也看出来了,屏幕图像只会在只会在固定时间内刷新,而上面的例子中setTimeout的属性更改与屏幕刷新步调不一致,导致直接跳过图像向左移动到2px的位置,这种情况就叫丢帧。
requestAnimationFrame带来了哪些不同?
它最大的优势在于它是由系统自己来决定回调函数的执行时机,如果系统的刷新率是60Hz(每隔16.7ms刷新一次),那它就会自己每隔16.7ms调用这个函数,这样就能完美地达到步调一致的效果。重新拿上面那个例子:
const move = () => {let left = div.offsetLeftif (left === 100) return// 使用requestAnimationFramewindow.requestAnimationFrame(() => {div.style.left = `${left + 1}px`move()});}move()
| 时间点 | 屏幕是否刷新 | 屏幕图像状态 |
|---|---|---|
| 16.7ms | 刷新 | 图像向左移动到1px的位置 (0 -> 1) |
| 33.4ms | 刷新 | 图像向左移动到2px的位置 (1 -> 2) |
| 50.1ms | 刷新 | 图像向左移动到3px的位置 (2 -> 3) |
| … |
用了requestAnimationFrame,你会发现它不会发生丢帧的现象,更舒服的是,不需要你自己去考虑间隔时间更不用理会啥是异步。当然如果你觉得每一次刷新向左移动一次移动太慢,那你只要改成div.style.left =${left + 2}px`那便等同于加快移动速度...<br />更更nice的是,**requestAnimationFrame还能够自动节能**,你应该知道setTimeout一旦设置,如果你不使用clearTimeout`那它便会无休止地进行,包括页面最小化了、被隐藏了,它仍会一直执行,但是requestAnimationFrame不一样,它如果发现页面最小化了,它便认为这个回调函数没有执行的必要,会直接停止调用,当页面重新激活时,才进行执行,动画也从上次停留的地方继续进行,大大节省CPU的开销。
它不仅适用于动画,更适用于高频率监听事件(如resize、scroll监听事件)
一个刷新间隔内函数执行多次是毫无意义的,因为显示器每16.7ms就刷新一次,你JS多次改变属性最后还是要等16.7ms后的一次绘制(如果是同个属性,中间的变化全部没意义),所以还不如在16.7ms的时候改变属性,中间用来摸鱼。而requestAnimationFrame恰恰能够实现“在16.7ms的时候帮你改变让你在过程摸鱼中”的效果,它能保证这类事件的监听回调函数在每个刷新间隔内只被执行一次,这样既保证了流程性,更加节省了函数执行的开销。
