在大多数编程语言中,代码执行时间多数在循环中度过。在一系列编程模式中,循环是最常用的模式之一,因此也是提高性能必须关注的地区之一。理解JavaScript中循环对性能的影响至关重要,因为死循环或者长时间运行的循环会严重影响用户体验。
一、循环的类型
ECMA-263标准第三版规定了JavaScript的基本语法和行为,定义了四种类型的循环。第一个是标准的for循环,与类C语言使用同样的语法:
for (var i=0; i < 10; i++){
//loop body
}
for循环大概是最常用的JavaScript 循环结构。它由四部分组成:初始化体,前测条件,后执行体,循环体。当遇到一个for 循环时,初始化体首先执行,然后进入前测条件。如果前测条件的计算结果为true,则执行循环体。然后运行后执行体。for 循环封装上的直接性是开发者喜欢的原因。
第二种循环是while 循环。while 循环是一个简单的预测试循环,由一个预测试条件和一个循环体构成:
var i = 0;
while(i < 10){
//loop body
i++;
}
在循环体执行之前,首先对前测条件进行计算。如果计算结果为true,那么就执行循环体;否则循环体将被跳过。任何for 循环都可以写成while 循环,反之亦然。
第三种循环类型是do-while 循环。do-while 循环是JavaScript 中唯一一种后测试的循环,它包括两部分:循环体和后测试条件体:
var i = 0;
do {
//loop body
} while (i++ < 10);
在一个do-while 循环中,循环体至少运行一次,后测试条件决定循环体是否应再次执行。
第四种也是最后一种循环称为for-in 循环。此循环有一个非常特殊的用途:它可以枚举任何对象的命名属性。其基本格式如下:
for (var prop in object){
//loop body
}
每次循环执行,属性变量被填充以对象属性的名字(一个字符串),直到所有的对象属性遍历完成才返回。返回的属性包括对象的实例属性和它从原型链继承而来的属性。
二、循环性能
循环性能争论的源头是应当选用哪种循环。在JavaScript 提供的四种循环类型中,只有一种循环比其他循环明显要慢:for-in 循环。
由于每次迭代操作要搜索实例或原形的属性,for-in 循环每次迭代都要付出更多开销,所以比其他类型循环慢一些。在同样的循环迭代操作中,for-in 循环比其他类型的循环慢7 倍之多。因此推荐的做法如下:除非你需要对数目不详的对象属性进行操作,否则避免使用for-in 循环。如果你迭代遍历一个有限的,已知的属性列表,使用其他循环类型更快,可使用如下模式:
var props = ["prop1", "prop2"],
i = 0;
while (i < props.length){
process(object[props[i]]);
}
此代码创建一个由成员和属性名构成的队列。while 循环用于遍历这几个属性并处理所对应的对象成员,而不是遍历对象的每个属性。此代码只关注感兴趣的属性,节约了循环时间。
除for-in 循环外,其他循环类型性能相当,难以确定哪种循环更快。选择循环类型应基于需求而不是性能。
如果循环类型与性能无关,那么如何选择?其实只有两个因素:每次迭代干什么;迭代的次数
通过减少这两者中一个或者全部(的执行时间),你可以积极地影响循环的整体性能。减少迭代的工作量
不言而喻,如果一次循环迭代需要较长时间来执行,那么多次循环将需要更长时间。限制在循环体内进
行耗时操作的数量是一个加快循环的好方法。
一个典型的数组处理循环,可使用三种循环的任何一种。最常用的代码写法如下:
for (var i=0; i < items.length; i++){
process(items[i]);
}
var j=0;
while (j < items.length){
process(items[j++]]);
}
var k=0;
do {
process(items[k++]);
} while (k < items.length);
在每个循环中,每次运行循环体都要发生如下几个操作:
1. 在控制条件中读一次属性(items.length)
2. 在控制条件中执行一次比较(i < items.length)
3. 比较操作,察看条件控制体的运算结果是不是true(i < items.length == true)
4. 一次自加操作(i++)
5. 一次数组查找(items[i])
6. 一次函数调用(process(items[i]))
在这些简单的循环中,即使没有太多的代码,每次迭代也要进行许多操作。代码运行速度很大程度上由process()对每个项目的操作所决定,即使如此,减少每次迭代中操作的总数可以大幅度提高循环整体性能。
优化循环工作量的第一步是减少对象成员和数组项查找的次数。在大多数浏览器上,这些操作比访问局部变量或直接量需要更长时间。前面的例子中每次循环都查找items.length。这是一种浪费,因为该值在循环体执行过程中不会改变,因此产生了不必要的性能损失。你可以简单地将此值存入一个局部变量中,在控制条件中使用这个局部变量,从而提高了循环性能:
for (var i=0, len=items.length; i < len; i++){
process(items[i]);
}
var j=0,
count = items.length;
while (j < count){
process(items[j++]]);
}
var k=0,
num = items.length;
do {
process(items[k++]);
} while (k < num);
这些重写后的循环只在循环执行之前对数组长度进行一次属性查询。这使得控制条件只有局部变量参与运算,所以速度更快。根据数组的长度,在大多数浏览器上你可以节省大约25%的总循环时间(在InternetExplorer可节省50%)。
你还可以通过改变他们的顺序提高循环性能。通常,数组元素的处理顺序与任务无关,你可以从最后一个开始,直到处理完第一个元素。倒序循环是编程语言中常用的性能优化方法,但一般来说不太容易理解。在JavaScript中,倒序循环可以略微提高循环性能,只要你消除因此而产生的额外操作:
for (var i=items.length; i–; ){
process(items[i]);
}
var j = items.length;
while (j–){
process(items[j]]);
}
var k = items.length-1;
do {
process(items[k]);
} while (k–);
例子中使用了倒序循环,并在控制条件中使用了减法。每个控制条件只是简单地与零进行比较。控制条件与true 值进行比较,任何非零数字自动强制转换为true,而零等同于false。实际上,控制条件已经从两次比较(迭代少于总数吗?它等于true 吗?)减少到一次比较(它等于true 吗?)。将每个迭代中两次比较减少到一次可以大幅度提高循环速度。通过倒序循环和最小化属性查询,你可以看到执行速度比原始版本快了50%-60%。与原始版本相比,每次迭代中只进行如下操作:
1. 在控制条件中进行一次比较(i == true)
2. 一次减法操作(i–)
3.一次数组查询(items[i])
4. 一次函数调用(process(items[i]))
新循环代码每次迭代中减少两个操作,随着迭代次数的增长,性能将显著提升。
即使循环体中最快的代码,累计迭代上千次(也将是不小的负担)。此外,每次运行循环体时都会产生一个很小的性能开销,也会增加总的运行时间。减少循环的迭代次数可获得显著的性能提升。最广为人知的限制循环迭代次数的模式称作“达夫设备”。
达夫设备是一个循环体展开技术,在一次迭代中实际上执行了多次迭代操作。Jeff Greenberg 被认为是将达夫循环从原始的C 实现移植到JavaScript 中的第一人。一个典型的实现如下:
var iterations = Math.floor(items.length / 8),
startAt = items.length % 8,
i = 0;
do {
switch(startAt){
case 0: process(items[i++]);
case 7: process(items[i++]);
case 6: process(items[i++]);
case 5: process(items[i++]);
case 4: process(items[i++]);
case 3: process(items[i++]);
case 2: process(items[i++]);
case 1: process(items[i++]);
}
startAt = 0;
} while (–iterations);
达夫设备背后的基本理念是:每次循环中最多可8次调用process()函数。循环迭代次数为元素总数除以8。因为总数不一定是8 的整数倍,所以startAt变量存放余数,指出第一次循环中应当执行多少次process()。比方说现在有12个元素,那么第一次循环将调用process()4次,第二次循环调用process()8次,用2次循环代替了12 次循环。
此算法一个稍快的版本取消了switch 表达式,将余数处理与主循环分开:
var i = items.length % 8;
while(i){
process(items[i--]);
}
i = Math.floor(items.length / 8);
while(i){
process(items[i--]);
process(items[i--]);
process(items[i--]);
process(items[i--]);
process(items[i--]);
process(items[i--]);
process(items[i--]);
process(items[i--]);
}
虽然此代码中使用两个循环替代了先前的一个,但它去掉了循环体中的switch 表达式,速度更快。是否值得使用达夫设备,无论是原始的版本还是修改后的版本,很大程度上依赖于迭代的次数。如果循环迭代次数少于1'000 次,你可能只看到它与普通循环相比只有微不足道的性能提升。如果迭代次数超过1'000 次,达夫设备的效率将明显提升。例如500'000 次迭代中,运行时间比普通循环减少到70%。
三、基于函数的迭代
ECMA-262标准第四版介绍了本地数组对象的一个新方法forEach()。此方法遍历一个数组的所有成员,并在每个成员上执行一个函数。在每个元素上执行的函数作为forEach()的参数传进去,并在调用时接收三个参数,它们是:数组项的值,数组项的索引,和数组自身。下面是用法举例:
items.forEach(function(value, index, array){
process(value);
});
forEach()函数在Firefox,Chrome,和Safari中为原生函数。另外,大多数JavaScript库都有等价实现:
//YUI 3
Y.Array.each(items, function(value, index, array){
process(value);
});
//jQuery
jQuery.each(items, function(index, value){
process(value);
});
//Dojo
dojo.forEach(items, function(value, index, array){
process(value);
});
//Prototype
items.each(function(value, index){
process(value);
});
//MooTools
$each(items, function(value, index){
process(value);
});
尽管基于函数的迭代显得更加便利,它还是比基于循环的迭代要慢一些。每个数组项要关联额外的函数调用是造成速度慢的原因。在所有情况下,基于函数的迭代占用时间是基于循环的迭代的八倍,因此在关注执行时间的情况下它并不是一个合适的办法。