一直都据说DOM很慢,要尽可能少的去操做DOM,因而就想进一步去探究下为何你们都会这样说,在网上学习了一些资料,这边整理出来。 css
首先,DOM对象自己也是一个js对象,因此严格来讲,并非操做这个对象慢,而是说操做了这个对象后,会触发一些浏览器行为,好比布局(layout)和绘制(paint)。下面主要先介绍下这些浏览器行为,阐述一个页面是怎么最终被呈现出来的,另外还会从代码的角度,来讲明一些很差的实践以及一些优化方案。 html
一个浏览器有许多模块,其中负责呈现页面的是渲染引擎模块,比较熟悉的有WebKit和Gecko等,这里也只会涉及这个模块的内容。 web
先用文字大体阐述下这个过程: chrome
其中DOM tree和Render tree上的节点并不是一一对应,好比一个display:none的节点就在会存在与DOM tree上,而不会出如今Render tree上,由于这个节点不须要被绘制。 编程
上图是Webkit的基本流程,在术语上和Gecko可能会有不一样,这里贴上Gecko的流程图,不过文章下面的内容都会统一使用Webkit的术语。 数组
影响页面呈现的因素有许多,好比link的位置会影响首屏呈现等。但这里主要集中讨论与layout相关的内容。 浏览器
paint是一个耗时的过程,然而layout是一个更耗时的过程,咱们没法肯定layout必定是自上而下或是自下而上进行的,甚至一次layout会牵涉到整个文档布局的从新计算。 缓存
可是layout是确定没法避免的,因此咱们主要是要最小化layout的次数。 app
在考虑如何最小化layout次数以前,要先了解何时浏览器会进行layout。 dom
layout(reflow)通常被称为布局,这个操做是用来计算文档中元素的位置和大小,是渲染前重要的一步。在HTML第一次被加载的时候,会有一次layout以外,js脚本的执行和样式的改变一样会致使浏览器执行layout,这也是本文的主要要讨论的内容。
通常状况下,浏览器的layout是lazy的,也就是说:在js脚本执行时,是不会去更新DOM的,任何对DOM的修改都会被暂存在一个队列中,在当前js的执行上下文完成执行后,会根据这个队列中的修改,进行一次layout。
然而有时但愿在js代码中马上获取最新的DOM节点信息,浏览器就不得不提早执行layout,这是致使DOM性能问题的主因。
以下的操做会打破常规,并触发浏览器执行layout:
咱们来经过一个例子直观的感觉下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// Read
var
h1
=
element1
.
clientHeight
;
// Write (invalidates layout)
element1
.
style
.
height
=
(
h1
*
2
)
+
'px'
;
// Read (triggers layout)
var
h2
=
element2
.
clientHeight
;
// Write (invalidates layout)
element2
.
style
.
height
=
(
h2
*
2
)
+
'px'
;
// Read (triggers layout)
var
h3
=
element3
.
clientHeight
;
// Write (invalidates layout)
element3
.
style
.
height
=
(
h3
*
2
)
+
'px'
;
|
这里涉及一个属性clientHeight,这个属性是须要计算获得的,因而就会触发浏览器的一次layout。咱们来利用chrome(v47.0)的开发者工具看下(截图中的timeline record已经通过筛选,仅显示layout):
上面的例子中,代码首先修改了一个元素的样式,接下来读取另外一个元素的clientHeight属性,因为以前的修改致使当前DOM被标记为脏,为了保证能准确的获取这个属性,浏览器会进行一次layout(咱们发现chrome的开发者工具良心的提示了咱们这个性能问题)。
优化这段代码很简单,预先读取所须要的属性,在一块儿修改便可。
1
2
3
4
5
6
7
8
9
|
// Read
var
h1
=
element1
.
clientHeight
;
var
h2
=
element2
.
clientHeight
;
var
h3
=
element3
.
clientHeight
;
// Write (invalidates layout)
element1
.
style
.
height
=
(
h1
*
2
)
+
'px'
;
element2
.
style
.
height
=
(
h2
*
2
)
+
'px'
;
element3
.
style
.
height
=
(
h3
*
2
)
+
'px'
;
|
看下此次的状况:
下面再介绍一些其余的优化方案。
上面提到的一个批量读写是一个,主要是由于获取一个须要计算的属性值致使的,那么哪些值是须要计算的呢?
这个连接里有介绍大部分须要计算的属性:http://gent.ilcore.com/2011/03/how-not-to-trigger-layout-in-webkit.html
再来看看别的状况:
针对一系列DOM操做(DOM元素的增删改),能够有以下方案:
好比(仅以documentFragment为例):
1
2
3
4
5
6
7
|
var
fragment
=
document
.
createDocumentFragment
(
)
;
for
(
var
i
=
0
;
i
<
items
.
length
;
i
++
)
{
var
item
=
document
.
createElement
(
"li"
)
;
item
.
appendChild
(
document
.
createTextNode
(
"Option "
+
i
)
;
fragment
.
appendChild
(
item
)
;
}
list
.
appendChild
(
fragment
)
;
|
这类优化方案的核心思想都是相同的,就是先对一个不在Render tree上的节点进行一系列操做,再把这个节点添加回Render tree,这样不管多么复杂的DOM操做,最终都只会触发一次layout。
针对样式的改变,咱们首先须要知道并非全部样式的修改都会触发layout,由于咱们知道layout的工做是计算RenderObject的尺寸和大小信息,那么我若是只是改变一个颜色,是不会触发layout的。
这里有一个网站CSS triggers,详细列出了各个CSS属性对浏览器执行layout和paint的影响。
像下面这种状况,和上面讲优化的部分是同样的,注意下读写便可。
1
2
3
4
5
|
elem
.
style
.
height
=
"100px"
;
// mark invalidated
elem
.
style
.
width
=
"100px"
;
elem
.
style
.
marginRight
=
"10px"
;
elem
.
clientHeight
// force layout here
|
可是要提一下动画,这边讲的是js动画,好比:
1
2
3
4
5
6
7
8
9
10
11
|
function
animate
(
from
,
to
)
{
if
(
from
===
to
)
return
requestAnimationFrame
(
function
(
)
{
from
+=
5
element1
.
style
.
height
=
from
+
"px"
animate
(
from
,
to
)
}
)
}
animate
(
100
,
500
)
|
动画的每一帧都会致使layout,这是没法避免的,可是为了减小动画带来的layout的性能损失,能够将动画元素绝对定位,这样动画元素脱离文本流,layout的计算量会减小不少。
任何可能致使重绘的操做都应该放入requestAnimationFrame
在现实项目中,代码按模块划分,很难像上例那样组织批量读写。那么这时能够把写操做放在requestAnimationFrame的callback中,统一让写操做在下一次paint以前执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// Read
var
h1
=
element1
.
clientHeight
;
// Write
requestAnimationFrame
(
function
(
)
{
element1
.
style
.
height
=
(
h1
*
2
)
+
'px'
;
}
)
;
// Read
var
h2
=
element2
.
clientHeight
;
// Write
requestAnimationFrame
(
function
(
)
{
element2
.
style
.
height
=
(
h2
*
2
)
+
'px'
;
}
)
;
|
能够很清楚的观察到Animation Frame触发的时机,MDN上说是在paint以前触发,不过我估计是在js脚本交出控制权给浏览器进行DOM的invalidated check以前执行。
除了因为触发了layout而致使性能问题外,这边再列出一些其余细节:
缓存选择器的结果,减小DOM查询。这里要特别体下HTMLCollection。HTMLCollection是经过document.getElementByTagName获得的对象类型,和数组类型很相似可是每次获取这个对象的一个属性,都至关于进行一次DOM查询:
1
2
3
4
|
var
divs
=
document
.
getElementsByTagName
(
"div"
)
;
for
(
var
i
=
0
;
i
<
divs
.
length
;
i
++
)
{
//infinite loop
document
.
body
.
appendChild
(
document
.
createElement
(
"div"
)
)
;
}
|
好比上面的这段代码会致使无限循环,因此处理HTMLCollection对象的时候要最些缓存。
另外减小DOM元素的嵌套深度并优化css,去除无用的样式对减小layout的计算量有必定帮助。
在DOM查询时,querySelector和querySelectorAll应该是最后的选择,它们功能最强大,但执行效率不好,若是能够的话,尽可能用其余方法替代。
下面两个jsperf的连接,能够对比下性能。
https://jsperf.com/getelementsbyclassname-vs-queryselectorall/162
http://jsperf.com/getelementbyid-vs-queryselector/218
上面的内容理论方面的东西偏多,从实践的角度来看,上面讨论的内容,正好是View层须要处理的事情。已经有一个库FastDOM来作这个事情,不过它的代码是这样的:
1
2
3
4
5
6
7
|
fastdom
.
read
(
function
(
)
{
console
.
log
(
'read'
)
;
}
)
;
fastdom
.
write
(
function
(
)
{
console
.
log
(
'write'
)
;
}
)
;
|
问题很明显,会致使callback hell,而且也能够预见到像FastDOM这样的imperative的代码缺少扩展性,关键在于用了requestAnimationFrame后就变成了异步编程的问题了。要让读写状态同步,那必然须要在DOM的基础上写个Wrapper来内部控制异步读写,不过都到了这份上,感受能够考虑直接上React了……
总之,尽可能注意避免上面说到的问题,但若是用库,好比jQuery的话,layout的问题出在库自己的抽象上。像React引入本身的组件模型,用过virtual DOM来减小DOM操做,并能够在每次state改变时仅有一次layout,我不知道内部有没有用requestAnimationFrame之类的,感受要作好一个View层就挺有难度的,以后准备学学React的代码。但愿本身一两年后会过来再看这个问题的时候,能够有些新的看法。