css

页面的重绘与回流

理解页面的渲染机制

Posted by Li Yucang on July 21, 2017

页面的重绘与回流

写在前面

资源加载执行循序

关于 JS 和 CSS 资源在 html 中的位置对自己以及其他资源加载的影响,这一块笔者一直存在疑惑,翻阅了很多资料,发现不同浏览器对资源的加载有一套自己的优化方案,所以并没有明确的答案。但不管怎么优化,有几个加载过程都会遵守的守则:

  1. JS 有可能会修改 DOM, 比如 document.write. 这意味着,在当前 JS 加载和执行完成前,后续所有资源的下载有可能是没必要的。这是 JS 阻塞后续资源下载的根本原因。
  2. JS 的执行有可能依赖最新样式。比如,可能会有 var width = $('#id').width(). 这意味着,JS 代码在执行前,浏览器必须保证在此 JS 之前的所有 css(无论外链还是内嵌)都已下载和解析完成。这是 CSS 阻塞后续 JS 执行的根本原因。
  3. 现代浏览器很聪明,会进行 preloader 优化。性能是如此重要,现代浏览器在竞争中,在 UI update 线程之外,还会开启另一个线程,对后续 JS 和 CSS 提前下载(注意,仅提前下载,并不执行)。有了 prefetch 优化,这意味着,在不存在任何阻塞的情况下,理论上 JS 和 CSS 的下载时机都非常优先,和位置无关。

关于浏览器的 preloader 机制,这里补充一下,并没有统一的标准规定这套机制应具备何种功能已经如何实现。但你可以大致这么理解:浏览器通常会准备两个页面解析器 parser,一个(main parser)用于正常的页面解析,而另一个(preloader)则试图去文档中搜寻更多需要加载的资源,但这里的资源通常仅限于外链的 js、stylesheet、image;不包括 audio、video 等。并且动态插入页面的资源无效。

但细节方面却值得注意:

  1. 比如关于 preloader 的触发时机,并非与解析页面同时开始,而通常是在加载某个 head 中的外链脚本阻塞了 main parser 的情况下才启动;
  2. 也不是所有浏览器的 preloader 会把图片列为预加载的资源,可能它认为图片加载过于耗费带宽而不把它列为预加载资源之列;
  3. preloader 也并非最优,在某些浏览器中它会阻塞 body 的解析。因为有的浏览器将页面文档拆分为 head 和 body 两部分进行解析,在 head 没有解析完之前,body 不会被解析。一旦在解析 head 的过程中触发了 preloader,这无疑会导致 head 的解析时间过长。

页面渲染过程

浏览器在取得 css 和 html 代码后,会进行如下解析:

  1. 浏览器把获取到的 HTML 代码解析成 1 个 DOM 树,HTML 中的每个 tag 都是 DOM 树中的 1 个节点,根节点就是我们常用的 document 对象。DOM 树里包含了所有 HTML 标签,包括 display:none 隐藏,还有用 JS 动态添加的元素等。
  2. 浏览器把所有样式(用户定义的 CSS 和用户代理)解析成样式结构体,在解析的过程中会去掉浏览器不能识别的样式,比如 IE 会去掉-moz 开头的样式,而 FF 会去掉_开头的样式。

  3. DOM Tree 和样式结构体组合后构建 render tree, render tree 类似于 DOM tree,但区别很大,render tree 能识别样式,render tree 中每个 NODE 都有自己的 style,而且 render tree 不包含隐藏的节点 (比如 display:none 的节点,还有 head 节点),因为这些节点不会用于呈现,而且不会影响呈现的,所以就不会包含到 render tree 中。注意 visibility:hidden 隐藏的元素还是会包含到 render tree 中的,因为 visibility:hidden 会影响布局(layout),会占有空间。根据 CSS2 的标准,render tree 中的每个节点都称为 Box (Box dimensions),理解页面元素为一个具有填充、边距、边框和位置的盒子。

  4. 一旦 render tree 构建完毕后,浏览器就可以根据 render tree 来绘制页面了。

回流(Reflow)与重绘(Repaint)

当 render tree 中的一部分(或全部)因为元素的规模尺寸,布局,隐藏等改变而需要重新构建。这就称为回流(reflow)。每个页面至少需要一次回流,就是在页面第一次加载的时候。在回流的时候,浏览器会使渲染树中受到影响的部分失效,并重新构造这部分渲染树,完成回流后,浏览器会重新绘制受影响的部分到屏幕中,该过程称为重绘。

当 render tree 中的一些元素需要更新属性,而这些属性只是影响元素的外观,风格,而不会影响布局的,比如 background-color。则就叫称为重绘。

注意:回流必将引起重绘,而重绘不一定会引起回流。 我们需要明白,页面若发生回流则需要付出很高的代价。

回流何时发生:

当页面布局和几何属性改变时就需要回流。下述情况会发生浏览器回流:

  1. 添加或者删除可见的 DOM 元素;
  2. 元素位置改变;
  3. 元素尺寸改变——边距、填充、边框、宽度和高度
  4. 内容改变——比如文本改变或者图片大小改变而引起的计算值宽度和高度改变;
  5. 页面渲染初始化;
  6. 浏览器窗口尺寸改变——resize 事件发生时;
  7. DOM 操作:offsetWidth, width, clientWidth, scrollTop/scrollHeight 的计算, 会使浏览器将渐进回流队列 Flush,立即执行回流。
  8. 元素内容变化,尤其是输入控件
  9. CSS 伪类激活

浏览器优化策略

现代浏览器很多都会对回流、重绘操作进行优化,浏览器会维护 1 个队列,把所有会引起回流、重绘的操作放入这个队列,等队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会 flush 队列,进行一个批处理。这样就会让多次的回流、重绘变成一次回流重绘。

虽然有了浏览器的优化,但有时候我们写的一些代码可能会强制浏览器提前 flush 队列,这样浏览器的优化可能就起不到作用了。当你请求向浏览器请求一些 style 信息的时候,就会让浏览器 flush 队列,比如:

  1. offsetTop, offsetLeft, offsetWidth, offsetHeight

  2. scrollTop/Left/Width/Height

  3. clientTop/Left/Width/Height

  4. width,height

  5. 请求了 getComputedStyle(), 或者 IE 的 currentStyle // 这个属性表示经过计算过最终的样式

当你请求上面的一些属性的时候,浏览器为了给你最精确的值,需要 flush 队列,因为队列中可能会有影响到这些值的操作。即使你获取元素的布局和样式信息跟最近发生或改变的布局信息无关,浏览器都会强行刷新渲染队列。

如何减少回流、重绘

减少回流、重绘其实就是需要减少对 render tree 的操作(合并多次多 DOM 和样式的修改),并减少对一些 style 信息的请求,尽量利用好浏览器的优化策略。具体方法有:

改 className

通过提前设置好相关样式,再改变 className 为设置好的 className 即可达到改变样式的效果。这种方法有一个限制:只能运用提前设置好的样式,如果是要动态设置样式就需要使用下面提到的 cssText 了。

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

使用 cssText

cssText 的本质就是设置 HTML 元素的 style 属性值。

在某些浏览器中(比如 Chrome),你给他赋什么值,它就返回什么值。在 IE 中则比较痛苦,它会格式化输出、会把属性大写、会改变属性顺序、会去掉最后一个分号,比如:

document.getElementById("d1").style.cssText = "color:red; font-size:13px;";
console.log(document.getElementById("d1").style.cssText);
在 IE 中值为:FONT-SIZE: 13px; COLOR: red

一般情况下我们用 js 设置元素对象的样式会使用这样的形式:

var element= document.getElementById(“id”);
element.style.width=”20px”;
element.style.height=”20px”;
element.style.border=”solid 1px red”;

样式一多,代码就很多;而且通过 JS 来覆写对象的样式是比较典型的一种销毁原样式并重建的过程,这种销毁和重建,都会增加浏览器的开销。

使用 cssText 语法为:

obj.style.cssText=”样式”;
element.style.cssText=”width:20px;height:20px;border:solid 1px red;”;

这样就可以尽量避免页面多次 reflow,提高页面性能。

但是,这样会有一个问题,会把原有的 cssText 清掉,比如原来的 style 中有’display:none;’,那么执行完上面的 JS 后,display 就被删掉了。为了解决这个问题,可以采用 cssText 累加的方法:

Element.style.cssText += ‘width:100px;height:100px;top:100px;left:100px;’

注意,由于 ie 中 cssText 会格式化输出、会把属性大写、会改变属性顺序、会去掉最后一个分号,上面 cssText 累加的方法在 IE 中是无效的,我们可以在前面添加一个分号来解决这个问题:

Element.style.cssText += ‘;width:100px;height:100px;top:100px;left:100px;’

隔离操作元素

让要操作的元素进行”离线处理”,处理完后一起更新。

documentFragment

使用 documentFragment 进行缓存操作,引发一次回流和重绘;

//不好的写法(模式中所说的反模式)
var p, t;
p = document.creatElement('p');
t = document.creatTextNode('fist paragraph');
p.appendChild(t);
document.body.appendChild(p);  //将引起一次回流

p = document.creatElement('p');
t = document.creatTextNode('second paragraph');
p.appendChild(t);
document.body.appendChild(p);  //将再引起一次回流

//好的写法
var p, t, frag;
frag = document.creatDocumentFragment();
p = document.creatElement('p');
t = document.creatTextNode('fist paragraph');
p.appendChild(t);
farg.appendChild(p);

p = document.creatElement('p');
t = document.creatTextNode('second paragraph');
p.appendChild(t);
farg.appendChild(p);

document.body.appendChild(frag);    //相比前面的方法,这里仅仅引起一次回流,倘若页面里有很多这样的操作,利用文档随便将会提升很多

display:none

使用 display:none 技术,只引发两次回流和重绘; ( 只是减少重绘和回流的次数,display:none 是会引起重绘并回流,相对来说,visibility: hidden 只会引起重绘 )

cloneNode 和 replaceChild

使用 cloneNode(true or false) 和 replaceChild 技术,引发一次回流和重绘;

//建立克隆镜像
var oldNode = document.getElementById('target'),
      clone = oldNode.cloneNode(true);   //深复制

//   处理克隆对象的操作....

//完成后
oldNode.parentNode.replaceChild(clone,oldNode);

缓存相关属性

不要经常访问会引起浏览器 flush 队列的属性,避免频繁读取会引发回流/重绘的属性,如果确实需要多次使用,就用一个变量缓存起来。

//BAD WAY
for(循环) {
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 (循环) {
left += 10;
top += 10;
s.left = left + "px";
s.top = top + "px";
}

让元素脱离动画流,减少回流的 Render Tree 的规模

对具有复杂动画的元素使用绝对定位,使它脱离文档流,否则会引起父元素及后续元素频繁回流。

其他措施

还有一些比较常见的措施:

  1. 避免使用 CSS 表达式(例如:calc())。

  2. 由于浏览器使用流式布局,对 Render Tree 的计算通常只需要遍历一次就可以完成,但 table 及其内部元素除外,他们可能需要多次计算,通常要花 3 倍于同等元素的时间,这也是为什么要避免使用 table 布局的原因之一。

结语

总之,在平时编码中,我们在进行一些会导致频繁回流的操作时一定要小心,尽量通过合并多次 DOM 和样式的修改来减少对 render tree 的操作。并且,在请求一些 style 信息时,注意是否发生回流,避免对页面性能造成影响。