Javascript编程实践
一、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;
- innerHTML与- DOM方法
有两种方式动态创建元素:使用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.images、document.forms或document.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属性中的childNodes、firstChild等不区分元素节点和其他类型节点(注释节点和文本节点),但在很多情况下,只需要访问元素节点。现代浏览器提供了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改变都会影响几何属性,例如改变背景色,此时只需要重绘即可。
重绘和重排版是负担很重的操作,有可能会导致页面失去响应,因此,应尽可能减少此过程。
-  通常情况下,以下操作会导致重排版: -  首次渲染页面 
-  添加或删除可见元素 
-  改变元素位置 
-  改变元素尺寸(宽、高、边距、边框等属性) 
-  改变元素内容(文本改变或图片被另一个不同尺寸的图片所代替) 
-  浏览器尺寸改变 
 
-  
根据改变的不同,渲染树上或大或小的部分需要重排版;某些改变可能导致重排版整个页面,例如:出现滚动条。
-  大多数浏览器通过使用队列和批量执行改变内容来优化重排版过程,但某些操作(获取布局信息的操作)将强制刷新队列并将所有计划改变的部分立刻应用,这些操作如下: -  offsetTop、offsetLeft、offsetWidth、offsetHeight
-  scrollTop、scrollLeft、scrollWidth、scrollHeight
-  clientTop、clientLeft、clientWidth、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.abs、Math.pow、Math.sqrt、Math.PI、Math.LOG2E、Math.SQRT2等
在使用CSS选择器选择DOM元素时,尽可能使用querySelector、querySelectorAll等原生方法。
附录
1、逻辑操作符
JavaScript中的逻辑操作符有以下四种:
-  &(AND)按位与:两个操作数的位都是1时,结果才是1 
-  |(OR)按位或:有一个操作数的位是1,结果就是1 
-  ^(XOR)异或:两个操作数的位中只有一个1时,结果才是1 
-  ~(NOT)按位非 
参考资料
- 《高性能Javascript编程》