【高性能JS】重绘、重排与浏览器优化方法

6,702 阅读8分钟

本文参考: 《高性能JS》、网页性能管理详解

基础知识

浏览器下载完页面中的所有组件--HTML标记、JS、CSS、图片--之后会解析并生成两个内部数据结构:

  • DOM 树:表示页面结构
  • 渲染树:表示DOM节点如何显示

网页生成的过程

  1. HTML被HTML解析器解析成DOM 树
  2. css被css解析器解析成CSSOM(CSS Object Model)
  3. attachment DOM 树和CSSOM,生成渲染树(Render Tree)
  4. 生成布局(flow),即将所有渲染树的所有节点进行平面合成
  5. 将布局绘制(paint)在屏幕上

"生成布局"(flow)和"绘制"(paint)这两步,合称为"渲染"(render)。

网页生成的时候,至少会渲染一次。用户访问的过程中,还会不断重新渲染。

节点定义

DOM 树种的每一个需要显示的节点在渲染树中至少存在一个对应的节点(隐藏的DOM元素在渲染树中没有对应的节点)。

渲染树中的节点称为“帧(frames)”或“盒(boxes)”,符合CSS模型的定义。

重排和重绘

定义

  • 重排是什么:重新生成布局。当DOM 的变化影响了元素的几何属性(宽和高)--比如改变边框宽度或给段落增加文字导致行数增加--浏览器需要重新计算元素的几何属性,同样其他元素的几何属性和位置也会因此受到影响。浏览器会使渲染树中受到影响的部分失效,并重新构造渲染树。这个过程称为重排。

  • 重绘是什么:重新绘制。完成重排后,浏览器会重新绘制受影响的部分到屏幕中。这个过程称为重绘。

重排与重绘的关系

重排一定会导致重绘,重绘不一定导致重排。如果DOM变化不影响几何属性,元素的布局没有改变,则只发生一次重绘(不需要重排)。

发生重排的情况

当页面布局和几何属性改变时发生“重排”。如下:

  • 添加或删除可见的DOM 元素
  • 元素位置改变
  • 元素尺寸改变(包括外边距、内边距、边框厚度、宽度、高度等属性改变)
  • 内容改变,例如:文本改变后图片被另一个不同尺寸的图片替代
  • 页面渲染器初始化
  • 浏览器窗口尺寸改变

发生重排的范围

整个页面或局部。例如:当滚动条出现时触发整个页面的重排。

对性能的影响

重排和重绘会不断触发,这是不可避免的。但是,它们非常耗费资源,是导致网页性能低下的根本原因。

提高网页性能,就是要降低"重排"和"重绘"的频率和成本,尽量少触发重新渲染。

渲染树变化的排队

前面提到,DOM变动和样式变动,都会触发重新渲染。但是,浏览器已经很智能了,会尽量把所有的变动集中在一起,排成一个队列,然后一次性执行,尽量避免多次重新渲染。

div.style.color = 'blue';
div.style.marginTop = '30px';

上面代码中,div元素有两个样式变动,但是浏览器只会触发一次重排和重绘。

如果写得不好,就会触发两次重排和重绘。

div.style.color = 'blue';
var margin = parseInt(div.style.marginTop);
div.style.marginTop = (margin + 10) + 'px';

上面代码对div元素设置背景色以后,第二行要求浏览器给出该元素的位置,所以浏览器不得不立即重排。

强制刷新队列

获取布局信息的操作会导致列队刷新,以下属性和方法需要返回最新的布局信息,最好避免使用。

  • offsetTop, offsetLeft, offsetWidth, offsetHeight
  • scrollTop, scrollLeft, scrollWidth, scrollHeight
  • clientTop, clientLeft, clientWidth, clientHeight
  • getComputedStyle() (currentStyle in IE)

clientTop:元素上边框的厚度,当没有指定边框厚底时,一般为0。

scrollTop:位于对象最顶端和窗口中可见内容的最顶端之间的距离,简单地说就是滚动后被隐藏的高度。

offsetTop:获取对象相对于由offsetParent属性指定的父坐标(css定位的元素或body元素)距离顶端的高度。

clientHeight:内容可视区域的高度,也就是说页面浏览器中可以看到内容的这个区域的高度,一般是最后一个工具条以下到状态栏以上的这个区域,与页面内容无关。

scrollHeight:IE、Opera 认为 scrollHeight 是网页内容实际高度,可以小于 clientHeight。FF 认为 scrollHeight 是网页内容高度,不过最小值是 clientHeight。

offsetHeight:获取对象相对于由offsetParent属性指定的父坐标(css定位的元素或body元素)的高度。IE、Opera 认为 offsetHeight = clientHeight + 滚动条 + 边框。FF 认为 offsetHeight 是网页内容实际高度,可以小于clientHeight。offsetHeight在新版本的FF和IE中是一样的,表示网页的高度,与滚动条无关,chrome中不包括滚动条。

Window.getComputedStyle()方法返回一个对象,该对象在应用活动样式表并解析这些值可能包含的任何基本计算后报告元素的所有CSS属性的值。 私有的CSS属性值可以通过对象提供的API或通过简单地使用CSS属性名称进行索引来访问。

解决办法

所以,从性能角度考虑,尽量不要把读操作和写操作,放在一个语句里面。

// bad
div.style.left = div.offsetLeft + 10 + "px";
div.style.top = div.offsetTop + 10 + "px";

// good
var left = div.offsetLeft;
var top  = div.offsetTop;
div.style.left = left + 10 + "px";
div.style.top = top + 10 + "px";

一般的规则是:

  • 样式表越简单,重排和重绘就越快。
  • 重排和重绘的DOM元素层级越高,成本就越高。
  • table元素的重排和重绘成本,要高于div元素。

浏览器优化方法

1. 减少布局信息的获取次数,获取后赋值给局部变量,操作局部变量

当查询布局信息时,比如获取偏移量(offset)、滚动位置(scroll)或计算出的样式值(computedstyle values)时,浏览器为了返回最新值,会刷新队列并应用所有变更。不利于优化。

所以应该尽量减少布局信息的获取次数,获取后把它赋值给局部变量,然后再操作局部变量

// 优化前
myElement.style.left = 1 + myElement.offsetLeft + 'px';
myElement.style.top = 1 + myElement.offsetTop + 'px';
if (myElement.offsetLeft >= 500) {
    stopAnimation();
}

// 优化后
// 获取一次起始位置的值,然后赋值给一个变量,在动画循环中直接使用变量不再查询偏移量
var current = myElement.offsetLeft;
current++;
myElement.style.left = current + 'px';
myElement.style.top = current + 'px';
if (myElement.offsetLeft >= 500) {
    stopAnimation();
}

2. 合并多次对DOM 和样式的修改:使用cssText属性

现在大部分浏览器都自动优化了

// 优化前
 var el = document.getElementById('mydiv');
 el.style.borderLeft = '1px';
 el.style.borderRight = '2px';
 el.style.padding = '5px';
 
 // 优化后
 var el = document.getElementById('mydiv');
 el.style.cssText = 'border-left: 1px; border-right: 2px; padding: 5px;';

3. 合并样式的修改时:修改css的class名称而不是修改内联样式

 var el = document.getElementById('mydiv');
 el.className = "active";

4. 使元素脱离文档流、对其改变后再把元素带回文档中

var ul = document.getElementById('mylist');
ul.style.display = 'none';
appendDataToElement(ul, data); // 更新指定节点数据的函数
ul.style.display = 'block';

5. (推荐使用)在文档之外创建并更新一个文档片段,然后把它附加到原始列表中

文档片段是个轻量级的document对象,用于更新和移动节点。当你附加一个片段到节点中,实际上添加的是该片段的子节点,而不是片段本身。

该方法产生的DOM遍历和重排次数最少。

//创建一个文档片段
var fragment = document.createDocumentFragment();

// 更新文档片段的数据
appendDataToElement(fragment, data);

// 将文档片段附加到原始列表中(实际添加的是子节点)
document.getElementById('mylist').appendChild(fragment);

实例如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>使用fragment进行重排重绘</title>
</head>
<body>
<ul id="myList">
  <li>a</li>
  <li>b</li>
</ul>
<p>
  向上面的ul中加入两个新的li,比较使用fragment和不使用的性能
</p>

<script>
  console.time(0);
  var newLi1 = document.createElement('li');
  newLi1.innerHTML = 'c';

  var newLi2 = document.createElement('li');
  newLi2.innerHTML = 'd';

  document.getElementById('myList').appendChild(newLi1);
  document.getElementById('myList').appendChild(newLi2);
  console.timeEnd(0)

  console.time(1);
  var fragment = document.createDocumentFragment();
  var newLi1 = document.createElement('li');
  newLi1.innerHTML = 'c';

  var newLi2 = document.createElement('li');
  newLi2.innerHTML = 'd';

  fragment.appendChild(newLi1);
  fragment.appendChild(newLi2);

  document.getElementById('myList').appendChild(fragment);
  console.timeEnd(1)
</script>
</body>
</html>

6. 备份一个节点,对副本操作,完成后用副本节点代替旧节点

var old = document.getElementById('mylist');

// 对旧节点备份
var clone = old.cloneNode(true);

appendDataToElement(clone, data);

// 用副本节点代替旧节点
old.parentNode.replaceChild(clone, old);

7. 让元素脱离动画流

许多展开区域的几何动画会将页面其他部分推向下方。一般来说,重排只影响渲染树中的一部分,但是也可能影响很大的部分。

当页面顶部的一个动画推移页面整个余下的部分时,会导致一次代价昂贵的大规模重排。

使用以下步骤可以避免页面中的大部分重排:

  1. 使用绝对位置定位页面上的动画元素,将其脱离文档流
  2. 让元素动起来。当它扩大时,会临时覆盖部分页面。但这只是页面一个小区域的重绘过程,不会产生重排并重绘页面的大部分内容。
  3. 动画结束时恢复定位,从而只会下移一次文档的其他元素。