一、DOM编程

DOM(文档对象模型)是一个和语言无关的应用程序接口(API),使用这些API可以改变文档的结构、样式或内容。

浏览器通常要求DOM实现和JavaScript实现保持相互独立,这两个独立的部分之间以功能接口连接时就会带来性能损耗。可以分别将DOM和JavaScript(ECMAScript)看成是一个岛屿,它们之间使用一座收费的桥相连,每次使用ECMAScript访问DOM时,需要支付过桥费用;当操作DOM的次数越多时,费用就越高。因此,建议尽量减少过桥次数,尽可能停留在ECMAScript岛上。

1、访问和修改DOM元素

  • 减少操作DOM的次数

与访问DOM元素的代价相比,修改DOM元素的代价更高,因为修改DOM时经常导致浏览器重新计算页面的几何变化,最坏的情况是在循环(特别是HTML集合循环)中执行此操作,例如:

for(var count = 0; count < 10000; count++){
	document.querySelector("#content").innerHTML += 'a';
}

在上面的每次循环中,都会访问两次DOM:读取innerHTML属性和写入此属性。建议使用局部变量存储要更新的内容,在循环结束时一次写入:

var content = "";
for(var count = 0; count < 10000; count++){
	content += 'a';
}
document.querySelector("#content").innerHTML = content;
  • innerHTMLDOM方法

有两种方式动态创建元素:使用innerHTML或使用DOM提供的方法createElement()appendChild()等。这两种方式性能差别不大,但如果在一个性能要求高的操作中更新大量元素,innerHTML在大多数浏览器中执行得更快。

var arr = ['<div>'];
arr.push('<p>This is a paragraph!</p>');
arr.push('</div>');
document.querySelector("#container").innerHTML = arr.join('');
var div = document.createElement("div");
var p = document.createElement("p");
p.appendChild(document.createTextNode('This is a paragraph!'));
div.appendChild(p);
document.querySelector("#container").appendChild(div);

也可以使用cloneNode()方法克隆一个已有的DOM元素而不是创建新的(使用createElement),此方法比createElement更有效率,但提高不太多。

  • HTML集合

HTML集合(HTML Collection)用于存放DOM节点引用的类数组对象,例如:document.imagesdocument.formsdocument.getElementsByTagName等方法的返回值就是HTML集合。

//一个集合遍历的死循环
var divs = document.getElementsByTagName("div");
for(var i = 0; i < divs.length; i++){
	//给body动态添加div元素
	document.body.appendChild(document.createElement("div"));
	//取某一元素的属性
	var name = document.getElementsByTagName("div")[i].nodeName;
	var type = document.getElementsByTagName("div")[i].nodeType;
	var tag = document.getElementsByTagName("div")[i].tagName;
}

上面的例子中在执行divs.length时每次都会重新查询文档,不仅效率低而且此length在每次迭代中都会增加,从而导致死循环。

如果将getElementsByTagName换成querySelectorAll或将divs.length赋值给变量且在循环判断条件中使用此变量则不会出现死循环:

var divs = document.querySelectorAll("div");

var divs = document.getElementsByTagName("div");
for(var i = 0, len = divs.length; i < len; i++)

一般来说,对于任何类型的DOM访问,如果同一个DOM属性或方法被访问一次以上,最好使用局部变量缓存此DOM。当遍历一个集合时,第一个优化是将集合引用赋值给局部变量,并在循环之外缓存length属性;第二个优化是如果在循环体中多次访问同一个集合元素,则将此元素缓存为局部变量;最后可以根据当前条件决定是否将集合元素拷贝到数组,因为遍历数组比集合更快(但需要额外拷贝)。

//优化后的集合遍历
var divs = document.getElementsByTagName("div");
var len = divs.length,
	div = null,
	name = "",
	type = "",
	tag = "";
for(var i = 0; i < len; i++){
	div = divs[i];
	//取某一元素的属性
	name = div.nodeName;
	type = div.nodeType;
	tag = div.tagName;
}
  • 元素节点

DOM属性中的childNodesfirstChild等不区分元素节点和其他类型节点(注释节点和文本节点),但在很多情况下,只需要访问元素节点。现代浏览器提供了API函数只返回元素节点,最好直接使用这些API,因为它们比你自己再写代码过滤要快。

API 新API
childNodes children
childNodes.length childElementCount
firstChild firstElementChild
lastChild lastElementChild
nextSibling nextElementSibling
previousSibling previousElementSibling

2、重绘和重排版

当浏览器加载完HTML页面以及JavaScript和CSS等资源后,它解析这些文件并创建两个内部数据结构:一个DOM树和一个渲染树。

渲染树中为每个需要显示的DOM树节点存放至少一个节点(隐藏DOM元素在渲染树中没有对应的节点),当DOM树和渲染树构造完毕后,浏览器就可以显示(绘制)页面上的元素了。

当DOM改变(例如在段落中添加了文字或改变了高度)影响到元素的几何属性(宽和高等)时,浏览器需要重新计算元素的几何属性,而且其他元素的几何属性和位置可能也会受到影响。浏览器使渲染树上受到影响的部分失效,然后重构渲染树,这个过程被称为重排版。重排版完成时,浏览器在一个重绘进程中重新绘制屏幕上受影响的部分。

不是所有的DOM改变都会影响几何属性,例如改变背景色,此时只需要重绘即可。

重绘和重排版是负担很重的操作,有可能会导致页面失去响应,因此,应尽可能减少此过程。

  • 通常情况下,以下操作会导致重排版:

    • 首次渲染页面

    • 添加或删除可见元素

    • 改变元素位置

    • 改变元素尺寸(宽、高、边距、边框等属性)

    • 改变元素内容(文本改变或图片被另一个不同尺寸的图片所代替)

    • 浏览器尺寸改变

根据改变的不同,渲染树上或大或小的部分需要重排版;某些改变可能导致重排版整个页面,例如:出现滚动条。

  • 大多数浏览器通过使用队列和批量执行改变内容来优化重排版过程,但某些操作(获取布局信息的操作)将强制刷新队列并将所有计划改变的部分立刻应用,这些操作如下:

    • offsetTopoffsetLeftoffsetWidth、offsetHeight

    • scrollTopscrollLeftscrollWidth、scrollHeight

    • clientTopclientLeftclientWidth、clientHeight

    • getComputedStyle()(在IE中为currentStyle)

在改变样式时,尽量不要使用上面列出的属性,因为这些属性都将刷新渲染队列,即使是获取最近未发生改变的元素的布局信息。

var computed = null,
	tmp = null,
	style = document.body.style;
if(document.body.currentStyle){
	computed = document.body.currentStyle;
}else{
	computed = document.defaultView.getComputedStyle(document.body, "");
}
style.color = "pink";
tmp = computed.backgroundColor;
style.color = "lightblue";
tmp = computed.backgroundRepeat;
style.color = "lightgreen";
tmp = computed.backgroundPosition;

上面例子中将body的前景色改变了三次,每次改变之后都获取一个样式,虽然获取的样式与color改变无关,但由于查询computed样式浏览器仍需刷新渲染队列并重排版。

优化后的结果:

style.color = "pink";
style.color = "lightblue";
style.color = "lightgreen";
tmp = computed.backgroundColor;
tmp = computed.backgroundRepeat;
tmp = computed.backgroundPosition;
  • 缓存布局信息

为了减少对布局信息的查询次数,在查询布局信息时可以将它赋值给局部变量,之后用此变量参与计算:

element.style.left = element.style.offsetLeft + 1 + 'px';
if(element.offsetLeft > 100){
	doSomething();
}

优化后的结果:

var current = element.offsetLeft;
current++;
element.style.left = current + 'px';
if(current > 100){
	doSomething();
}
为了减少重绘和重排版的次数,应该将多个DOM和样式的改变合并起来一次执行。
  • 批量修改样式
var element = document.querySelector("#test");
element.style.border = "2px dotted gray";
element.style.padding = "5px";
element.style.margin = "10px";

优化后的结果:

var element = document.querySelector("#test");
//将所有的改变合并起来一次执行,只修改一次DOM
element.style.cssText += "border: 2px dotted gray; padding: 5px; margin: 10px;";

var element = document.querySelector("#test");
//修改css类名称
element.className = "active";
  • 批量修改DOM

    当需要对DOM元素进行多次修改时,可以通过从文档流中摘除该元素、对其应用多重改变、将元素带回文档的方式来减少重绘和重排版的次数。这种方式只会引发两次重排版:摘除元素时和带回元素时。

    实现此过程有三种方法:

    • 先隐藏元素,然后对其修改,最后再显示出来

        var element = document.querySelector("#test");
        element.style.display = 'none';
        //操作DOM元素
        doSomething(element);
        element.style.display = 'block';
      
    • 在DOM之外使用文档片断创建一个子树,然后将它拷贝到文档中

      文档片段(DocumentFragment)是一个轻量级的document对象,它被设计用于更新、移动节点之类的任务。当向一个节点添加一个文档片段时,实际添加的是文档片段的子节点,而不是片段自己。

        var fragment = document.createDocumentFragment();
        doSomething(fragment);
        document.querySelector("#test").appendChild(fragment);
      
    • 将原始元素拷贝到一个脱离文档的节点中,修改此副本,然后覆盖原始元素

        var originalElt = document.querySelector("#test");
        var cloneElt = originalElt.cloneNode();
        doSomething(cloneElt);
        originalElt.parentNode.replaceChild(cloneElt, originalElt);
      

    推荐使用第二种(文档片段)方式,因为它涉及最少数量的DOM操作和重排版。

  • 将元素提出动画流

    重排版时有时只影响渲染树的一小部分,但也可能影响一大部分,甚至整个渲染树。当浏览器要重排版的部分越小时,应用响应的速度就越快。如果页面顶部有一个动画元素,该元素动画时会推移差不多整个页面,此时将引发巨大的重排版,渲染树中的大多数节点需要重新计算位置,使用户感到界面卡顿。

    使用以下步骤可以避免对大部分页面进行重排版:

    • 使用绝对坐标定位页面动画元素,使它位于页面布局流之外

    • 元素开始动画,当它扩大时将会临时覆盖部分页面,这是一个重绘过程,但只影响页面的一部分,避免重排版并且重绘一大块页面。

    • 动画结束后,恢复元素的位置,此时只是下移一次文档中其他元素的位置

3、事件托管

连接每个句柄(attaching every handler)都是有代价的,当页面中存在大量挂接了一个或多个事件句柄(例如onclick)的元素时,可能会影响性能。因为这种元素比较多时需要访问和修改更多的DOM节点,程序就会变慢,特别是当事件挂接过程都发生在onload事件中时,对任何一个富交互网页来说都是比较耗时的阶段。挂接事件不仅占用了处理时间,而且浏览器需要保持对每个句柄(handler)的监测,将会占用更多内存。但其实,很多事件句柄可能根本不需要(因为并不是所有的按钮或链接都会被用户点击到),因此很多挂接工作都是不必要的。

对于上述问题,可以通过事件托管来解决。事件托管基于这样的一个事实:由于DOM标准的事件都会经历捕获、到达目标和冒泡三个阶段,事件逐层冒泡总能被父元素捕获。

使用事件托管,只需要在父元素上挂接一个句柄,用于处理子元素的所有事件。

例如,下面的例子中在ul上挂接一个句柄来处理所有链接的click事件。

<ul id="menu">
	<li><a href="#one">menu 1</a></li>
	<li><a href="#two">menu 2</a></li>
	<li><a href="#three">menu 3</a></li>
</ul>
document.querySelector('#menu').onclick = function(e){
	e = e || window.event;
	var target = e.target || e.srcElement;
	if(target.nodeName !== 'A'){
		return;
	}
	doSomething();
	if(typeof e.preventDefault === 'function'){
		e.preventDefault();
		e.stopPropagation();
	}else{
		e.returnValue = false;
		e.cancelBubble = true;
	}
};

二、通用技巧

1、避免二次评估(double evaluation)

JavaScript允许在程序中执行包含代码的字符串,有以下四种方式:eval()Function()setTimeout()setInterval()

var num1 = 1,
	num2 = 2;
//eval
var result = eval("num1 + num2");
//Function
var sum = new Function("arg1", "arg2", "return arg1 + arg2;");
result = sum(num1, num2);
//setTimeout
setTimeout("result = num1 + num2", 100);
//setInterval
var itl = setInterval("result = num1 + num2", 100);
clearInterval(itl);

上面的每种方式在执行字符串中的代码时将会多一次评估,因为在调用这些函数时要创建一个新的解释/编译实例。二次评估是一项昂贵的操作,与直接包含相应代码相比将占用更长时间。因此,对于eval()Function()要尽可能的避免使用它们;对于setTimeout()setInterval()的第一个参数建议传入函数而不是字符串。

var num1 = 1,
	num2 = 2,
	result = null;
function sum(){
	result = num1 + num2;
}
setTimeout(sum, 100);
setInterval(sum, 100);

2、使用对象/数组直接量

在JavaScript中创建对象和数组最快的方式是使用对象或数组的直接量。

//new Object()
var myObject = {
	name: 'albert',
	age: 24
};

//new Ayyay()
var myArray = ['A', 'B', 'C', 1 , 2 , 3];

3、不要重复工作

常见的例子是浏览器检测:

function addHandler(target, eventType, handler){
	if(target.addEventListener){
		target.addEventListener(eventType, handler, false);
	}else{
		//IE
		target.attachEvent("on" + eventType, handler);
	}
}		
function removeHandler(target, eventType, handler){
	if(target.removeEventListener){
		target.removeEventListener(eventType, handler, false);
	}else{
		//IE
		target.detachEvent("on" + eventType, handler);
	}
}

上面的代码在每次函数调用时都会执行同样的检查,由于用户不可能在页面加载时改变浏览器,因此这种判断是重复的。消除重复有两种方式:延迟加载和条件预加载。

  • 延迟加载

此种方式下,函数在第一次被调用时判断条件,并将原函数使用满足条件的新函数覆盖,之后再次调用该函数不会重复检测。

第一次调用一个延迟加载函数时使用时间较长,之后调用同一函数将快很多,因为不用再执行检测校验等逻辑了。延迟加载最佳适用场合是函数不会在页面中立即被用到。

function addHandler(target, eventType, handler){
	if(target.addEventListener){
		addHandler = function(target, eventType, handler){
			target.addEventListener(eventType, handler, false);
		}
	}else{
		//IE
		addHandler = function(target, eventType, handler){
			target.attachEvent("on" + eventType, handler);
		}
	}
	addHandler(target, eventType, handler);
}		

function removeHandler(target, eventType, handler){
	if(target.removeEventListener){
		removeHandler = function(target, eventType, handler){
			target.removeEventListener(eventType, handler, false);
		}
	}else{
		//IE
		removeHandler = function(target, eventType, handler){
			target.detachEvent("on" + eventType, handler);
		}
	}
	removeHandler(target, eventType, handler);
}
  • 条件预加载

此种方式是在脚本加载之前提前进行检查,而不等待函数调用。其代价是在脚本加载时进行检测,预加载适用于一个函数马上会被用到,而且在整个页面生命周期中经常使用的场合。

var addHandler = document.body.addEventListener ? 
	function(target, eventType, handler){
		target.addEventListener(eventType, handler, false);
	} : 
	function(target, eventType, handler){
		target.attachEvent("on" + eventType, handler);
	};
	
var removeHandler = document.body.removeEventListener ?
	function(target, eventType, handler){
		target.removeEventListener(eventType, handler, false);
	} : 
	function(target, eventType, handler){
		target.detachEvent("on" + eventType, handler);
	};

4、使用速度快的部分(方式)

JavaScript中的数字按IEEE-754标准64位格式存储,在位运算中,数字被转换为有符号的32位格式。

可以使用位运算符替代纯数学操作,例如:对2取模实现表格行颜色交替

for(var i = 0, len = rows.length; i < len; i++){
	className = i % 2 ? "even" : "odd";
}

将32位数字用二进制表示,可以看到偶数的最低位是0,奇数的最低位是1。因此,对2取模操作可以使用和1进行位与的方式。

for(var i = 0, len = rows.length; i < len; i++){
	className = i & 1 ? "even" : "odd";
}
  • 使用位掩码

位掩码在计算机中是一种常用的技术,可以用来判断选项的内容。掩码中每个选项的值都等于2的幂:

//所有选项
var OPTION_A = 1,
	OPTION_B = 2,
	OPTION_C = 4,
	OPTION_D = 8;
//选择的项
var options = OPTION_A | OPTION_B | OPTION_D;
//选项中是否有A
if(options & OPTION_A){
	console.log("option A in the list");
}
//选项中是否有B
if(options & OPTION_B){
	console.log("option B in the list");
}

如果许多选项保存在一起并经常检查,使用位掩码有助于加快整体性能。

  • 使用JavaScript的原生方法

在数学计算中可以使用内置Math对象提供的方法或已经计算好的数值,例如:Math.absMath.powMath.sqrtMath.PIMath.LOG2EMath.SQRT2

在使用CSS选择器选择DOM元素时,尽可能使用querySelectorquerySelectorAll等原生方法。

附录
1、逻辑操作符

JavaScript中的逻辑操作符有以下四种:

  • &(AND)

    按位与:两个操作数的位都是1时,结果才是1

  • |(OR)

    按位或:有一个操作数的位是1,结果就是1

  • ^(XOR)

    异或:两个操作数的位中只有一个1时,结果才是1

  • ~(NOT)

    按位非

参考资料
  • 《高性能Javascript编程》