20.3 性能
因为 JavaScript 是一个解释型语言,执行速度要比编译型语言慢得多。除此之外,只有有限的资源 (基于浏览器设置) 分配给 Web 应用,也就是说 JavaScript 相比较桌面应用只能访问较少的内存和 CPU 周期。
虽然从 2005 年开始,浏览器在 JavaScript 执行性能方面在大踏步前进,但它还是比其他语言慢很多。不过,还是有一些方式可以改进代码的整体性能的。
20.3.1 注意作用域
第4章讨论了 JavaScript 中 "作用域" 的概念以及作用域链是如何运作的。随着作用域链中的作用域数量的增加,访问当前作用域以外的变量的时间也在增加。访问全局变量总是要比访问局部变量慢,因为需要遍历作用域链。只要能减少花费在作用域链上的时间,就能增加脚本的整体性能。
1.避免全局查找
可能优化脚本性能最重要的就是注意全局查找。使用全局变量和函数肯定要比局部的开销更大,因为要涉及作用域链上的查找。请看以下函数:
function updateUI(){
var imgs = document.getElementsByTagName("img");
for (var i=0, len=imgs.length; i<len; i++) {
imgs[i].title = document.title + " image " + i;
}
var msg = document.getElementById("msg");
msg.innerHTML = "Update complete.";
}
该函数可能看上去完全正常,但是它包含了三个对于全局 document 对象的引用。如果在页面上有多个图片,那么 for 循环中的 document 引用就会被执行多次甚至上百次,每次都会要进行作用域链查找。通过创建一个指向 document 对象的局部变量,就可以通过限制一次全局查找来改进这个函数的性能:
function updateUI(){
var doc = document;
var imgs = doc.getElementsByTagName("img");
for (var i=0, len=imgs.length; i<len; i++){
imgs[i].title = doc.title + " image " + i;
}
var msg = doc.getElementById("msg");
msg.innerHTML = "Update complete.";
}
这里,首先将 document 对象存在本地的 doc 变量中;然后在余下的代码中替换原来的 document 。与原来的版本相比,现在的函数只有一次全局查找,肯定更快。
将在一个函数中会用到多次的全局对象存储为局部变量总是没错的。
2.避免 with 语句
在性能非常重要的地方必须避免使用 with 语句。和函数类似,with 语句会创建自己的作用域,因此会增加其中执行的代码的作用域链的长度。由于额外的作用域链查找,在 with 语句中执行的代码肯定比外面执行的代码要慢。
必须使用 with 语句的情况很少,因为它主要用于消除额外的字符。在大多数情况下,可以用局部变量完成相同的事情而不引入新的作用域。下面是一个例子:
function updateBody(){
with(document.body){
alert(tagName);
innerHTML = "Hello world!";
}
}
这段代码中的 with 语句让 document.body 变得更容易使用。其实可以使用局部变量达到相同的效果,如下所示:
function updateBody(){
var body = document.body;
alert(body.tagName);
body.innerHTML = "Hello world";
}
虽然代码稍微长了点,但是阅读起来比 with 语句版本更好,它确保让你知道 tagName 和 innerHTML 是属于哪个对象的。同时,这段代码通过将 document.body 存储在局部变量中省去了额外的全局查找。
20.3.2 选择正确方法
1.避免不必要的属性查找
使用变量和数组要比访问对象上的属性更有效率。
4.避免双重解释
当 JavaScript 代码想解析 JavaScript 的时候就会存在双重解释惩罚。当使用 eval() 函数或者是 Function 构造函数以及使用 setTimeout() 传一个字符串参数时都会发生这种情况。下面有一些例子:
// 某些代码求值 -- 避免!!
eval("alert('Hello world!')");
// 创建新函数 -- 避免 !!
var sayHi = new Function("alert('Hello world!')");
// 设置超时 -- 避免!!
setTimeout("alert('Hello world!')", 500);
在以上这些例子中,都要解析包含了 JavaScript 代码的字符串。这个操作是不能在初始的解析过程中完成的,因为代码是包含在字符串中的,也就是说在 JavaScript 代码运行的同时必须新启动一个解析器来解析新的代码。实例化一个新的解析器有不容忽视的开销,所以这种代码要比直接解析慢得多。
对于这几个例子都有另外的办法。只有极少的情况下 eval() 是绝对必须的,所以尽可能避免使用。在这个例子中,代码其实可以直接内嵌在原代码中。对于 Function 构造函数,完全可以直接写成一般的函数,调用 setTimeout() 可以传入函数作为第一个参数。以下是一些例子:
// 已修正
alert('Hello world!');
// 创建新函数 -- 已修正
var sayHi = function(){
alert('Hello world!');
};
// 设置一个超时 -- 已修正
setTimeout(function(){
alert('Hello world!');
}, 500);
20.3.4 优化 DOM 交互
在 JavaScript 各个方面中,DOM 毫无疑问是最慢的一部分。DOM 操作与交互要消耗大量时间,因为它们往往需要重新渲染整个页面或者某一部分。进一步说,看似细微的操作也可能要花很久来执行,因为 DOM 要处理非常多的信息。理解如何优化与 DOM 的交互可以极大得提高脚本完成的速度。
1.最小化现场更新
一旦你需要访问的 DOM 部分是已经显示的页面的一部分,那么你就是在进行一个现场更新。之所以叫现场更新,是因为需要立即 (现场) 对页面对用户的显示进行更新。每一个更改,不管是插入单个字符,还是移除整个片段,都有一个性能惩罚,因为浏览器要重新计算无数尺寸以进行更新。现场更新进行得越多,代码完成执行所花的时间就越长;完成一个操作所需的现场更新越少,代码就越快。请看以下例子:
var list = document.getElementById("myList");
for(var i=0; i<10; i++){
var item = document.createElement("li");
list.appendChild(item);
item.appendChild(document.createTextNode("Item " + i));
}
这段代码为列表添加了 10 个项目。添加每个项目时,都有2个现场更新:一个添加 <li> 元素,另一个给它添加文本节点。这样添加 10 个项目,这个操作总共要完成 20 个现场更新。
要修正这个性能瓶颈,需要减少现场更新的数量。一般有2种方法。第一种是将列表从页面上移除,然后进行更新,最后再将列表插回到同样的位置。这个方法不是非常理想,因为在每次页面更新的时候它会不必要的闪烁。第二个方法是使用文档碎片来构建 DOM 结构,接着将其添加到 List 元素中。这个方式避免了现场更新和页面闪烁问题。请看下面内容:
var list = document.getElementById("myList");
var fragment = document.createDocumentFragment();
for(var i=0; i<10; i++){
var item = document.createElement("li");
fragment.appendChild(item);
item.appendChild(document.createTextNode("Item " + i));
}
list.appendChild(fragment);
在这个例子中只有一次现场更新,它发生在所有项目都创建好之后。文档碎片用作一个临时的占位符,放置新创建的项目。然后使用 appendChild() 将所有项目添加到列表中。记住,当给 appendChild() 传入文档碎片时,只有碎片中的子节点被添加到目标,碎片本身不会被添加。
一旦需要更新 DOM ,请考虑使用文档碎片来构建 DOM 结构,然后再将其添加到现存的文档中。
2.使用 innerHTML
有两种在页面上创建 DOM 节点的方法:使用诸如 createElement() 和 appendChild() 之类的 DOM 方法,以及使用 innerHTML 。对于小的 DOM 更改而言,两种方法效率都差不多。然而,对于大的 DOM 更改,使用 innerHTML 要比使用标准 DOM 方法创建同样的 DOM 结构快得多。
当把 innerHTML 设置为某个值时,后台会创建一个 HTML 解析器,然后使用内部的 DOM 调用来创建 DOM 结构,而非基于 JavaScript 的 DOM 调用。由于内部方法是编译好的而非解释执行的,所以执行快得多。前面的例子还可以用 innerHTML 改写如下:
var list = document.getElementById("myList");
var html = "";
for (var i=0; i<10; i++){
html += "<li>Item " + i + "</li>";
}
list.innerHTML = html;
这段代码构建了一个 HTML 字符串,然后将其指定到 list.innerHTML ,便创建了需要的 DOM 结构。虽然字符串连接上总是有点性能损失,但这种方式还是要比进行多个 DOM 操作更快。
使用 innerHTML 的关键在于 (和其他 DOM 操作一样) 最小化调用它的次数。例如,下面的代码在这个操作中用到 innerHTML 的次数太多了:
var list = document.getElementById("myList");
for(var i=0; i<10; i++){
list.innerHTML += "<li>Item " + i + "</li>"; // 避免
}
这段代码的问题在于每次循环都要调用 innerHTML ,这是极其低效的。调用 innerHTML 实际上就是一次现场更新,所以也要如此对待。构建好一个字符串然后一次性调用 innerHTML 要比调用 innerHTML 多次快得多。
3.使用事件代理
大多数 web 应用在用户交互上大量用到事件处理程序。页面上的事件处理程序的数量和页面响应用户交互的速度之间有个负相关。为了减轻这种惩罚,最好使用事件代理。
事件代理,如第 12 章中所讨论的那样,用到了事件冒泡。任何可以冒泡的事件都不仅仅可以在事件目标上进行处理,目标的任何祖先节点上也能处理。使用这个知识,就可以将事件处理程序附加到更高层的地方复杂多个目标的事件处理。如果可能,在文档级别附加事件处理程序,这样可以处理整个页面的事件。
4.注意 NodeList
NodeList 对象的陷阱已经在本书中讨论过了,因为它们对于 web 应用的性能而言是巨大的损害。记住,任何时候要访问 NodeList ,不管它是一个属性还是一个方法,都是在文档上进行一个查询,这个查询开销很昂贵。最小化访问 NodeList 的次数可以极大地改进脚本的性能。
也许优化 NodeList 访问最重要的地方就是循环了。前面提到过将长度计算移入 for 循环的初始化部分。现在看一下这个例子:
var images = document.getElementsByTagName("img");
for (var i=0, len=images.length; i<len; i++){
// 处理
}
这里的关键在于长度 length 存入了 len 变量,而不是每次都去访问 NodeList 的 length 属性。当在循环中使用 NodeList 的时候,下一步应该是获取要使用的项目的引用,如下所示,以便避免在循环体内多次调用 NodeList 。
var images = document.getElementsByTagName("img");
for(var i=0, len=images.length; i<len; i++){
var image = image[i];
// 处理
}
这段代码添加了 image 变量,保存了当前的图像。这之后,在循环内就没有理由在访问 images 的NodeList 了。
==== 编写 JavaScript 的时候,一定要知道何时返回 NodeList 对象,这样你就可以最小化对他们的访问。发生以下情况时会返回 NodeList 对象:====
- 进行了对 getElementsByTagName() 的调用;
- 获取了元素的 childNodes 属性;
- 获取了元素的 attributes 属性;
- 访问了特殊的集合,如 document.forms、document.images 等等。