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编程》