第 3 章 DOM 编程
《高性能 JavaScript》—— Nicholas C. Zakas
用 JS 操作 DOM 的代价很昂贵,这通常是 web 应用的性能瓶颈。
本章讨论三类问题:
- 访问和修改 DOM 元素
- 修改 DOM 元素的样式会导致重绘(repaint)和重排(reflow)
- 通过 DOM 事件处理与用户的交互
1. 浏览器中的 DOM
DOM(Document Object Model,文档对象模型)就是 W3 定义的操作 XML/HTML 的接口层,
浏览器对(DOM)接口层进行实现,我们(开发者)对接口层进行调用,
也就是说,我们通过(DOM)接口层可以操作界面。
浏览器实现 DOM 和 ECMAScript 是不同的两个东西,
比如 Chrome, 使用 webkit 的 WebCore 库来渲染界面,使用 V8 来实现 ECMAScript。
可以认为 webkit 和 v8 是两个不同的孤岛,
它们之期通信是有额外的开销的。
2. DOM 访问与修改
访问 DOM 元素是有代价的,
修改元素代价更大,因为通常会导致重新计算页面的重绘和重排。
尤其是对 HTML 元素集合进行循环操作,如下:
// 总共读写 15000 * 2 次
function innerHTMLLoop() {
for (var count = 0; count < 15000; count++) {
// 先读一次,再写一次
document.getElementById('here').innerHTML += 'a';
}
}
应该尽量减少读写 DOM 的次数,把运算逻辑尽量放到 JS 中去,如下:
// 总共读写 2 次
// 改进后,性能(花费的时间)提升几十倍
function innerHTMLLoop2() {
var content = '';
for (var count = 0; count < 15000; count++) {
content += 'a';
}
document.getElementById('here').innerHTML += content;
}
2.1. innerHTML vs DOM方法
当要修改页面区域时,有两种方式:
innerHTML
document.createElement()
这两种方式的性能相差无几,
在非现代浏览器中,innerHTML
要快一点点,
在现代浏览器中,document.createElement()
要快一点点。
应该根据可读性、稳定性、团队习惯、代码风格来综合考虑使用哪种方式。
2.2. 克隆节点
创建新的页面,除了 document.createElement(tagName)
,
还可以使用 var dupNode = node.cloneNode(deep);
克隆已有的节点。
虽然克隆节点会更有效率,但不是特别明显,只能带来百分之几的性能提升。
2.3. HTML 集合
HTML 集合是是类数组对象,集合里的元素是 Node 节点的引用,获取集合方式:
document.getElementsByName()
document.getElementsByClassName()
document.getElementsByTagName()
document.images
Allimg
elements on the pagedocument.links
Alla
elementsdocument.forms
All formsdocument.forms[0].elements
All fields in the first form on the page- 等等
它们都会返回 HTMLCollection
对象。
HTML集合是实时的,
也就是说,当底层文档对象更新时,它也会自动更新。
HTML集合对象一直与文档保持着连接,
如 length
, 每次获取集合对象的元素个数时,会重新在整个页面查询一次以获取最新的个数。
2.3.1. 昂贵的集合
集合是实时的,如下代码:
var alldivs = document.getElementsByTagName('div');
// 每次执行 alldivs.length
// 都会重新执行一次 `document.getElementsByTagName('div')` 获取最新的 length
for (var i = 0; i < alldivs.length; i++) {
// 往页面不断插入新元素
document.body.appendChild(document.createElement('div'))
}
上面的代码是个死循环。
比较如下操作:
// 慢
// 每次访问 length 都会重新查询所有 div
function loopCollection() {
var coll = document.getElementsByTagName('div');
for (var count = 0; count < coll.length; count++) {
/* do nothing */
}
}
// 快
// 缓存 length,比上面代码快 几倍 ~ 几十倍
function loopCacheLengthCollection() {
var coll = document.getElementsByTagName('div'),
len = coll.length;
for (var count = 0; count < len; count++) {
/* do nothing */
}
}
function toArray(coll) {
for (var i = 0, a = [], len = coll.length; i < len; i++) {
a[i] = coll[i];
}
return a;
}
// 快
// 将集合元素拷贝到数组,比上面代码快一点点,因为访问数组元素比访问类数组元素快
function loopCopiedArray() {
var coll = document.getElementsByTagName('div');
var arr = toArray(coll);
for (var count = 0; count < arr.length; count++) {
/* do nothing */
}
}
2.3.2. 使用局部变量缓存集合元素
当遍历 HTML 集合时,优化原则如下:
- 缓存集合对象: 把集合存储在局部变量中
- 缓存
length
属性: 把length
缓存在循环外部 - 缓存集合元素: 把多次访问的集合元素放到局部变量中
代码如下:
// 慢
function collectionGlobal() {
var coll = document.getElementsByTagName('div'),
len = coll.length,
name = '';
for (var count = 0; count < len; count++) {
name = document.getElementsByTagName('div')[count].nodeName;
name = document.getElementsByTagName('div')[count].nodeType;
name = document.getElementsByTagName('div')[count].tagName;
}
return name;
};
// 较快
// 比上面代码快 几倍 ~ 几十倍,缓存集合对象,缓存 length 属性
function collectionLocal() {
var coll = document.getElementsByTagName('div'),
len = coll.length,
name = '';
for (var count = 0; count < len; count++) {
name = coll[count].nodeName;
name = coll[count].nodeType;
name = coll[count].tagName;
}
return name;
};
// 最快
// 比上面代码快一点点,缓存集合对象,缓存 length 属性,缓存多次访问的集合元素
function collectionNodesLocal() {
var coll = document.getElementsByTagName('div'),
len = coll.length,
name = '',
el = null;
for (var count = 0; count < len; count++) {
el = coll[count];
name = el.nodeName;
name = el.nodeType;
name = el.tagName;
}
return name;
};
2.4. 遍历 DOM
DOM API 提供了很多方法来访问指定部分的文档结构,你可以根据情况选择高效的方式。
2.4.1. 遍历子节点
childNodes
、nextSibling
都可以用于遍历子节点,代码如下:
function testNextSibling() {
var el = document.getElementById('mydiv'),
ch = el.firstChild,
name = '';
do {
name = ch.nodeName;
console.log(name);
} while (ch = ch.nextSibling);
}
function testChildNodes() {
var el = document.getElementById('mydiv'),
ch = el.childNodes,
len = ch.length,
name = '';
for (var count = 0; count < len; count++) {
name = ch[count].nodeName;
console.log(name);
}
}
上面的代码,执行速度差不多。
2.4.2. Element 节点
div
、p
、a
等等这些标签都是 Element
节点
Element
节点继承 Node
节点,
而文本节点、注释节点也继承 Node
节点。
有时,我们需要剔除掉非 Element
节点,DOM 提供了一些方法
返回 Element 节点 | 返回 Node 节点 |
---|---|
children | childNodes |
childElementCount | childNodes.length |
firstElementChild | firstChild |
lastElementChild | lastChild |
nextElementSibling | nextSibling |
previousElementSibling | previousSibling |
返回 Element
节点的方法更快,因为少了非 Element
节点
2.4.3. 选择器 API
通过 CSS 选择器查找 DOM 元素比原生 DOM 方法要快,如下:
var errs = [],
divs = document.getElementsByTagName('div'),
classname = '';
for (var i = 0, len = divs.length; i < len; i++) {
classname = divs[i].className;
if (classname === 'notice' || classname === 'warning') {
errs.push(divs[i]);
}
}
// 比上面要快几倍
var errs = document.querySelectorAll('div.warning, div.notice');
querySelectorAll()
返回 NodeList
对象(快照),
不是 HTML 集合,所以不会对应实时的文档结构。
3. 重绘与重排
页面资源(HTML、JS、CSS、图片等)下载完毕后,浏览器会解析并生成两个内部结构:
- DOM树: 表示页面结构
- 渲染树: 表示DOM节点如何显示
DOM树中需要显示的节点(非隐藏节点)在渲染树中有对应的节点。
渲染树中的节点称为 帧(frames)或盒(boxes),
盒(boxes)跟 CSS 盒模型一样,有 padding、margins、 borders、position,
一旦DOM树和渲染树构建完毕,浏览器就会把页面元素显示(绘制 paint)出来。
当DOM元素的变化影响到其几何形状(宽、高),
浏览器会重新计算 该元素及受其影响的其他元素 的几何形状和位置,
并使渲染树中受影响的部分失效,并重新构造渲染树,这个过程称为 重排(reflow),
重排之后,浏览器会重新绘制受影响的部分到屏幕上,这个过程称为 重绘(repaint)。
如果元素改变的是背景色(并不会影响其宽高),则只需要重绘即可。
重绘和重排是非常昂贵的,会导致 UI 反应迟钝,所以尽量减少此类过程。
3.1. 重排何时发生
当页面布局或元素几何形状发生“变化”时,会发生重排:
- 增删可见的元素
- 改变元素位置
- 改变元素尺寸( margin, padding, border thickness, width, height)
- 改变元素内容(改变文本,改变图片尺寸)
- 页面初始化渲染
- 改变浏览器窗口大小
改变的部分 所对应的渲染树部分 会重新进行计算,
有些改变会导致整个页面重排,如出现滚动条。
3.2. 重排队列与刷新
由于每次重排都会消耗计算能力,浏览器把多个(导致重排的)“变化”放到队列里,然后批量执行。
也就是说,创建缓冲区,缓存“变化”,缓冲区满后再执行计算,最后重排一次即可。
但当你查询布局信息时会导致刷新缓冲区(让缓冲区中的“变化”立马重排):
offsetTop
,offsetLeft
,offsetWidth
,offsetHeight
scrollTop
,scrollLeft
,scrollWidth
,scrollHeight
clientTop
,clientLeft
,clientWidth
,clientHeight
getComputedStyle()
(currentStyle
in IE)
以上属性和方法需要返回的最新的布局信息。
所以尽量把这些操作放到最后执行,即延迟访问布局信息。
3.3. 最小化重绘和重排
合并所有修改,然后一次处理:
3.3.1. 改变样式
var el = document.getElementById('mydiv');
// 单独处理影响性能
el.style.borderLeft = '1px';
el.style.borderRight = '2px';
el.style.padding = '5px';
// 合并处理
el.style.cssText = 'border-left: 1px; border-right: 2px; padding: 5px;';
// 或者设置样式类
el.className = 'active';
3.3.2. 批量处理DOM的变化
当你要对一个DOM元素进行多次修改时,你可以通过下面的步骤来减少重排和重绘:
- 让元素脱离文档流
- 对运行进行修改
- 将元素返回文档中
这个过程之后只有第1步、第2步会会硬气重排,
如果不怎么做,第2步中的每个修改都可能会引起重排。
有三种基本的方法:
- 隐藏元素,然后修改元素,最后显示出来
- 使用文档片段
- 克隆元素,然后修改克隆元素,最后用克隆的元素替换掉原始元素
示例:
function appendDataToElement(appendToElement, data) {
var a, li;
for (var i = 0, max = data.length; i < max; i++) {
a = document.createElement('a');
a.href = data[i].url;
a.appendChild(document.createTextNode(data[i].name));
li = document.createElement('li');
li.appendChild(a);
appendToElement.appendChild(li);
}
};
var data = [
{
"name": "Nicholas",
"url": "http://nczonline.net"
},
{
"name": "Ross",
"url": "http://techfoolery.com"
}
];
// 方式 1
var ul = document.getElementById('mylist');
ul.style.display = 'none';
appendDataToElement(ul, data);
ul.style.display = 'block';
// 方式 2
var fragment = document.createDocumentFragment();
appendDataToElement(fragment, data);
document.getElementById('mylist').appendChild(fragment); // 只会添加片段的所有子节点
// 方式 3
var old = document.getElementById('mylist');
var clone = old.cloneNode(true);
appendDataToElement(clone, data);
old.parentNode.replaceChild(clone, old);
3.4. 缓存布局信息
获取布局信息(如 offsets, scroll values, computed style values)
会是重排缓冲区刷新(计算队列里所有“变化”并重排)。
如果多次获取布局信息,可以缓存到局部变量中:
// inefficient
myElement.style.left = 1 + myElement.offsetLeft + 'px';
myElement.style.top = 1 + myElement.offsetLeft + 'px';
if (myElement.offsetLeft >= 500) {
stopAnimation();
}
// 高效
var current = myElement.offsetLeft;
current++;
myElement.style.left = current + 'px';
myElement.style.top = current + 'px';
if (current >= 500) {
stopAnimation();
}
3.5. 使动画元素脱离文档流
在页面通常使用 展开/折叠 的(动画)方式来 显示/隐藏 元素,
如果处理不好会导致很大的区域(甚至整个页面)的重排。
用以下的方式,可以避免页面大块区域的重排:
- 通过绝对定位让元素脱离文档流
- 在元素上应用动画效果
- 恢复元素的定位属性
3.6. 事件委托
避免在“同类型”的元素上挨个绑定事件处理器。
每个事件有三个阶段:
- 捕获 (根元素 -> 目标元素)
- 在目标元素上触发
- 冒泡 (目标元素 -> 根元素)
事件发生时,该事件都会传播祖先元素上。
在祖先元素上绑定事件处理器,在处理器中判断目标元素是否是指定元素:
// 阻止所有链接直接打开页面
document.onclick = function(e) {
var target = e.target;
if (target.nodeName !== 'A') {
return;
}
e.preventDefault();
handleUnsafeLinks(target.href);
};
4. 总结
在现代web应用中,操作DOM是很重要的一部分,每次通过 JS 与 DOM 通信都会有开销,
为了降低DOM编程的性能开销,请记住以下几点:
- 最小化 DOM 操作,尽量把业务逻辑放在 JS 这一块做完,然后再操作 DOM
- 用局部变量存储经常访问的 DOM 的引用
- HTML集合是实时的,缓存HTML集合的
length
,或者将其拷贝到数组,进而对数组进行操作 - 尽量使用更高效的 API,如
querySelectorAll()
、firstElementChild
- 注意重排和重绘;批量处理样式更新,“离线”操作DOM树,缓存并最小化访问布局信息
- 使动画元素绝对定位,使用拖拽代理
- 使用事件委托来最小化事件处理函数的绑定