reflow和repaint引发的性能问题

9,939 阅读13分钟

reflow和repaint在pc端只要不是怀有明知山有虎,偏向虎山行的心态写代码,这两货几乎不会引发性能问题, 但是移动端的渲染能力和pc端差了不止一个大截,一个不小心reflow和repaint就成了移动端的“性能杀手”。所以了解reflow和repaint也是很有必要的,在考量页面性能的时候分析reflow和repaint也算是一个切入点。

是什么


reflow 回流,或者叫重排都可以。回流(reflow)这个名词指的是浏览器为了重新渲染部分或全部的文档而重新计算文档中元素的位置和几何结构的过程。

简单来说就是当页面布局或者几何属性改变时就需要reflow。

在一个页面中至少在页面刚加载的时候有一次reflow,在reflow的过程中浏览器会将render tree中受影响的节点失效,再重新构建render tree,有时候,即使仅仅回流一个单一的元素,也可能要求它的父元素以及任何跟随它的元素也产生回流

repaint重绘,当页面中的元素只需要更新样式风格不影响布局,比如更换背景色background-color,这个过程就是重绘。

如何触发


reflow

从reflow的定义中就可以听出一些来,元素的布局和几何属性改变时就会触发reflow。主要有这些属性:

  • 盒模型相关的属性: width,height,margin,display,border,etc

  • 定位属性及浮动相关的属性: top,position,float,etc

  • 改变节点内部文字结构也会触发回流: text-align, overflow, font-size, line-height, vertival-align,etc

除开这三大类的属性变动会触发reflow,以下情况也会触发:

  • 调整窗口大小
  • 样式表变动
  • 元素内容变化,尤其是输入控件
  • dom操作
  • css伪类激活
  • 计算元素的offsetWidth、offsetHeight、clientWidth、clientHeight、width、height、scrollTop、scrollHeight

repaint

页面中的元素更新样式风格相关的属性时就会触发重绘,如background,color,cursor,visibility,etc

注意:由页面的渲染过程可知,reflow必将会引起repaint,而repaint不一定会引起reflow

了解有哪些属性值改变会触发回流或者重绘点击这里

聪明的浏览器


设想一个这样的场景,我们需要在一个循环中不断修改一个dom节点的位置或者是内容

   document.addEventListener('DOMContentLoaded', function () {
    var date = new Date();
    for (var i = 0; i < 70000; i++) {
        var tmpNode = document.createElement("div");
        tmpNode.innerHTML = "test" + i;
        document.body.appendChild(tmpNode);
    }
    console.log(new Date() - date);
}); 

这里多次测量消耗时间大概在500ms(运行环境均为pc端,小霸王笔记本)。看到这个结果可能就有疑问了,这里有70000次内容的修改,就有70000reflow操作,也就用了500ms的时间(归功于迟缓的dom操作),说好的reflow消耗性能呢。

其实在这个过程中,浏览器为了防止我们犯二把多次reflow操作放在循环中而引发浏览器假死,做了一个聪明的小动作。它会收集reflow操作到缓存队列中直到一定的规模或者过了特定的时间,再一次性地flush队列,反馈到render tree中,这样就将多次的reflow操作减少为少量的reflow。但是这样的小动作带来了另外一个问题,如果我们想要在一次reflow过后就获取元素变动过后的值呢?这个时候浏览器为了获取真实的值就不得不立即flush缓存的队列。这些值或方法包括:

  • offsetTop/Left/Width/Height
  • scrollTop/Left/Width/Height
  • clientTop/Left/Width/Height
  • getComputedStyle(), or currentStyle in IE

犯二代码如下:

        document.addEventListener('DOMContentLoaded', function () {
            var date = new Date();
            for (var i = 0; i < 70000; i++) {
                var tmpNode = document.createElement("div");
                tmpNode.innerHTML = "test" + i;
                document.body.offsetHeight; // 获取body的真实值
                document.body.appendChild(tmpNode);
            }
            console.log("speed time", new Date() - date);
        });

一般人应该不会去运行这种代码,如果你运行了的话,恭喜你的电脑-1s。但是如果没有衡量指标,优化性能也就无从谈起。

“If you cannot measure it, you cannot improve it.” -Lord Kelvin

为了防止浏览器假死,把循环次数都改为7000次,得出的结果是(多次平均):

  • 获取了真实值的样例用时约18000ms
  • 没有获取真实值的样例用时约50ms

通过这两个样例印证了浏览器确实有优化reflow的小动作,聪明的程序员不会依赖浏览器的优化策略,在日常开发中遇到for循环就应该慎重编写循环体内部的代码。

减少reflow和repaint


如何减少reflow和repaint呢?回到定义去,reflow在页面布局或者定位发生变化时才会发生,从定义中我们至少可以得出两个优化思路

  • 减少reflow操作
  • 替代会触发回流的属性

减少reflow操作

其本质上为减少对render tree的操作。render tree也就是渲染树,它的每个节点都是可见,且包含该节点的内容和对应的规则样式,这也是render tree和dom数最大的区别所在, 减少reflow操作,主旨是合并多个reflow,最后再反馈到render tree中,诸如:

1,直接更改classname

    // 不好的写法
    var left = 1;
    var top = 2;
    ele.style.left = left + "px";
    ele.style.top = top + "px";
    // 比较好的写法
    ele.className += " className1";

或者直接修改cssText:

    ele.style.cssText += ";
    left: " + left + "px;
    top: " + top + "px;";

2.让频繁reflow的元素“离线”

  • 使用DocumentFragment进行缓存操作,引发一次回流和重绘;
  • 使用display:none,只引发两次回流和重绘;
  • 使用cloneNode(true or false) 和 replaceChild 技术,引发一次回流和重绘;

Dom规定文档片段(document fragment)是一种“轻量级”的文档,可以包含和控制节点,但不会想完整的文档那样占用额外的资源。虽然不能把文档片段直接添加到文档中,但是可以将它作为一个“仓库”来使用,即可以在里面保存将来可能会添加到文档中的节点。 比如最开始的样例结合DocumentFragment就可以这样写:

    document.addEventListener('DOMContentLoaded', function () {
        var date = new Date(),
            fragment = document.createDocumentFragment();
        for (var i = 0; i < 7000; i++) {
            var tmpNode = document.createElement("div");
            tmpNode.innerHTML = "test" + i;
            fragment.appendChild(tmpNode);
        }
        document.body.appendChild(fragment);
        console.log("speed time", new Date() - date);
    });

将多个修改结果收纳到了documentFragment这个“仓库”中,这个过程并不会影响到render tree,待循环完毕再将这个“仓库”的“存货”添加到dom上,以此达到减少reflow的目的,使用cloneNode也是同理。 而使用display:none来降低reflow的性能开销的原理在于使节点从render tree中失效,等经过多个会触发reflow操作后再“上线”

3.减少会flush缓存队列属性的访问次数,如果一定要访问,使用缓存

// 不好的写法
for(let i = 0; i < 20; i++ ) { 
    el.style.left = el.offsetLeft + 5 + "px"; 
    el.style.top = el.offsetTop + 5 + "px"; 
}
// 比较好的写法
var left = el.offsetLeft, 
top = el.offsetTop, 
s = el.style; 
for (let i = 0; i < 20; i++ ) { 
    left += 5;
    top += 5; 
    s.left = left + "px"; 
    s.top = top + "px"; 
}

替代会触发reflow和repaint的属性

我们可以将一些会触发回流的属性替换,来避免reflow。比如用translate代替top,用opacity替代visibility

样例代码:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        #react {
            position: relative;
            top: 0;
            width: 100px;
            height: 100px;
            background-color: red;
        }
    </style>
</head>

<body>
    <div id="react"></div>
    <script type="text/javascript">
        setTimeout(() => {
            document.getElementById("react").style.top = "100px"
        }, 2000);
    </script>
</body>
</html>

代码很简单,页面上有一个红色的方块,2秒后它的top值将会变为“100px”,为了方便体现替代的属性可以避免reflow这里我们使用chrome的开发者工具,部分截图如下

如上图,在top值变为“100px”的过程中有上图五个阶段.

  • Recalculate Style,浏览器计算改变过后的样式
  • Layout,这个过程就是我们说得reflow回流过程
  • Update Layer Tree,更新Layer Tree
  • Paint,图层的绘制过程
  • Composite Layers,合并多个图层

我们把这五个过程用时记下:80 + Layout(73) + 72 + 20 + 69 = 316us

再用translate替代top:

-       position: relative;
-       top: 0;
+       transform: translateY(0);

-       document.getElementById("react").style.top = "100px"
+       document.getElementById("react").style.transform = "translateY(100px)"

Performace截图:

可以看到用translate替换top后减少了原来的Layout也就是reflow的过程,用时:81 + 80 + 36 + 83 = 280us。 结果非常明显315us减少到了280us。有人说这个效果不明显呀,但是让我们设想这样一个业务场景,有许多网站都会有不停移动的飘窗,这种飘窗通常是用定时器实现,每隔100ms就去修改一次它的top,如果用translate的话1s就可以减少10次reflow,如果这个飘窗样式比较多,比较复杂,那么1秒钟减少的10次reflow就有可能减少几百毫秒甚至几秒Layout的过程

我们再用opacity去替代visibility试试看。

-            document.getElementById("react").style.transform = "translateY(100px)"            
+            document.getElementById("react").style.visibility = "hidden"            

Performace截图:

visibility属性值改变只会触发repaint,不会触发reflow,所以只有四个阶段,其中第三个阶段Paint就是重绘的体现,用时:48 + 50 + Paint(14) + 71 = 183us。我们再用opacity替代visibility

+            opacity: 1;

-            document.getElementById("react").style.visibility = "hidden"    
+            document.getElementById("react").style.opacity = "0"

按照上面的样例,应该得出用opacity替代visibility后重绘也就是Paint这个过程会消失从而达到性能提升的目的,既然这样我们来看Performace截图:

对,你没有看错,我也没有截错图,这次不光是Paint过程没有消失,就连Layout都出现了,惊不惊喜!意不意外!
我们再来重定义一下repaint重绘,它是重新绘制当前图层的内容,(什么是图层,点击查看这篇文章)

其实opacity变化并不能改变这个图层的内容,改变的只是当前图层的alpha通道的值,从而来决定这个图层显不显示。但是这opacity变化的元素并不是单独的图层,而是在document这个图层上的,如下Layers截图:

就是说浏览器并不能因为图层里面有一个opacity为0的元素就让整个图层的alpha通道变为零,而让整个图层不显示,所以就有了Layout和Paint这两个过程。解决办法也很简单那就是直接让这个元素单独为一个图层

修改css新建图层有两种办法:

  • will-change:transform
  • transform:translateZ(0)

这里我们用下面一个

+   transform: translateZ(0);

Performace截图:

现在就和理想中的情况一样了,用opacity替代visibility可以避免Paint重绘的过程。再来看看用时: 66 + 53 + 52 = 171us

这里由于我变动的元素非常简单,只有一个简单的div,减少Paint过程带来的优化收益并不是很明显,如果是Paint过程是毫秒级别减少Paint过程的效果还是可观的。

由上述两个替代会触发reflow和repaint的属性取得性能优化收益的例子中可以看出,这个方法是可行的,除开第一点减少reflow操作和第二点替换属性以外还有一些方法可以减少reflow和repaint

  • 减少table的使用

  • 动画实现的速度选择

  • 对于动画新建图层

    table自带的样式和一些非常方便的特性会方便我们的开发,但是table也有一些与生俱来的性能缺陷,如果想要修改表格里不管哪一个单元格,都会导致整张表格的重新Layout,如果这个表格很大,性能的消耗会有一个上升成本的。

图层的运用


在上一个样例中我们新建了一个图层实现了opacity替代visibility去减少repaint的可行性,那么图层还有什么其他运用吗?答案是有的,我们可以将一些频繁重绘回流的DOM元素作为一个图层,那么这个DOM元素的重绘和回流的影响只会在这个图层中,当然如果你为每一个元素都创建一个图层那样肯定也会聪明反被聪明误,还记得上述的Performance截图中的过程吗,最后一个Composite Layers这个过程就是合并多个图层的,图层过多这个过程会非常耗时,其实这个过程本身也非常耗时,原则上是在必要的情况下才会新建图层来减少重绘和回流的影响范围,到底使不使用就需要开发人员在业务情景中balance. 在Chrome浏览器下可以这样创建图层:

  • 3D或透视变换CSS属性(perspective transform)
  • 使用加速视屏解码的video标签
  • 拥有3D(WebGL)上下文或加速的2D上下文的canvas
  • 混合插件如(如Flash)
  • 对自己的opacity做CSS动画或使用一个动画webkit变换的元素
  • 拥有加速CSS过滤器的元素(GPU加速)
  • 元素有一个包含复合层的后代节点
  • 元素有一个z-index较低且包含一个复合层的兄弟元素
  • will-change: transform;

大体思路就是我们把频繁重绘回流的DOM元素作为一个图层,那么这个DOM元素的重绘和回流的影响只会在这个图层中,来提升性能。举个栗子,我们打开chrome开发者工具中的Layers,然后打开某网站

从红框中可以看出这个网站已经被分为了很多图层,当前选中的的这个baner图层在视图区域已经标注出来,由图可知,将一个经常触发回流和重绘的元素新开图层也算一个优化性能的做法。我们再勾选这个选项

浏览器会用绿色高亮出当前正在repaint的元素,勾选上过后我们打开一个视频:

可以看到视频在播放过程中一直处于高亮状态,这个不难理解,video为单独一个图层,在整个视频播放过程中video接受到发送过来的每一帧,都会将触发video所在图层的重绘。

结语

简单回顾一下本文,我们最开始聊了一下reflow和repaint是什么,如何触发它们,接下来谈了一下浏览器在处理它们所采取的策略,最后就是如何避免reflow和repaint带来的性能开销,还补充了一下图层的存在意义和简单运用。 其实在优化reflow和repaint上就是两点:

  • 避免使用触发reflow、repaint的css属性
  • 将reflow、repaint的影响范围限制在单独的图层之内

参考资料

https://csstriggers.com

http://blog.csdn.net/luoshengyang/article/details/50661553