随着近年来Web前端技术的高速发展,从最初的基本的Form表单验证,到现在大型WebGL的工程实践,前端技术有了一个质的飞跃。我们经历了互联网Web1.0到Web2.0的改变,也开始越来越重视前端开发这个领域。前端开发也逐渐确立了自己一个不可取代的程序地位。最初,我们有prototype.js/jquery.js等框架,到现在前端框架的天下三分(Angular.js、React.js、Vue.js),各位的技术栈也跟着更新了吗?随着页面功能的复杂化及追求页面效果的观赏性,伴随而来的就是页面性能低下的问题,那么,如何优化页面的性能?优化页面的性能都包含哪些方面?这是一个相对复杂的课题。

笼统的来讲,前端的优化,主要包含:网络层面的优化、构建层面的优化、浏览器渲染机制的优化及服务端层面的优化。再往细分,可以具体到:资源的合并与压缩、图片的类型选择与编码原理、浏览器的渲染机制、资源的懒加载与预加载、浏览器存储、缓存机制等方面。本文主要针对浏览器的渲染机制,着重分析回流与重绘对页面性能的影响。

浏览器的渲染过程,一般由构建DOM-tree -> 构建Render-tree -> 布局Render-tree -> 绘制Render-tree构成。那么,在布局(layer)与绘制(painting)的过程中,就会发生回流与重绘,那么,什么是回流与重绘?

回流(reflow),是当Render-tree中的一部分(或全部)因为元素的规模尺寸、布局、隐藏等改变需要重新构建,导致页面的局部及几何属性改变时,就会发生回流。可以触发页面重新布局的属性主要有:盒模型相关属性(width/height/padding/margin)、定位属性(position:relative/absolute/fiexd)、浮动属性(float)、以及(display/border-weidth/border/min-height/top/bottom/left/right/position/float/clear/text-align/overflow-y/font-weight/overflow/font-family/line-height/vertival-align/white-space/font-size)等,此外,改变Node节点内部的文字结构,同样会触发页面的重新布局。

重绘,指当Render-tree中的元素需要更新属性,而这些属性只影响元素的外观、风格,而不会影响布局的属性,比如:color/border-style/border-radius/visbility/text-decration/background/background-image/background-position/background-repeat/background-size/outline-color/outline/outline-style/outline-width/box-shadow等。

需要指出,回流一定会触发重绘,而重绘不一定触发回流。

当我们理解了回流与重绘的概念,我们就需要了解,浏览器是如何新建一个DOM元素的?在这个过程中,都发生了什么?在这个过程中,哪里会触发回流?哪里会触发重绘?

浏览器在新建DOM元素时,可以分为以下几个过程:

  1. 获取DOM后分为多个图层
  2. 对每个图层节点技术样式结果(样式的重计算过程)
  3. 为每个节点生成图形和位置信息(重布局与回流)
  4. 将每个节点绘制填充到图层位图中(重绘)
  5. 图层作为纹理上传到GPU
  6. 复合多个图层到页面生成最终屏幕图像(图层重组)

整个过程,我们可以通过Chrome浏览器的调试工具——Performance来查看整个DOM的变化情况,那么,针对回流与重绘,我们主要从以下几点入手:

  • 用translate代替位置属性(top/left/right/left)

  #box{
    width: 100px;
    height: 100px;
    background: #f00;
    position: relative;
    top: 50px;
    left: 50px;
  }

  <div id="box"></box>

  setTimeout(() = > {
    let ele = document.getElementById('box');
    ele.style.left = '100px';
  },2000)

在这里,我们2s之后,将box的位置向右移动的100px,在这个过程中消耗的时间如图:

Web前端性能优化——浅析回流与重绘-Rome-Web 前端开发博客

sum = 51 + 49 + 42 + 17 + 73 = 232(微秒);

然后我们修改为使用translate:


  #box{
    width: 100px;
    height: 100px;
    background: #f00;
    margin-top: 50px;
    transform: translateX(50px);
  }

  <div id="box"></box>

  setTimeout(() = > {
    let ele = document.getElementById('box');
    ele.style.transform = 'translateX(100px)';
  },2000)

Web前端性能优化——浅析回流与重绘-Rome-Web 前端开发博客

sum = 58 + 45 + 65 = 168(微秒);

相比于使用位置属性,使用translate可以减少渲染时的回流与重绘,明显在图2中没有了Layout与Paint的过程。

  • 用opacity代替visibility

visibility这个属性,会触发重绘的过程,但是不会触发回流的过程。

使用visibility如图:

Web前端性能优化——浅析回流与重绘-Rome-Web 前端开发博客

修改为使用opacity,对比:

Web前端性能优化——浅析回流与重绘-Rome-Web 前端开发博客

有趣的事,我们发现,并不是像我们想象中的那样,Layout和Paint过程非但没有消失,还多出来了Layout过程,这是什么原因呢?实际上是这样的,使用opacity并不会触发重绘与回流的过程,上图中的Layout和Paint过程实际上是整个文档的Layout和Paint,并不是我们box元素的Layout和Paint。opacity的原理,是修改Alpha通道的透明度,此过程,并不会触发回流与重绘。

  • 不要一条一条的修改DOM样式,预先定义好class,然后修改DOM的className

  setTimeout(() = > {
    let ele = document.getElementById('box');
    ele.style.top= '100px';
    ele.style.left = '100px';
    ele.style.height = '200px';
    ele.style.width = '300px';
    //......
  },2000)

类似上面的代码,从写法上,我们就可以看出,这并不是最佳实践,结合前面的浏览器渲染机制,你可能会想到,在上面的代码中,可能会发生4次的回流与重绘,但是实际如图:

Web前端性能优化——浅析回流与重绘-Rome-Web 前端开发博客

实际上,基于浏览器的缓冲机制(不是缓存机制),在一定的时间内,会将多次的回流与重绘合并为一次,进行Layout和Paint,但是我们不能依赖浏览器的特性,需要在我们自己代码的健壮性上下功夫,从根本上解决这个问题,那就需要将这种多次设置属性的方式,修改为使用className进行替换(代码省略)。

  • 将DOM离线后修改,如:先把DOM给display:none(一次reflow),然后修改N次,再显示出来

这个离线修改的概念,并不是传统意义上的离线,我们使用的方式就是先隐藏我们的DOM元素,然后修改完属性之后,再显示出来,这样,尽管进行了多次的属性修改,回流与重绘,只会发生一次。


  setTimeout(() = > {
    let ele = document.getElementById('box');
    ele.style.display = 'none';
    // 此处多次修改属性,并不是最佳实践,仅进行举例使用
    ele.style.top= '100px';
    ele.style.left = '100px';
    ele.style.height = '200px';
    ele.style.width = '300px';
    ele.style.display = 'block';
  },2000)

  • 不要将DOM节点的属性放在一个循环里当成循环的变量(offsetHeight/offsetWidth)

不论是获取DOM节点的属性,还是获取DOM节点,我们都需要进行“缓存”,结合循环的优化,举例:


  let doms = []; // 选择出dom的一个集合;
  let domsTop = [];
  for (let i = doms.length - 1;i <= 0; i--) {
    let clientHeight = document.body.clientHeight; //可视区域的高度
    domsTop.push(clientHeight + i * 100);
  }

从上面的代码中,可以看到,每一次的循环,都获取了body的clientHeight属性,这将导致一个什么问题呢?这会导致,不停获取节点属性,导致浏览器的缓冲区域不停的刷新,降低DOM渲染的效率,那么,我们就需要将获取DOM属性的语句,放到for循环外,进行缓存,这样,我们获取DOM属性只获取了一次,在for循环中,使用的是缓存变量,就不会发生频繁刷新缓冲区的问题。

  • 不要使用table布局,很小的改动都会造成整个table的重新布局

这一点,是针对我们当前设备端来说,相关性最高的一点,因为我们设备端页面,除了table,就是table布局,并不是最佳实践,以为我们修改了其中的某一行或者某个单元格,就都造成整个table的重新布局,导致Layout的时间增长,举例来说:


  <table width="50%" border="1">
    <tr>
      <td>test1</td>
      <td>test2</td>
    </tr>
    <!-- 这里省略18个tr -->
    <tr>
      <td>test1</td>
      <td id="last">test2</td>
    </tr>
  </table>

  setTimeout(() => {
    let ele = document.getElementById('last');
    ele.style.width = '200px';
  })

在上面的代码中,我们写了20个tr,然后修改最后一个td的宽度,来看一下Layout的时间:

Web前端性能优化——浅析回流与重绘-Rome-Web 前端开发博客

然后修改为div布局:


  <div>
    <div>
      <span>test1</span>
      <span>test2</span>
    </div>
    <!-- 这里省略18个div -->
    <div>
      <span id="last">test1</span>
      <span>test2</span>
    </div>
  </div>

  setTimeout(() => {
    let ele = document.getElementById('last');
    ele.style.width = '200px';
  })

Layout和Painting的时间如图所示:

Web前端性能优化——浅析回流与重绘-Rome-Web 前端开发博客

通过两种布局的对比,在渲染上可以看出明显的差距,所以,在布局上,尽量不要使用table布局,尽量选择渲染成本低的布局方式。

  • 关于动画的优化,动画的渲染,存在UI线程阻塞JS线程的问题,建议选择合适的执行速度,对于动画新建图层,并且启用GPU硬件加速

通过一段小动画,继续探讨这个问题,请看代码:


  let ele = document.getElementById('box');
  let distance = 0;
  setInterval(() => {
    distance ++;
    ele.style.left = distance + 'px';
    ele.style.left = distance + 'px';
  },10)

从上面的代码中,可以看到我们方块box,做一个向右下方45°的运动,因为再定时中,10ms就发生了一次位置变化,当我们设置的interval时间越小,那么我们会获得一个更好动画效果,FPS更高,平滑度更好,而这样多带来的问题,就是更多次的回流与重绘,当然不是10ms一次,浏览器的缓冲机制会将一定时间的回流与重绘,进行合并,在效果与渲染损耗中间,我们要进行取舍,找到一个平衡点来实现我们的动画效果。

针对于DOM元素或者动画,使用*{transform:translateZ(0)};和*{will-change:transform};属性都可以为其创建新的Layer,并且启用GPU加速,但是这种方式,也并不是适用于所有的场景,需要在具体场景进行具体的分析,通过优化前后performance的数据对比,来选择合适的优化方式。

Web前端性能优化——浅析回流与重绘-Rome-Web 前端开发博客

上图是通过量化分析的方式,在不同元素个数下,相同的动画类型,资源的消耗对比与FPS的对比。请针对自己的业务场景,进行合适的选择。

以上,就是针对回流与重绘的分析与优化方式,前6点,都是可控的,最后一点,需要针对场景进行取舍,欢迎大家留言补充。