JavaScript性能优化
0. 提要
自从JavaScript诞生以来,用这门语言编写网页的开发人员有了极大的增长。与此同时,JavaScript代码的执行效率也越来越受到关注。因为JavaScript最初是一个解释型语言,执行速度要比编译型语言慢得多。Chrome是第一款内置优化引擎,将JavaScript变异成本地代码的浏览器。此后,主流浏览器纷纷效仿,陆续实现了JavaScript的编译执行。
即使到了编译执行JavaScript的新阶段,仍然会存在低效率的代码。不过,还是有一些方式可以改进代码的整体性能的。
主要有以下四个方面:
- 作用域方面。
- 选择方法方面。
- 最小化语句数方面。
- 优化DOM交互方面。
1.作用域方面
1.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。与原来的的版本相比,现在的函数只有一次全局查找,肯定更快。
将在一个函数中会用到多次的全局对象存储为局部变量总是没错的。
1.2避免with语句
在性能非常重要的地方必须避免使用with语句。和函数类似,with语句会创建自己的作用域,因此会增加其中执行的代码的作用域链的长度。由于额外的作用域链查找,在with语句中执行的代码肯定会比外米娜执行的代码要慢。
对比一下以下两段作用相同的代码:
function updateBody(){ with(document.body){ alert(tagName); innerHTML = "Hello world!"; } }
function updateBody(){ var body = document.body alert(body.tagName); body.innerHTML = "Hello world!"; }
第一个使用了with语句,第二个没有使用。第二个代码虽然稍微长了点,但是阅读起来比with语句版本更好,它确保让你知道tagName和innerHTML是属于哪个对象的。同时,这段代码通过将document.body存储在局部变量中省去了额外的全局查找。
2.选择方法方面
2.1避免不必要的属性查找
var values = [5, 10]; var sum = values[0] + values[1]; alert(sum);
var values = { first: 5, second: 10}; var sum = values.first + values.second; alert(sum);
对比一下上述两个代码,第一段代码使用数组相加来得到sum值,第二段代码使用访问对象的属性方法相加来得到sum值,前者为O(1),后者为O(n)。
对象上的任何属性查找都要比访问变量或者数组花费更长时间,因为必须在原型链中对拥有该名称的属性进行一次搜索。如第二段代码进行一两次属性查找并不会导致显著的性能问题,但是进行成百上千次则肯定会减慢执行速度。
简而言之,属性查找越多,执行时间就越长。
2.2优化循环
- 减值迭代——大多数循环使用一个从0开始、增加到某个特定值的迭代器。在很多情况下,从最大值开始,在循环中不断减值的迭代器更加高效。
- 简化终止条件——由于每次循环过程都会计算终止条件,所以必须保证它尽可能快。也就是说避免属性查找或其他O(n)的操作。
- 简化循环体——循环体是执行最多的,所以要确保其被最大限度地优化。确保没有某些可以被很容易移出循环的密集计算。
- 使用后测试循环——最常用for循环和while循环都是前测试循环。而如do-while这种后测试循环,可以避免最初终止条件的计算,因此运行更快。
2.3展开循环
当循环的次数是确定的,消除循环并使用多次函数调用往往更快。
for (var i=values.length -1; i >= 0; i--){ process(values[i]); }
//消除循环 process(values[0]); process(values[1]); process(values[2]);
如果数组的长度总是一样的,对每个元素都调用process()可能更优。这个例子假设values数组里面只有3个元素,直接对每个元素调用process()。这样展开循环可以消除建立循环和处理终止条件的额外开销,使代码运行得更快。
2.4性能的其他注意事项
- 原生方法较快——只要有可能,使用原生方法而不是自己用JavaScript重写一个。
- switch语句较快。
- 位运算符较快——当进行数学运算的时候,位运算操作要比任何布尔运算或者算数运算快。选择性地用位运算替换算数运算可以极大提升复杂运算的性能。诸如取模,逻辑与和逻辑或都可以考虑用位运算来替换。
3.最小化语句数方面
3.1多个变量声明
//4 个语句—— 很浪费 var count = 5; var color = "blue"; var values = [1,2,3]; var now = new Date();
改为
//一个语句 var count = 5, color = "blue", values = [1,2,3], now = new Date();
此处,变量声明只用了一个var语句,之间由逗号隔开。在大多数情况下这种优化都非常容易做,并且要比单个变量分别声明快很多。
3.2插入迭代值
var name = values[i]; i++;
改为
var name = values[i++];
3.3使用数组和对象字面量
有两种创建数组和对象的方法:构造函数和字面量。使用构造函数总是要用到更多的语句来插入元素或者定义属性,而字面量可以将这些操作在一个语句中完成。
//用 4 个语句创建和初始化数组——浪费 var values = new Array(); values[0] = 123; values[1] = 456; values[2] = 789; //用 4 个语句创建和初始化对象——浪费 var person = new Object(); person.name = "Nicholas"; person.age = 29; person.sayName = function(){ alert(this.name); };
改为
//只用一条语句创建和初始化数组 var values = [123, 456, 789]; //只用一条语句创建和初始化对象 var person = { name : "Nicholas", age : 29, sayName : function(){ alert(this.name); } };
重写后的代码只包含两条语句,一条创建和初始化数组,另一条创建和初始化对象。之前用了八条语句的东西现在只用了两条,减少了75%的语句量。在包含成千上万行JavaScript的代码库中,这些优化的价值更大。
4.优化DOM交互
在JavaScript各个方面中,DOM毫无疑问是最慢的一部分。DOM操作与交互要消耗大量时间,因为它们往往需要重新渲染整个页面或者某一部分。进一步说,看似细微的操作也可能要花很久来执行,因为DOM要处理非常多的信息。理解如何优化与DOM的交互可以极大得提高脚本完成的速度。
4.1最小化现场更新
一旦你需要访问的DOM部分是已经显示的页面的一部分,那么你就是在进行一个现场更新。之所以叫现场更新,是因为需要立即(现场)对页面对用户的显示进行更新。每一个更改,不管是插入单个字符,还是移除整个片段,都有一个性能惩罚,因为浏览器要重新计算无数尺寸以进行更新。现场更新进行得越多,代码完成执行所花的时间就越长;完成一个操作所需的现场更新越少,代码就越快。
要修正这个性能瓶颈,需要减少现场更新的数量。一般有2种方法。
- 将列表从页面上移除,最后进行更新,最后再将列表插回到同样的位置。这个方法不是非常理想,因为每次页面更新的时候它会不必要的闪烁。
- 使用文档片段来构建DOM结构,接着将其添加到List元素中。这个方式避免了现场更新和页面闪烁问题。
var list = document.getElementById("myList"), item, i; for (i=0; i < 10; i++) { item = document.createElement("li"); list.appendChild(item); item.appendChild(document.createTextNode("Item " + i)); }
改为
var list = document.getElementById("myList"), fragment = document.createDocumentFragment(), item, i; for (i=0; i < 10; i++) { item = document.createElement("li"); fragment.appendChild(item); item.appendChild(document.createTextNode("Item " + i)); } list.appendChild(fragment);
在这个例子中只有一次现场更新,它发生在所有项目都创建好之后。文档片段用作一个临时的占位符,放置新创建的项目。然后使用appendChild()将所有项目添加到列表中。
一旦需要更新DOM,请考虑使用文档片段来构建DOM结构,然后再将其添加到现存的文档中。
4.2使用innerHTML
有两种在页面上创建DOM节点的方法:
- 使用诸如createElement()和appendChild()之类的DOM方法。
- 使用innerHTML。
对于小的DOM更改而言,两种方法效率都差不多。然而,对于大的DOM更改,使用innerHTML要比使用标准DOM方法创建同样的DOM结构快得多。
var list = document.getElementById("myList"), item, i; for (i=0; i < 10; i++) { item = document.createElement("li"); list.appendChild(item); item.appendChild(document.createTextNode("Item " + i)); }
改为
var list = document.getElementById("myList"), html = "", i; for (i=0; i < 10; i++) { html += "<li>Item " + i + "</li>"; } list.innerHTML = html;
参考文献:《JavaScript高级程序设计(第三版)》。