前言
前端开发中经常涉及到数组的相关操作:去重、过滤、求和、数据二次处理等等。都需要我们对数组进行循环。为了满足各种需求,JS除了提供最简单的for
循环,在ES6
和后续版本中也新增的诸如:map、filter、some、reduce
等实用的方法。因为各个方法作用不同,简单的对所有涉及到循环的方法进行执行速度比较,是不公平的,也是毫无意义的。那么我们就针对最单纯的以取值为目的的循环进行一次性能和效率测试,用肉眼可见的方式,对JS中常见的这些数组循环方式进行一次探讨。
从最简单的for循环说起
for
循环常见的四种写法,不啰嗦,直接上代码
const persons = ['郑昊川', '钟忠', '高晓波', '韦贵铁', '杨俊', '宋灿']
// 方法一
for (let i = 0; i < persons.length; i++) {
console.log(persons[i])
}
// 方法二
for (let i = 0, len = persons.length; i < len; i++) {
console.log(persons[i])
}
// 方法三
for (let i = 0, person; person = persons[i]; i++) {
console.log(person)
}
// 方法四
for (let i = persons.length; i--;) {
console.log(persons[i])
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
第一种方法是最常见的方式,不解释。
第二种方法是将
persons.length
缓存到变量len
中,这样每次循环时就不会再读取数组的长度。第三种方式是将取值与判断合并,通过不停的枚举每一项来循环,直到枚举到空值则循环结束。执行顺序是:
- 第一步:先声明索引
i = 0
和变量person
- 第二步:取出数组的第
i
项persons[i]
赋值给变量person
并判断是否为Truthy - 第三步:执行循环体,打印
person
- 第四步:
i++
。
- 第一步:先声明索引
当第二步中
person
的值不再是Truthy时,循环结束。方法三甚至可以这样写
js
for (let i = 0, person; person = persons[i++];) {
console.log(person)
}
- 第四种方法是倒序循环。执行的顺序是:
- 第一步:获取数组长度,赋值给变量
i
- 第二步:判断
i
是否大于0并执行i--
- 第三步:执行循环体,打印
persons[i]
,此时的i
已经-1
了
- 第一步:获取数组长度,赋值给变量
从后向前,直到
i === 0
为止。这种方式不仅去除了每次循环中读取数组长度的操作,而且只创建了一个变量i
。四种
for
循环方式在数组浅拷贝中的性能和速度测试先造一个足够长的数组作为要拷贝的目标(如果
i
值过大,到亿级左右,可能会抛出JS堆栈跟踪的报错)const ARR_SIZE = 6666666 const hugeArr = new Array(ARR_SIZE).fill(1)
- 1
- 2
然后分别用四种循环方式,把数组中的每一项取出,并添加到一个空数组中,也就是一次数组的浅拷贝。并通过console.time和console.timeEnd记录每种循环方式的整体执行时间。通过process.memoryUsage()比对执行前后内存中已用到的堆的差值。
/* node环境下记录方法执行前后内存中已用到的堆的差值 */ function heapRecord(fun) { if (process) { const startHeap = process.memoryUsage().heapUsed fun() const endHeap = process.memoryUsage().heapUsed const heapDiff = endHeap - startHeap console.log('已用到的堆的差值: ', heapDiff) } else { fun() } }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
// 方法一,普通for循环 function method1() { var arrCopy = [] console.time('method1') for (let i = 0; i < hugeArr.length; i++) { arrCopy.push(hugeArr[i]) } console.timeEnd('method1') } // 方法二,缓存长度 function method2() { var arrCopy = [] console.time('method2') for (let i = 0, len = hugeArr.length; i < len; i++) { arrCopy.push(hugeArr[i]) } console.timeEnd('method2') } // 方法三,取值和判断合并 function method3() { var arrCopy = [] console.time('method3') for (let i = 0, item; item = hugeArr[i]; i++) { arrCopy.push(item) } console.timeEnd('method3') } // 方法四,i--与判断合并,倒序迭代 function method4() { var arrCopy = [] console.time('method4') for (let i = hugeArr.length; i--;) { arrCopy.push(hugeArr[i]) } console.timeEnd('method4') }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
分别调用上述方法,每个方法重复执行12次,去除一个最大值和一个最小值,求平均值(四舍五入),最终每个方法执行时间的结果如下表(测试机器:
MacBook Pro (15-inch, 2017) 处理器:2.8 GHz Intel Core i7 内存:16 GB 2133 MHz LPDDR3
执行环境:node v10.8.0
):- 方法一 方法二 方法三 方法四 第一次 152.201ms 156.990ms 152.668ms 152.684ms 第二次 150.047ms 159.166ms 159.333ms 152.455ms 第三次 155.390ms 151.823ms 159.365ms 149.809ms 第四次 153.195ms 155.994ms 155.325ms 150.562ms 第五次 151.823ms 154.689ms 156.483ms 148.067ms 第六次 152.715ms 154.677ms 153.135ms 150.787ms 第七次 152.084ms 152.587ms 157.458ms 152.572ms 第八次 152.509ms 153.781ms 153.277ms 152.263ms 第九次 154.363ms 156.497ms 151.002ms 154.310ms 第十次 153.784ms 155.612ms 161.767ms 153.487ms 平均耗时 152.811ms 155.182ms 155.981ms 151.700ms 用栈差值 238511136Byte 238511352Byte 238512048Byte 238511312Byte 意不意外?惊不惊喜?想象之中至少方法二肯定比方法一更快的!但事实并非如此,不相信眼前事实的我又测试了很多次,包括改变被拷贝的数组的长度,长度从百级到千万级。最后发现:在
node
下执行完成同一个数组的浅拷贝任务,耗时方面四种方法的差距微乎其微,有时候排序甚至略有波动。
内存占用方面:**方法一小结
考虑到在不同环境或浏览器下的性能和效率:
推荐
:第四种i–
倒序循环的方式。在奇舞团的这篇文章——嗨,送你一张Web性能优化地图的2.3 流程控制
小节里也略有提及这种方式。不推荐
:第三种方式。主要是因为当数组里存在非Truthy
的值时,比如0
和”
,会导致循环直接结束。while
循环以及ES6+的新语法forEach
、map
和for of
,会更快吗?不啰嗦,实践是检验真理的唯一标准
// 方法五,while function method5() { var arrCopy = [] console.time('method5') let i = 0 while (i < hugeArr.length) { arrCopy.push(hugeArr[i++]) } console.timeEnd('method5') } // 方法六,forEach function method6() { var arrCopy = [] console.time('method6') hugeArr.forEach((item) => { arrCopy.push(item) }) console.timeEnd('method6') } // 方法七,map function method7() { var arrCopy = [] console.time('method7') arrCopy = hugeArr.map(item => item) console.timeEnd('method7') } // 方法八,for of function method8() { var arrCopy = [] console.time('method8') for (let item of hugeArr) { arrCopy.push(item) } console.timeEnd('method8') }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
测试方法同上,测试结果:
- 方法五 方法六 方法七 方法八 第一次 151.380ms 221.332ms 875.402ms 240.411ms 第二次 152.031ms 223.436ms 877.112ms 237.208ms 第三次 150.442ms 221.853ms 876.829ms 253.744ms 第四次 151.319ms 222.672ms 875.270ms 243.165ms 第五次 150.142ms 222.953ms 877.940ms 237.825ms 第六次 155.226ms 225.441ms 879.223ms 240.648ms 第七次 151.254ms 219.965ms 883.324ms 238.197ms 第八次 151.632ms 218.274ms 878.331ms 240.940ms 第九次 151.412ms 223.189ms 873.318ms 256.644ms 第十次 155.563ms 220.595ms 881.203ms 234.534ms 平均耗时 152.040ms 221.971ms 877.795ms 242.332ms 用栈差值 238511400Byte 238511352Byte 53887824Byte 191345296Byte 在
node
下,由上面的数据可以很明显的看出,forEach
、map
和for of
这些ES6+
的语法并没有传统的for
循环或者while
循环快,特别是map
方法。但是由于map
有返回值,无需额外调用新数组的push
方法,所以在执行浅拷贝任务上,内存占用很低。而for of
语法在内存占用上也有一定的优势。顺便提一下:for循环 while循环 for of 循环
是可以通过break
关键字跳出的,而forEach map
这种循环是无法跳出的。但是随着执行环境和浏览器的不同,这些语法在执行速度上也会出现偏差甚至反转的情况,直接看图:
谷歌浏览器
火狐浏览器
safari浏览器下
可以看出:
- 谷歌浏览器中
ES6+
的循环语法会普遍比传统的循环语法慢,但是火狐和safari中情况却几乎相反。 - 谷歌浏览器的各种循环语法的执行耗时上差距并不大。但
map
特殊,速度明显比其他几种语法慢,而在火狐和safari中却出现了反转,map
反而比较快! - 苹果大法好
总结
之前有听到过诸如“缓存数组长度可以提高循环效率”或者“ES6的循环语法更高效”的说法。说者无心,听者有意,事实究竟如何,实践出真知。抛开业务场景和使用便利性,单纯谈性能和效率是没有意义的。 ES6新增的诸多数组的方法确实极大的方便了前端开发,使得以往复杂或者冗长的代码,可以变得易读而且精炼,而好的
for
循环写法,在大数据量的情况下,确实也有着更好的兼容和多环境运行表现。当然本文的讨论也只是基于观察的一种总结,并没有深入底层。而随着浏览器的更新,这些方法的孰优孰劣也可能成为玄学。目前发现在Chrome Canary 70.0.3513.0
下for of
会明显比Chrome 68.0.3440.84
快。如果你有更深入的见解或者文章,也不妨在评论区分享,小弟的这篇文章也权当抛砖引玉。如果你对数组的其他循环方法的性能和效率也感兴趣,不妨自己动手试一试,也欢迎评论交流。本文的测试环境:
node v10.8.0
、Chrome 68.0.3440.84
、Safari 11.1.2 (13605.3.8)
、Firefox 60.0