浏览器的渲染引擎
浏览器的主要组件有:用户界面、浏览器引擎、渲染引擎、网络、用户界面后端、JavaScript解释器、数据存储。
浏览器的主要功能就是向服务器发出请求,在浏览器窗口中展示您选择的网络资源。浏览器在解析HTML文档,将网页内容展示到浏览器上的流程,其实就是渲染引擎完成的。
浏览器的渲染过程
我们在这里讨论Gecko和Webkit这两种渲染引擎,其中Firefox 使用的是 Gecko,这是 Mozilla 公司“自制”的呈现引擎。而 Safari 和 Chrome 浏览器使用的都是 WebKit。
WebKit 渲染引擎的主流程:
Mozilla 的 Gecko渲染引擎的主流程:
HTML被HTML解析器解析成DOM 树
css则被css解析器解析成CSSOM 树
结合DOM树和CSSOM树,生成一棵渲染树(Render Tree)
Layout(回流):根据生成的渲染树,进行回流(Layout),得到节点的几何信息(位置,大小)(生成布局(flow),即将所有渲染树的所有节点进行平面合成)
Painting(重绘):根据渲染树以及回流得到的几何信息,得到节点的绝对像素(将布局绘制(paint)在屏幕上)
Display:将像素发送给GPU,展示在页面上。(这一步其实还有很多内容,比如会在GPU将多个合成层合并为同一个层,并展示在页面中。而css3硬件加速的原理则是新建合成层)
第四步和第五步是最耗时的部分,这两步合起来,就是我们通常所说的渲染。
渲染树与渲染对象
生成渲染树
渲染树(render tree
)是由可视化元素按照其显示顺序而组成的树,也是文档的可视化表示。它的作用是让您按照正确的顺序绘制内容。
为了构建渲染树,浏览器主要完成了以下工作:
从DOM树的根节点开始遍历每个可见节点。
对于每个可见的节点,找到CSSOM树中对应的规则,并应用它们。
根据每个可见节点以及其对应的样式,组合生成渲染树。
第一步中,既然说到了要遍历可见的节点,那么我们得先知道,什么节点是不可见的。不可见的节点包括:
一些不会渲染输出的节点,比如script、meta、link等。
一些通过css进行隐藏的节点。比如display:none。注意,利用visibility和opacity隐藏的节点,还是会显示在渲染树上的。只有display:none的节点才不会显示在渲染树上。
注意:渲染树只包含可见的节点
渲染对象
Firefox 将渲染树中的元素称为“框架”。WebKit 使用的术语是渲染器(renderer
)或渲染对象(render object
)。
每一个渲染对象都代表了一个矩形区域,通常对应相关节点的css框,包含宽度、高度和位置等几何信息。框的类型受“display
”样式属性影响,根据不同的 display
属性,使用不同的渲染对象(如 RenderInline
、RenderBlock
、RenderListItem
等对象)。
WebKits RenderObject
类是所有渲染对象的基类,其定义如下:
class RenderObject{
virtual void layout();
virtual void paint(PaintInfo);
virtual void rect repaintRect();
Node* node; //the DOM node
RenderStyle* style; // the computed style
RenderLayer* containgLayer; //the containing z-index layer}
我们可以看到,每个渲染对象都有 layout
和 paint
方法,分别对应了回流和重绘的方法。
渲染
网页生成的时候,至少会渲染一次。
在用户访问的过程中,还会不断重新渲染
重新渲染需要重复之前的第四步(重新生成布局)+第五步(重新绘制)或者只有第五个步(重新绘制)。
重排比重绘大
大,在这个语境里的意思是:谁能影响谁?
重绘:某些元素的外观被改变,例如:元素的填充颜色
重排:重新生成布局,重新排列元素。
就如上面的概念一样,单单改变元素的外观,肯定不会引起网页重新生成布局,但当浏览器完成重排之后,将会重新绘制受到此次重排影响的部分。
比如改变元素高度,这个元素乃至周边dom都需要重新绘制。
也就是说:“重绘”不一定会出现”重排”,”重排”必然会出现”重绘”
重排(reflow)
概念:
当DOM的变化影响了元素的几何信息(DOM对象的位置和尺寸大小),浏览器需要重新计算元素的几何属性,将其安放在界面中的正确位置,这个过程叫做重排。
重排也叫回流
前面我们通过构造渲染树,我们将可见DOM节点以及它对应的样式结合起来,可是我们还需要计算它们在设备视口(viewport)内的确切位置和大小,这个计算的阶段就是回流。
为了弄清每个对象在网站上的确切大小和位置,浏览器从渲染树的根节点开始遍历,我们可以以下面这个实例来表示:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Critial Path: Hello world!</title>
</head>
<body>
<div style=" 50%">
<div style=" 50%">Hello world!</div>
</div>
</body>
</html>
我们可以看到,第一个div将节点的显示尺寸设置为视口宽度的50%,第二个div将其尺寸设置为父节点的50%。而在回流这个阶段,我们就需要根据视口具体的宽度,将其转为实际的像素值。(如下图)
常见引起重排属性和方法
任何会改变元素几何信息(元素的位置和尺寸大小)的操作,都会触发重排,下面列一些栗子:
添加或者删除可见的DOM元素;
元素的尺寸发生变化(包括外边距、内边框、边框大小、高度和宽度等)
元素的位置发生变化
内容变化,比如文本变化或图片被另一个不同尺寸的图片所替代
页面一开始渲染的时候(这肯定避免不了)
浏览器的窗口尺寸变化(因为回流是根据视口的大小来计算元素的位置和大小的)
计算 offsetWidth 和 offsetHeight 属性
设置 style 属性的值
改变这些属性会触发回流:
盒模型相关的属性: width
,height
,margin
,display
,border
等
定位属性及浮动相关的属性: top
,position
,float
等
改变节点内部文字结构也会触发回流:text-align
, overflow
, font-size
, line-height
, vertival-align
等
重排影响的范围:
由于浏览器渲染界面是基于流失布局模型的,所以触发重排时会对周围DOM重新排列,影响的范围有两种:
全局范围:从根节点html
开始对整个渲染树进行重新布局。
局部范围:对渲染树的某部分或某一个渲染对象进行重新布局
全局范围重排:(比如,滚动条出现的时候或者修改了根节点)
<body>
<div class="hello">
<h4>hello</h4>
<p><strong>Name:</strong>BDing</p>
<h5>male</h5>
<ol>
<li>coding</li>
<li>loving</li>
</ol>
</div>
</body>
当p节点上发生reflow时,hello和body也会重新渲染,甚至h5和ol都会收到影响。
局部范围重排:
用局部布局来解释这种现象:把一个dom的宽高之类的几何信息定死,然后在dom内部触发重排,就只会重新渲染该dom内部的元素,而不会影响到外界。
尽可能的减少重排的次数、重排范围:
重排需要更新渲染树,性能花销非常大:
它们的代价是高昂的,会破坏用户体验,并且让UI展示非常迟缓,我们需要尽可能的减少触发重排的次数。
重排的性能花销跟渲染树有多少节点需要重新构建有关系:
所以我们应该尽量以局部布局的形式组织html
结构,尽可能小的影响重排的范围。
而不是像全局范围的示例代码一样一溜的堆砌标签,随便一个元素触发重排都会导致全局范围的重排。
“回流”还是”重排”?
本质上它们是同样的流程,只是在不同浏览器引擎下的“说法”有所差异。
Gecko 将视觉格式化元素组成的树称为 “Frame tree
” 框架树
。每个元素都是一个框架;
对于元素的放置,将其称为 “Reflow
” 回流
。
WebKit 使用的术语是 “Render Tree
” 渲染树
,它由”Render Objects
“组成。对于元素的放置,WebKit 使用的术语是 “Layout
” 布局
(或Relayout重排
)
重绘(repaint):
概念:
当一个元素的外观发生改变,但没有改变布局,重新把元素外观绘制出来的过程,叫做重绘。
最终,我们通过构造渲染树和回流阶段,我们知道了哪些节点是可见的,以及可见节点的样式和具体的几何信息(位置、大小),那么我们就可以将渲染树的每个节点都转换为屏幕上的实际像素,这个阶段就叫做重绘节点。
重绘是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。在重绘阶段,系统会遍历渲染树,并调用渲染对象的“paint
”方法,将渲染对象的内容显示在屏幕上。
常见的引起重绘的属性:
绘制顺序
绘制的顺序其实就是元素进入堆栈样式上下文的顺序。这些堆栈会从后往前绘制,因此这样的顺序会影响绘制。块渲染对象的堆栈顺序如下:
1、背景颜色
2、背景图片
3、边框
4、子代
5、轮廓
浏览器的渲染队列:
思考以下代码将会触发几次渲染?
div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';
根据我们上文的定义,这段代码理论上会触发4次重排+重绘,因为每一次都改变了元素的几何属性,实际上最后只触发了一次重排,这都得益于浏览器的渲染队列机制:
当我们修改了元素的几何属性,导致浏览器触发重排或重绘时。它会把该操作放进渲染队列,等到队列中的操作到了一定的数量或者到了一定的时间间隔时,浏览器就会批量执行这些操作。
强制刷新队列:
div.style.left = '10px';
console.log(div.offsetLeft);
div.style.top = '10px';
console.log(div.offsetTop);
div.style.width = '20px';
console.log(div.offsetWidth);
div.style.height = '20px';
console.log(div.offsetHeight);
这段代码会触发4次重排+重绘,因为在console
中你请求的这几个样式信息,无论何时浏览器都会立即执行渲染队列的任务,即使该值与你操作中修改的值没关联。
因为队列中,可能会有影响到这些值的操作,为了给我们最精确的值,浏览器会立即重排+重绘。
强制刷新队列的style样式请求:
offsetTop, offsetLeft, offsetWidth, offsetHeight
scrollTop, scrollLeft, scrollWidth, scrollHeight
clientTop, clientLeft, clientWidth, clientHeight
getComputedStyle(), 或者 IE的 currentStyle
我们在开发中,应该谨慎的使用这些style请求,注意上下文关系,避免一行代码一个重排,这对性能是个巨大的消耗
重排优化建议
就像上文提到的我们要尽可能的减少重排次数、重排范围,这样说很泛,下面是一些行之有效的建议,大家可以参考一下。
1. 分离读写操作
div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';
console.log(div.offsetLeft);
console.log(div.offsetTop);
console.log(div.offsetWidth);
console.log(div.offsetHeight);
还是上面触发4次重排+重绘的代码,这次只触发了一次重排:
在第一个console
的时候,浏览器把之前上面四个写操作的渲染队列都给清空了。剩下的console,因为渲染队列本来就是空的,所以并没有触发重排,仅仅拿值而已。
2. 样式集中改变
div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';
虽然现在大部分浏览器有渲染队列优化,不排除有些浏览器以及老版本的浏览器效率仍然低下:
建议通过改变class或者csstext属性集中改变样式
// bad
var left = 10;
var top = 10;
el.style.left = left + "px";
el.style.top = top + "px";
// good
el.className += " theclassname";
// good
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
3. 缓存布局信息(避免触发同步布局事件)
当我们访问元素的一些属性的时候,会导致浏览器强制清空队列,进行强制同步布局。举个例子,比如说我们想将一个p标签数组的宽度赋值为一个元素的宽度,我们可能写出这样的代码:
function initP() {
for (let i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = box.offsetWidth + 'px';
}
}
这段代码看上去是没有什么问题,可是其实会造成很大的性能问题。在每次循环的时候,都读取了box的一个offsetWidth属性值,然后利用它来更新p标签的width属性。这就导致了每一次循环的时候,浏览器都必须先使上一次循环中的样式更新操作生效,才能响应本次循环的样式读取操作。每一次循环都会强制浏览器刷新队列。我们可以优化为:
const width = box.offsetWidth;
function initP() {
for (let i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = width + 'px';
}
}
避免循环读取offsetLeft等属性,在循环之前把它们存起来
另外一个例子:
// bad 强制刷新 触发两次重排
div.style.left = div.offsetLeft + 1 + 'px';
div.style.top = div.offsetTop + 1 + 'px';
// good 缓存布局信息 相当于读写分离
var curLeft = div.offsetLeft;
var curTop = div.offsetTop;
div.style.left = curLeft + 1 + 'px';
div.style.top = curTop + 1 + 'px';
避免循环读取offsetLeft等属性,在循环之前把它们存起来
4. 离线改变dom
批量修改DOM
当我们需要对DOM进行一系列修改的时候,可以通过以下步骤减少回流重绘次数:
使元素脱离文档流
对其进行多次修改
将元素带回到文档中。
该过程的第二步和第三步可能会引起回流,但是经过第一步之后,对DOM的所有修改都不会引起回流,因为它已经不在渲染树了。
有三种方式可以让DOM脱离文档流:
隐藏要修改的元素,应用修改,重新显示
在要操作dom之前,通过display隐藏dom,当操作完成之后,才将元素的display属性为可见,因为不可见的元素不会触发重排和重绘。
dom.display = ‘none’
// 修改dom样式
dom.display = ‘block’
通过使用DocumentFragment创建一个dom
碎片,在它上面批量操作dom,操作完成之后,再添加到文档中,这样只会触发一次重排。
将原始元素拷贝到一个脱离文档的节点中,修改节点后,再替换原始的元素。
考虑我们要执行一段批量插入节点的代码:
function appendDataToElement(appendToElement, data) {
let li;
for (let i = 0; i < data.length; i++) {
li = document.createElement('li');
li.textContent = 'text';
appendToElement.appendChild(li);
}
}
const ul = document.getElementById('list');
appendDataToElement(ul, data);
如果我们直接这样执行的话,由于每次循环都会插入一个新的节点,会导致浏览器回流一次。
我们可以使用这三种方式进行优化:
隐藏元素,应用修改,重新显示
这个会在展示和隐藏节点的时候,产生两次重绘
function appendDataToElement(appendToElement, data) {
let li;
for (let i = 0; i < data.length; i++) {
li = document.createElement('li');
li.textContent = 'text';
appendToElement.appendChild(li);
}
}
const ul = document.getElementById('list');
ul.style.display = 'none';
appendDataToElement(ul, data);
ul.style.display = 'block';
使用文档片段(document fragment)在当前DOM之外构建一个子树,再把它拷贝回文档
const ul = document.getElementById('list');
const fragment = document.createDocumentFragment();
appendDataToElement(fragment, data);
ul.appendChild(fragment);
将原始元素拷贝到一个脱离文档的节点中,修改节点后,再替换原始的元素。
const ul = document.getElementById('list');
const clone = ul.cloneNode(true);
appendDataToElement(clone, data);
ul.parentNode.replaceChild(clone, ul);
对于上述那种情况,我写了一个demo来测试修改前和修改后的性能。然而实验结果不是很理想。
原因:原因其实上面也说过了,浏览器会使用队列来储存多次修改,进行优化,所以对这个优化方案,我们其实不用优先考虑。
5. position属性为absolute或fixed
position属性为absolute或fixed的元素,重排开销比较小,不用考虑它对其他元素的影响
6. 优化动画
对于复杂动画效果,使用绝对定位让其脱离文档流
可以把动画效果应用到position属性为absolute或fixed的元素上,这样对其他元素影响较小
对于复杂动画效果,使用绝对定位让其脱离文档流,否则会引起父元素及后续元素大量的回流
动画效果还应牺牲一些平滑,来换取速度,这中间的度自己衡量:
比如实现一个动画,以1个像素为单位移动这样最平滑,但是reflow就会过于频繁,大量消耗CPU资源,如果以3个像素为单位移动则会好很多。
css3硬件加速(GPU加速)
比起考虑如何减少回流重绘,我们更期望的是,根本不要回流重绘。这个时候,css3硬件加速就闪亮登场啦!!(最佳的性能渲染流程,就是直接避开回流和重绘,只运行Composite
合成这一操作。)
划重点:使用css3硬件加速,可以让transform、opacity、filters这些动画不会引起回流重绘 。但是对于动画的其它属性,比如background-color这些,还是会引起回流重绘的,不过它还是可以提升这些动画的性能。
GPU(图像加速器):
GPU 硬件加速是指应用 GPU 的图形性能对浏览器中的一些图形操作交给 GPU 来完成,因为 GPU 是专门为处理图形而设计,所以它在速度和能耗上更有效率。
GPU 加速通常包括以下几个部分:Canvas2D,布局合成, CSS3转换(transitions),CSS3 3D变换(transforms),WebGL和视频(video)。
如何使用
常见的触发硬件加速的css属性:
transform
opacity
filters
Will-change
css3硬件加速的坑
如果你为太多元素使用css3硬件加速,会导致内存占用较大,会有性能问题。
在GPU渲染字体会导致抗锯齿无效。这是因为GPU和CPU的算法不同。因此如果你不在动画结束的时候关闭硬件加速,会产生字体模糊。
7. 避免使用table
布局
HTML 采用基于流的布局模型,从根渲染对象(即<html>
)开始,递归遍历部分或所有的框架层次结构,为每一个需要计算的渲染对象计算几何信息,大多数情况下只要一次遍历就能计算出几何信息。但是也有例外,比如<table>
的计算就需要不止一次的遍历。
参考
浏览器重绘(repaint)重排(reflow)与优化[浏览器机制](优先)
前端回流/重排reflow与重绘redraw(主要看问题)