你真的了解重排和重绘吗?

2,272 阅读17分钟

做过前端开发的小伙伴就算不是非常理解重排与重绘,但是肯定都听过这两个词。那为什么这两个东西这么重要?因为他与我们的页面性能息息相关,今天,我们就来好好研究一下这两个东西。

浏览器的渲染流程

在讲解重排和重绘之前,我们有必要说一下浏览器的渲染流程。下面是浏览器渲染过程中最关键的几个部分。如果想了解完整的浏览器渲染流程,推荐大家去阅读李兵老师的浏览器工作原理实践,需要付费阅读。或者参考我的这篇博文:一文让你彻底搞懂浏览器的渲染流程

在这里插入图片描述

  • JavaScript:一般来说,我们会使用 JavaScript 来实现一些视觉变化的效果。比如用 jQuery 的 animate 函数做一个动画、对一个数据集进行排序或者往页面里添加一些 DOM 元素等。当然,除了 JavaScript,还有其他一些常用方法也可以实现视觉变化效果,比如:CSS Animations、Transitions 和 Web Animation API。
  • 样式计算:此过程是根据匹配选择器(例如 .headline 或 .nav > .nav__item)计算出哪些元素应用哪些 CSS 规则的过程。从中知道规则之后,将应用规则并计算每个元素的最终样式。
  • 布局:在知道对一个元素应用哪些规则之后,浏览器即可开始计算它要占据的空间大小及其在屏幕的位置。网页的布局模式意味着一个元素可能影响其他元素,例如 元素的宽度一般会影响其子元素的宽度以及树中各处的节点,因此对于浏览器来说,布局过程是经常发生的。
  • 绘制:绘制是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。绘制一般是在多个表面(通常称为层)上完成的。
  • 合成:由于页面的各部分可能被绘制到多层,由此它们需要按正确顺序绘制到屏幕上,以便正确渲染页面。对于与另一元素重叠的元素来说,这点特别重要,因为一个错误可能使一个元素错误地出现在另一个元素的上层。

其中,重排和重绘影响的就是其中的布局和绘制过程。

什么是重排和重绘制

  • 重排:当DOM的变化引发了元素几何属性的变化,比如改变元素的宽高,元素的位置,导致浏览器不得不重新计算元素的几何属性,并重新构建渲染树,这个过程称为“重排”。
  • 重绘:完成重排后,要将重新构建的渲染树渲染到屏幕上,这个过程就是“重绘”。

简单来说,涉及元素的几何更新时,叫重排。而只涉及样式更新而不涉及几何更新时,叫重绘。对于两者来说,重排必定引起重绘,但是重绘并不一定引起重排。所以,当涉及重排时,浏览器会将上述的步骤再次执行一遍。当只涉及重绘时,浏览器会跳过Layout步骤,即:

在这里插入图片描述
而如果既不需要重排,也不需要重绘,那么就是下面这样:
在这里插入图片描述
浏览器会直接跳到合成阶段。显然,对于页面性能来说,不重排也不重绘 > 重绘 > 重排。

什么操作会引起重排和重绘

显然,触发重排的一般都是几何因素,这是比较好理解的:

  • 页面第一次渲染 在页面发生首次渲染的时候,所有组件都要进行首次布局,这是开销最大的一次重排
  • 浏览器窗口尺寸改变
  • 元素位置和尺寸发生改变的时候
  • 新增和删除可见元素
  • 内容发生改变(文字数量或图片大小等等)
  • 元素字体大小变化

还有其他一些操作也可能引发重排

  • 查询某些属性或调用某些方法
    • offset(Top|Left|Width|Height)
    • scroll(Top|Left|Width|Height)
    • client(Top|Left|Width|Height)
    • getComputedStyle()

我们可能不太理解为什么这些操作也能引起重排,这里我先简单解释一下。因为现在的浏览器已经非常完善了,会自动帮我们做一些优化。当我们用js操作DOM的时候,浏览器并不是立马执行的,而是将操作存储在一个队列中。当达到一定数量或者经过一定时间以后浏览器再统一的去执行队列中的操作。那么回到我们刚才的问题,为什么查询这些属性也会导致重排?因为当你查询这些属性时,浏览器就会强制刷新队列,因为如果不立马执行队列中的操作,有可能得到的结果就是错误的。所以相当于你强制打断了浏览器的优化流程,引发了重排。下面我们通过一些小例子来进一步理解这段话:

首先我们来一个显然会引发重排的操作

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    #test {
      width: 100px;
      height: 100px;
      background-color: red;
      position: relative;
    }
  </style>
</head>
<body>
  <div id="test">

  </div>
  <button onclick="reflow()">click</button>
  <script>
    function reflow() {
      var div = document.querySelector("#test");
      div.style.left = '200px';
    }
  </script>
</body>
</html>

当我们点击按钮时,将div的left设置为200px,很显然,这个操作会引发浏览器的重排。下面我们用chrome自带的performence工具分析一下左移的过程。

在这里插入图片描述
可以看到这时已经触发了点击事件,此时我们的div.style.left='200px'的代码显然已经生效了,但是浏览器并没有立马更新布局,我们再往后看
在这里插入图片描述
把时间轴往后拉,可以看到这几个过程,先简单介绍一些这些名词代表的含义:

  • Recalculate Style:这个过程就是生成CSSOM的过程
  • Layout:这就是布局阶段,即重排的过程
  • Update Layer Tree:这个阶段是更新层树的过程
  • Paint:该阶段是为每一层准备绘制列表的过程
  • Composite Layers:该阶段是利用绘制列表来生成相应图层的位图了,还涉及到合成线程和光栅化,performence面板中的Raster就是光栅化线程池 。

这里只做一个简单的介绍,对其中内容不太明白的同学可以参考李兵老师的文章或者参考我介绍浏览器渲染流程的博客。

那通过这个图我们可以看到,我们改变了div的left之后就触发了Layout,即重排的过程。下面我们仅改变div的背景颜色,给大家一个对比。

在这里插入图片描述
可以看到,如果仅仅改变背景颜色,是没有Layout这个过程的。同时,chrome还提供了一个工具rendering,我们后面也会用到这个工具,如果最顶部找不到的话可以去more tools中找到。
在这里插入图片描述
当你把第一个选项勾上以后,页面中涉及重新渲染的操作都会被绿色的框框表示出来,可以很方便的看到什么元素被重新渲染了。但是这样辨别不了重排和重绘的区别。

下面我们回到最初的问题,我们获取offsetLeft属性时是如何引发重排的?

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    #test {
      width: 100px;
      height: 100px;
      background-color: red;
      position: relative;
    }
  </style>
</head>
<body>
  <div id="test">

  </div>
  <button onclick="reflow()">click</button>
  <script>
    function reflow() {
      var div = document.querySelector("#test");
      console.log(div.offsetLeft);
    }
  </script>
</body>
</html>

我们用chrome工具看看渲染的过程

在这里插入图片描述
当我们点击按钮之后,浏览器立马重新计算了CSSOM,和我们之前的分析一致,浏览器需要强制更新队列。但是在后续的过程中,我们也并没有看到Layout过程,这是因为我们实际上并没有改变几何元素,所以本次操作并没有产生重排。(红框上方的 reflow 为我们的函数名)

那么我们再看下面的代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    #test {
      width: 100px;
      height: 100px;
      background-color: red;
      position: relative;
    }
  </style>
</head>
<body>
  <div id="test">

  </div>
  <button onclick="reflow()">click</button>
  <script>
    function reflow() {
      var div = document.querySelector("#test");
      div.style.left = '200px';
      console.log(div.offsetLeft);
    }
  </script>
</body>
</html>

我们加了一行代码,改变了他的left,我们再来看看会产生什么效果

在这里插入图片描述
点击事件发生后,浏览器立马进行了重排,并没有像以前一样经过一段时间才产生重排。看到这里大家应该明白了,获取 offset 等属性是否会产生重排取决于之前是否有改变几何因素的操作。但是,不论怎样,它还是会重新计算 CSSOM 。那么有的同学可能会问了,所以我使用offsetLeft仅仅是把重排提前了,并没有造成什么实际的影响。对于这种情况而言是这样的,那么我们来看看下面两段代码的区别:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    #test {
      width: 100px;
      height: 100px;
      background-color: red;
      position: relative;
    }
  </style>
</head>
<body>
  <div id="test">

  </div>
  <button onclick="reflow()">click</button>
  <script>
    function reflow() {
      var div = document.querySelector("#test");
      div.style.left = '200px';
      console.log(div.offsetLeft);
      div.style.left = '100px';
      console.log(div.offsetLeft);
      div.style.left = '200px';
      console.log(div.offsetLeft);
      div.style.left = '100px';
      console.log(div.offsetLeft);
    }
  </script>
</body>
</html>

这个操作会产生几次重排呢?我们来看一下:

在这里插入图片描述
因为每次获取属性的时候都需要更新队列,所以一共是产生了四次重排,如果我们修改一下代码的顺序:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    #test {
      width: 100px;
      height: 100px;
      background-color: red;
      position: relative;
    }
  </style>
</head>
<body>
  <div id="test">

  </div>
  <button onclick="reflow()">click</button>
  <script>
    function reflow() {
      var div = document.querySelector("#test");
      div.style.left = '200px';
      div.style.left = '100px';
      div.style.left = '200px';
      div.style.left = '100px';
      console.log(div.offsetLeft);
      console.log(div.offsetLeft);
      console.log(div.offsetLeft);
      console.log(div.offsetLeft);
    }
  </script>
</body>
</html>

我们再来看看过程

在这里插入图片描述
可以看到,只发生了一次重排,reflow的时间比原先少了将近一半。原因很简单:因为第一个console.log会清空之前的四次操作,但是后面队列中已经没有新的操作加入,自然后面三个就不会引发重排。所以可以看出,在代码中合理的使用offset等属性还是非常有必要的。

同时,在其他博客中,我留意到这几种属性也能引发重排:

  • 激活CSS伪类(例如::hover)
  • 设置style属性

但是经过我的尝试,如果单纯的改变背景颜色等非几何属性,是不会引发重排的。而如果是去改变几何属性,那么我觉得就可以归结到最开始的几种情况中去。所以对于这块内容我持怀疑态度。如果有同学了解的希望在评论区指出。

即不重排也不重绘

说完了重排和重绘,不要忘记我们最开始提到的,最高效的方式就是跳过重排和重绘阶段。你可能会想,什么情况下可以做到这一点?其实这就是我们平时说的GPU加速,具体是如何实现呢?在开发过程中,如果我们使用了某些属性,浏览器会帮助我们将使用了该属性的div提升到一个单独的合成层,而在后面的渲染中,提升到该层的div将跳过重排和重绘的操作,直接到合成阶段。在stack overflow上有问题提到了这块内容。我们翻译一下就是: 下面几个属性能让浏览器帮助我们将div提升到一个单独的合成层:

  • 图层具有3D或透视变换CSS属性
  • 使用加速视频解码的 video 元素
  • 拥有 3D(WebGL) 上下文或者加速 2D 上下文的 canvas 元素
  • 混合插件(Flash)
  • 对自己的 opacity 做 CSS 动画或使用一个动画 webkit 变换的元素
  • 图层使用加速的CSS过滤器
  • 层具有作为合成层的后代
  • 图层具有较低z索引的同级元素,该同级元素具有合成层(换句话说,该层在合成层的顶部渲染)
  • css will-change属性

最后一点是我加上去的,同时根据文中的内容我们可以知道,css3硬件加速是浏览器的行为,所以在不同浏览器下可能会有不同的表现形式。下面我们用一个例子来理解一下。这是李兵老师在他的专栏中提出的一个例子,我拿过来借用一下,注意box中的will-change属性:

<html>

  <head>
      <title>观察will-change</title>
      <style>
          .box {
              will-change: transform, opacity;
              display: block;
              float: left;
              width: 40px;
              height: 40px;
              margin: 15px;
              padding: 10px;
              border: 1px solid rgb(136, 136, 136);
              background: rgb(187, 177, 37);
              border-radius: 30px;
              transition: border-radius 1s ease-out;
          }
  
          body {
              font-family: Arial;
          }
      </style>
  </head>
  
  
  <body>
      <div id="controls">
          <button id="start">start</button>
          <button id="stop">stop</button>
      </div>
      <div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
          <div class="box">旋转盒子</div>
      </div>
      <script>
  
          let boxes = document.querySelectorAll('.box');
          let boxes1 = document.querySelectorAll('.box1');
          let start = document.getElementById('start');
          let stop = document.getElementById('stop');
          let stop_flag = false
  
  
          start.addEventListener('click', function () {
              stop_flag = false
              requestAnimationFrame(render);
          })
  
  
          stop.addEventListener('click', function () {
              stop_flag = true
          })
  
  
          let rotate_ = 0
          let opacity_ = 0
          function render() {
              if (stop_flag)
                  return 0
              rotate_ = rotate_ + 6
              if (opacity_ > 1)
                  opacity_ = 0
              opacity_ = opacity_ + 0.01
              let command = 'rotate(' + rotate_ + 'deg)';
              for (let index = 0; index < boxes.length; index++) {
                  boxes[index].style.transform = command
                  boxes[index].style.opacity = opacity_
              }
              requestAnimationFrame(render);
          }
      </script>
  </body>
  
  
  </html>
  

chrome工具为我们提供了查看是否提升了层的方法

在这里插入图片描述
选中layers在选中第二个图标,就可以用鼠标查看3d视图,我们可以看到每个div都被提升到了单独的层。如果我们把will-change去掉再来查看会是下面这样:
在这里插入图片描述
可以看到他们变成全部都在同一层了。那么加不加这个属性到底有没有实际的意义呢?我们下面实际检测一下。首先再介绍一下上文提到过的rendering工具
在这里插入图片描述

  • paint flashing会将渲染的div用绿框标出来
  • layer borders会将在合成层中渲染的div,即提升到单独层的div用黄框标出来
  • FSP meter会标记出当前页面的帧率

首先我们不加will-change来实现动画,可以看到如下效果

在这里插入图片描述
我们的每个div都在不断的重新渲染,同时fps是在30左右,大家自己去尝试就会发现已经能够感到明显的卡顿。我们再加上will-change尝试一下:
在这里插入图片描述
我们可以看到黄框取代了绿框,证明这些小球已经没有重新渲染了,而是被提升到了单独的层,同时fps稳定在60,能够直接感受到比原先顺畅了许多。

通过这个简单的例子,我们就能直观的感受到跳过重排和重绘带来的好处。我们也日常开发中也可以使用

<style>
  .demo {
    transform: translateZ(0);
  }
</style>

来欺骗浏览器,让其帮助我们将div提升到一个单独的层。不过要注意,该方法虽好,但是也会加大内存的消耗,如果将太多没有必要的层单独提升了反而会造成页面的卡顿,具体可以参考一下这篇文章

而关于这块更详细的介绍可以参考淘系前端团队分享的文章 无线性能优化:Composite

写在最后

好了,这就是今天的全部内容,看到的小可爱可以帮忙点个赞和关注,让我有继续写下去的动力~同时文中有什么错误的地方也欢迎大家在评论区指出,共同探讨。