循环
在大多数编程语言中,代码执行时间大部分消耗在循环中。循环处理是最常见的编程模式之一,也是提升性能必须关注的要点之一。理解JavaScript中循环对性能的影响至关重要,因为死循环或长时间运行的循环会严重影响用户体验。
循环的类型
- for循环
在for循环初始化中的var语句会创建一个函数级 的变量,而不是循环级。由于JavaScript只有函数级作用域,因此在for循环中定义一个新变量相当于在循环体外定义一个新变量。 - while
- do while
- for in
循环体每次运行时,prop变量被赋值为object的一个属性名(字符串),直到所有属性遍历完成才返回。所返回的属性包括对象实例属性以及从原型链中继承而来的属性。
性能
- 由干每次迭代操作会同时搜索实例或原型属性, for-in循环的每次迭代都会产生更多开销, 所以比其他循环类型要慢。
- 除非你明确需要迭代一个属性数量未知的对象, 否则应避免使用for-in循环。
- 如果你需要遍历一个数量有限的已知属性列表, 使用其他循环类型会更快, 比如可以使用数组。
var props = ["propl","prop2"] i = 0; while (i < props.length){ process(object[props[i++]]); }
- 不要使用for-in来遍历数组成员
- 除for-in循环外, 其他循环类型的性能都差不多, 深究哪种循环最快没有什么意义。 循环类型的选择应该基干需求而不是性能。
减少迭代的工作量
一个典型的数组处理循环可以采用三种循环中的任何一种:
//原始版本
for (var i=0; i < items.length; i++){
process(items [i]);
}
var j=O;
while (j < items.length){ process(items[j++]);
var k=O;
do {
process(items[ k++]);
} while (k < items.length);
在上面的循环中, 每次运行循环体时都会产生如下操作:
- 在控制条件中查找一次属性 (items.length)。
- 在控制条件中执行一次数值比较(i < items.length)。
- 一次比较操作查看控制条件的计算结果是否为true ( i < iterns. length == true)。
- 一次自增操作 (i++)。
- 一次数组查找(items[i])。
- 一次函数调用(process(items[i]))。
在这些简单的循环中, 即使代码不多, 每次迭代也要进行许多操作。 代码运行速度很大程度上取决于函数 process()对每个数组项的操作, 即使如此, 减少每次迭代中的操作总数能 大幅提高循环的总体性能。
-
优化循环的第一步是要减少对象成员及数组项的查找次数(正如第2章所讨论的)。
//最小化属性查找 for (var i=0, len=items.length; i < len; i++){ }
这些重写后的循环只在循环运行前对数组长度进行一次属性查找。 这使得控制条件可直接读取局部变量, 所以速度更快。 根据数组的长度, 在大多数浏览器中能节省大概 25%的运行时间 (IE 中甚至可以节省 50% )。
-
颠倒数组的顺序来提高循环性能
//减少属性金执并反转 for (var i=items.length; i--; ){ }
本例中使用了倒序循环, 并且把减法操作放在控制条件中。 现在每个控制条件只是简单地与零比较。 控制条件与true 值比较时, 任何非零数 会自动转换为true,而零值等同于false。 实际上, 控制条件已经从两次比较(选代数少于总数吗?它是否为 true? )减少到一次比较(它是true吗?)。 每次迭代从两次比较减少到一次, 进一步提高了循环速度。
对比原始版本, 每次选代中只有如下操作:
- 一次控制条件中的比较(i == true)。
- 一次减法操作(i–)。
- 一次数组查找(items[i])。
- 一次函数调用(process(items[i]))。
新的循环代码在每次迭代中减少了两次操作,随着迭代次数增加,性能的提升会更趋明显。
减少迭代次数
- 循环体运行时会带来一次小的性能开销, 这增加了总体运行时间。
- 减少迭代次数能获得更加显著的性能提升。
最广为人知的一种限制循环迭代次数的模式被成为 “达夫设备(Duff’s Device)”
Duff’s Device,是一个循环体展开技术,它使得一次迭代中实际上执行了多次迭代的操作。 Jeff Greenberg被认为是将 “Duff’s Device” 代码从原始的C实现移植到JavaScript中的第一人。 一个典型实现如下:
//credit:Jeff Greenberg
var iterations= Math.floor(items.length I 8),
startAt =items.length% 8,
i = 0;
do{
switch(startAt){
case o: 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);
Duff’s Device背后的基本理念是:每次循环中最多可调用8次 process()。 循环的迭代次数为 总数除以8。由于不是所有数字都能被8整除, 变量startAt用来存放余数,表示第一次循环中应调用多少次process()。 如果是12次, 那么第一次循环会调用process() 4次, 第二次循环调用process()8次,用两次循环替代了12次循环。
此算住一 个稍快的版本取消了switch语句, 井将余数处理和主循环分开 :
//credit: Jeff Greenberg
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 i吾句, 速度比原始循环更快。
是否应该使用Duff’s Device,无论是原始版本还是修改后的版本,都很大程度上依赖于选代次数。如果循环迭代次数小于 1000,很有可能看到它与常规循环结构相比只有微不足道的性能提升。如果迭代数超过 1000 ,那么Duff’s Device的执行效率将明显提升。例如在 500000次选代中, 其运行时间比常规循环少 70% 。
基于函数的迭代
数组方法 forEach()
尽管基于函数的迭代提供了 一个更为便利的迭代方怯, 但它仍然比基于循环的选代要慢 一些。 对每个数组项调用外部方法所带来的开销是速度慢的主要原因。 在所有情况下, 基于循环的迭代比基于函数的迭代快8倍,因此在运行 速度要求严格时, 基于函数的选代不是合适的选择。
条件语句
与循环的原理类似, 条件表达式决定了 JavaScript运行流的走向。 其他语言对应该使用 if-else语句还是switch语句的传统观点同样适用于JavaScript。由于不同的浏览器针对流程控制进行了不同的优化,因此使用哪种技术更好没有定论。
if-else对比switch
-
条件数量越大,越倾向于使用switch而不是if-else。这通常归结于代码的易读性。
-
多数情况下switch比if-else运行得要快,但只有当条件数量很大时才快得明 显
这两个语句主要性能区别是:当条件增加时,if-else性能负担增加的程度比switch要 多。因此,我们自然倾向于在条件数量较少时使用if-else,而在条件数量较大时使用switch,这从性能方面考虑也是合理的。
大多数的语言对switch语句的实现都采用了branchtable (分支表)索引来进行优化,另外,在JavaScript中,switch语句比较值时使用全等操作符,不会发生类型转换的损耗
-
通常来说,if-else适用干判断两个离散值或几个不同的值域。当判断多于两个离散值时, switch语句是更佳选择。
优化if-else
- 优化if-else的目标是:最小化到达正确分支前所需判断的条件数量。最简单的优化方能是确保最可能出现的条件放在首位。
- 另一种减少条件判断次数的方也是把if-else组织成一系列嵌套的if-else语句。
使用二分法把值域分成一系列的区间,然后逐步缩小范围。
查找表
JavaScript中可以使用数组和普通对象来构建查找表,通过查找表访问数据比用if-else或switch快很多,特别是在条件语句数量很大的时候。
//将返回位集合存入数组
var results= [result0, resultl, result2, result3, result4, result5, result6, result7, result8, result9, result10]
//返回当前结果
return results[value);
当你使用查找表时, 必须完全抛弃条件判断语句。 这个过程变成数组项查询或者对象成员查询。 查找表的 个主要优点是: 不用书写任何条件判断语句, 即便候选值数量增加时,也几乎不会产生额外的性能开销。
递归
递归函数的潜在问题是终止条件不明确或缺少终止条件会导致函数长时间运行, 并使得用户界面处于假死状态。 而且, 递归函数还可能遇到浏览器的 “调用栈大小限制”( Call stack size limites)。
调用栈限制
-
JavaScript引擎支持的递归数量与JavaScript调用栈大小直接相关。
只有IE例外, 它的调用栈与系统空闲内存有关, 而其他所有浏览器都有固定数量的调用栈限制。 大多数现代浏览器的调用栈数量比老版本浏览多出很多。
-
当你使用了太多的递归, 甚至超过最大调用栈容量时, 浏览器会报告以下出错信息:
- lncernet Explorer: “ Stack overflow at line X ”
- Firefox: “Too much recursion”
- Safari: “Maximum call stack size exceeded"
- Opera: “Abort (control stack overflow)”
Chrome是唯一不显示调用检溢出错误的浏览器。
-
关于调用钱溢出错误,也许最有趣的部分是,在某些浏览器中,它们的确是 JavaScript错误,因此能用try-catch表达式捕获。
如果不捕获它,这些错误会像其他错误一样向上冒泡传递(在Firefox中,冒泡停止于Firebug 和错误控制台),在Safari/Chrome中错误会显示在JavaScript控制台上)。只有IE例外,它不但显示一个JavaScript错误, 还会弹出一个类似alert警告的对话框显示栈溢出信息。
尽管在JavaScript中捕族这些错误是有可能的, 但并不推荐 这样做。 那些有潜在的调用栈溢出问题的脚本就不应该发布上线。
递归模式
- 接递归模式(函数调用自身)
在发生错误时,这个模式很容易定位。function recurse(){ recurse() } recurse()
- 隐伏模式
这种递归模式中,两个函数相互调用,形成一个无限宿环。这种模式更令人不安,在大型代码库中很难定位原因。function a(){ b() } function b(){ a() } a()
替代方案一:迭代
-
基于递归的合并排序算法
function mergeSort(arr) { if (arr.length < 2) { return arr; } const mid = Math.floor(arr.length / 2); const left = arr.slice(0, mid); const right = arr.slice(mid); return merge(mergeSort(left), mergeSort(right)); } function merge(left, right) { let result = []; while (left.length && right.length) { if (left[0] < right[0]) { result.push(left.shift()); } else { result.push(right.shift()); } } result. concat(left).concat(right); return result; }
这段合并排序的代码相当简单直观,但是mergeSort()函数会导致很频繁的自调用。一个长度为n的数组最终会调用mergeSort()2*n-1次,这意味着一个长度超过1500的数组会在Firefox上发生检溢出错误。
-
基于迭代的合并排序算法
//使用与前例相同的mergeSort()函数
function mergeSort(items){
if (items.length== 1) {
return items;
}
var work=[];
for ( var i=O, len=items.length; i < len; i ++ ){
work.push([items[i]]);
}
work.push([]); //如果数组长度为奇数
for(var lim=len; lim > 1; lim = (lim+1)/2){
for (var j=O,k=0; k < lim;j++,k+=2){
work[j] = merge(work[k], work[k+1]);
}
work[j] = []; //如果数组长反为奇数
}
return work[o];
}
这个版本的mergeSort()函数功能与前例相同却没有使用递归。尽管迭代版本的合并排序算法比递归实现得要慢一些,但它不会像递归版本那样受调用栈限制的影响。把递归算法改用迭代实现是避免栈溢出错误的方法之一。
Memoization
减少工作量就是最好的性能优化技术。代码要处理的事越少,它的运行速度就越快。沿着这个思路,避免重复工作也是有意义的。
多次执行相同的任务纯粹是浪费时间。Memoization 正是一种避免重复工作的方法,它缓存前一个计算结果供后续计算使用,避免了重复工作。这使得它成为递归算法中有用的技术。
// 阶乘
function factorial(n) {
// 检查是否已经计算过n的阶乘
if(!factorial.cache){
factorial.cache={
0:1,
1:1
}
}
if (factorial.cache[n]) {
return factorial.cache[n];
}
// 如果n小于等于1,直接返回1(阶乘的基准情况)
if (n <= 1) {
return 1;
}
// 否则,计算n的阶乘并存储到缓存中
factorial.cache[n] = n * factorial(n - 1, factorial.cache);
return factorial.cache[n];
}
小结
JavaScript和其他编程语言一样,代码的写也和算能会影响运行时间。与其他语言不同的是, JavaScript可用资源有限,因此优化技术更为重要。
- for、while和do-while循环性能特性相当,并没有一种循环类型明显快于或慢于其他类型。
- 避免使用for-in循环, 除非你需要遍历一个属性数量未知的对象。
- 改善循环性能的最佳方式是减少每次迭代的运算量和减少循环选代次数。
- 通常来说, switch总是比if-else快, 但并不总是最佳解决方案。
- 在判断条件较多时, 使用查找表比if-else和switch更快。
- 浏览器的调用栈大小限制了递归算法在JavaScript中的应用;栈溢出错误会导致其他代码中断运行。
- 如果你遇到栈溢出错误, 可将方法改为迭代算法, 或使用Memoization来避免重复计算。
运行的代码数量越大, 使用这些策略所带来的性能提升也就越明显。