前言
最近在扒拉着Lodash的源码仓库在看各个函数的实现,发现在array|object的方法实现上充斥着大量的while、for...xx,不明白在不同的操作中为何使用不同的遍历方式,有感而发决定深入了解一番。
导读
遍历数组或对象是一名程序员的基本素养之一. 然而遍历却不是一件简单的事, 优秀的程序员知道怎么去选择合适的遍历方法, 优化遍历效率. 本篇将带你走进JavaScript遍历的世界, 享受分析JS循环的快感. 本篇所有代码都可以直接运行, 希望您通读本篇后, 不止是浏览, 最好是亲手去实践下.
概述
Javascript有如下两种数据需要经常遍历
- 数组(Array)
- 对象(Object)
同时又提供了如下13种方法方便我们遍历元素
- for
- while(或do~while)
- forEach
- for...in
- for...of
- map()
- some()
- every()
- filter()
- reduce()
- reduceRight()
- find()
- findIndex()
最终我们将分析遍历效率选出最佳遍历选手.
本文将针对如下两种数据进行详细的分析和举栗. 下面举栗中如果不加特殊说明将会用到如下数据.
const array = ['囚徒', '过客', '领袖']; // 职场3种人
const o = { 0: 'linda', 1: 'style', 2: 'nick', length: 3 };
for
语法: for(初始化; 循环执行条件; 每遍历一个元素后做的事情;){}
// 普通版for
(function () {
for (let index = 0; index < array.length; index++) {
console.log(array[index]);
}
})();
// 优化版for
// 简要说明: 使用临时变量,将长度缓存起来,避免重复获取数组长度,当数组较大时优化效果才会比较明显
(function () {
// 循环置于闭包之内
for (let index = 0, length = array.length; index < length; index++) {
// 缓存数组长度
console.log(array[index]); // 内部方法若有可能相互影响,也要置于闭包之内
}
})();
for循环只能遍历数组, 不能遍历对象. 写for循环时有两点需要注意.
- 其一, 为了避免遍历时执行多遍计算数组长度的操作, 影响效率, 建议在循环开始以变量的形式缓存下数组长度, 若在循环内部有可能改变数组长度, 请务必慎重处理, 避免数组越界.
- for循环内部定义的变量会直接暴露在外(如 index,循环退出后,i变量将等于数组长度, 后续代码将能访问到 i 变量的值), 因此建议将for循环置于闭包内. 特别要注意的是: 如果在循环内部, 前一个元素的遍历有可能影响到后一个元素的遍历, 那么for循环内部方法也需要置于闭包之内.
- 当然现在更推荐let定义内部变量
do/while
语法: do{…}while(true);
(function () {
let index = 0;
const len = array.length;
do {
if (index == 2) {
break; // 循环被终止, 此处如果是continue就会造成循环无法退出
}
console.log(`array[${index}]: ${array[index]}`);
index++; // 此句建议放置循环while头部
} while (index < len);
})();
do/while的语法简化了循环的实现, 只保留对循环条件的判断, 所以我们要在循环内部构造出循环退出的条件, 否则有可能造成死循环. 特别要注意的是: 使用 continue 跳出本次遍历时, 要保证循环能够自动进入到下一次遍历, 因此保证循环走到下一次遍历的语句需要放到 continue 前面执行, 建议置于循环头部.(如上, index++ 语句最好放置循环头部)
do/while 循环与for循环大体差不多,只支持数组遍历, 多用于对循环退出条件不是很明确的场景. 一般来说不建议使用这种方式遍历数组.
另外相较于for语义化较弱,除工具库、私有方法外在实际业务中更推荐for遍历,尤其是多人维护的项目
forEach
语法: array.forEach(function(item){}), 参数item表示数组每一项的元素
array.forEach((item, index, array) => {
if (item == '囚徒') return false; // 这里只能使用return跳过当前元素处理
console.log(item);
});
forEach回调function默认有三个参数: item, index, array.
使用forEach循环有几点需要特别注意:
- forEach无法遍历对象
- forEach无法在IE中使用,只是在firefox和chrome中实现了该方法
- forEach无法使用break,continue跳出循环,使用return时,效果和在for循环中使用continue一致
for in
语法: for(const item in array){}
for (const item in array) {
console.log(item);
} // 0 1 2
for (const item in o) {
console.log(item);
} // 0 1 2 length
for in 可用于遍历数组和对象, 但它输出的只是数组的索引和对象的key, 我们可以通过索引和key取到对应的值. 如下:
for (const item in array) {
console.log(array[item]);
} // "囚徒" "过客" "领袖"
for (const item in o) {
console.log(o[item]);
} // "linda" "style" "nick" "length"
for of
语法: for(const item of array){}
for (const item of array) {
console.log(item);
}
可以正确响应break、continue和return语句
map
即 Array.prototype.map,该方法只支持数组
语法: array.map(callback[,thisArg]) map方法使用其提供函数的每次返回结果生成一个新的数组.
const array = [1, 4, 9];
const roots = array.map(Math.sqrt); // map包裹方法名
console.log(roots)
// roots is now [1, 2, 3], array is still [1, 4, 9]
const doubles = array.map(function (num) {
// map包裹方法实体
return num * 2;
});
console.log(doubles)
// doubles is now [2, 8, 18]. array is still [1, 4, 9]
实际上,由于map方法被设计成支持 [鸭式辨型][] , 该方法也可以用来处理形似数组的对象, 例如 NodeList.
const elems = document.querySelectorAll('select option:checked');
const values = Array.prototype.map.call(elems, function (obj) {
return obj.value;
});
甚至还可以用来处理字符串, 如下:
const map = Array.prototype.map;
const array = map.call('Hello 中国', function (x) {
return x.charCodeAt(0);
});
console.log(array);
// [72, 101, 108, 108, 111, 32, 20013, 22269]
map处理字符串的方式多种多样, 例如 反转等.
const str = '12345';
const output = Array.prototype.map
.call(str, function (x) {
return x;
})
.reverse()
.join('');
console.log(output); // 54321
例如 将字符串数组转换为数字数组, 只需一条语句, 如下:
console.log(['1', '2', '3'].map(Number)); // [1,2,3]
目前map方法被大部分浏览器支持, 除了IE 6,7,8.
some
some()是对数组中每一项运行指定函数,如果该函数对任一项返回true,则返回true。
语法: array.some(callback[,thisArg])
const arr = [4, 5, 6]
arr.some(function (item, index, array) {
return item > 3;
}) // true
every
即 Array.prototype.every, 该方法同上述map方法也只支持数组
语法: arr.every(callback[, thisArg]) every 方法用于检验数组中的每一项是否符合某个条件, 若符合则放回true, 反之则返回false.
function isBigEnough(element, index, array) {
return element >= 10;
}
[12, 5, 8, 130, 44].every(isBigEnough); // false
[12, 54, 18, 130, 44].every(isBigEnough); // true
该方法还有简写方式, 如下:
[12, 5, 8, 130, 44].every((elem) => elem >= 10); // false
[12, 54, 18, 130, 44].every((elem) => elem >= 10); // true
filter
filter() 方法创建给定数组一部分的浅拷贝,其包含通过所提供函数实现的测试的所有元素。 语法:array.filter(function(currentValue,index,arr), thisValue)
const words = ['spray', 'limit', 'elite', 'exuberant', 'destruction', 'present'];
const result = words.filter((word) => word.length > 6);
console.log(result);
// Expected output: Array ["exuberant", "destruction", "present"]
reduce
reduce() 方法对数组中的每个元素按序执行一个由您提供的 reducer 函数,每一次运行 reducer 会将先前元素的计算结果作为参数传入,最后将其结果汇总为单个返回值。
第一次执行回调函数时,不存在“上一次的计算结果”。如果需要回调函数从数组索引为 0 的元素开始执行,则需要传递初始值。否则,数组索引为 0 的元素将被作为初始值 initialValue,迭代器将从第二个元素开始执行(索引为 1 而不是 0)。
语法:array.reduce(function(total, currentValue, currentIndex, arr), initialValue)
const array = [1, 2, 3, 4];
// 0 + 1 + 2 + 3 + 4
const initialValue = 0;
const sumWithInitial = array.reduce((accumulator, currentValue) => accumulator + currentValue, initialValue);
console.log(sumWithInitial);
// Expected output: 10
reduceRight
reduceRight()方法的功能和reduce()功能是一样的,不同的是reduceRight()从数组的末尾向前将数组中的数组项做累加。 reduceRight()首次调用回调函数callbackfn时,prevValue 和 curValue 可以是两个值之一。如果调用 reduceRight() 时提供了 initialValue 参数,则 prevValue 等于 initialValue,curValue 等于数组中的最后一个值。如果没有提供 initialValue 参数,则 prevValue 等于数组最后一个值, curValue 等于数组中倒数第二个值。。
语法:array.reduceRight(function(total, currentValue, currentIndex, arr), initialValue)
const array1 = [
[0, 1],
[2, 3],
[4, 5],
];
const result = array1.reduceRight((accumulator, currentValue) => accumulator.concat(currentValue));
console.log(result);
// Expected output: Array [4, 5, 2, 3, 0, 1]
find
find() 方法返回数组中满足提供的测试函数的第一个元素的值。否则返回 undefined。
语法:array.find(function(currentValue, index, arr),thisValue)
const array = [5, 12, 8, 130, 44];
const found = array.find((element) => element > 10);
console.log(found);
// Expected output: 12
findIndex
findIndex()方法返回数组中满足提供的测试函数的第一个元素的索引。若没有找到对应元素则返回 -1。
语法:array.find(function(currentValue, index, arr),thisValue)
const array = [5, 12, 8, 130, 44];
const isLargeNumber = (element) => element > 13;
console.log(array.findIndex(isLargeNumber));
// Expected output: 3
以上, 遍历数组和对象的8种方法简单的介绍完, 小结如下:
- for in 既支持对象也支持数组遍历;
- for , do/while , forEach 只支持数组;
- Array.prototype.map, Array.prototype.every 只支持数组和形似数组的对象;
- forEach不能退出循环,只能通过return来进入到下一个元素的遍历中(相当于for循环的continue), 且在IE没有实现该方法;
测试各方法效率
下面我们来测试下上述方法的效率.
如下是测试代码:
let array = Array(10000000); // 10^6
const length = array.length;
let startTime = +new Date();
let endTime = +new Date();
/for
startTime = +new Date();
for (let index = 0; index < length; index++) {}
endTime = +new Date();
console.log(`for: ${endTime - startTime}`);
// ---------- do/while --------------
startTime = +new Date();
let index = 0;
do {
index++;
} while (index < length);
endTime = +new Date();
console.log(`do while: ${endTime - startTime}`);
// ---------- forEach --------------
startTime = +new Date();
array.forEach((item) => {});
endTime = +new Date();
console.log(`forEach: ${endTime - startTime}`);
// ---------- for in --------------
startTime = +new Date();
for (const item in array) {
}
endTime = +new Date();
console.log(`for in: ${endTime - startTime}`);
// ---------- for of --------------
startTime = +new Date();
for (const value of array) {
}
endTime = +new Date();
console.log(`for of: ${endTime - startTime}`);
// ---------- map --------------
startTime = +new Date();
array.map((num) => {});
endTime = +new Date();
console.log(`map: ${endTime - startTime}`);
// ---------- every --------------
startTime = +new Date();
array.every((e, i, arr) => {});
endTime = +new Date();
console.log(`every: ${endTime - startTime}`);
// ---------- some --------------
startTime = +new Date();
array.some((e, i, arr) => {});
endTime = +new Date();
console.log(`every: ${endTime - startTime}`);
// ---------- filter --------------
startTime = +new Date();
array.filter((e, i, arr) => {});
endTime = +new Date();
console.log(`every: ${endTime - startTime}`);
// ---------- reduce --------------
startTime = +new Date();
array.reduce((previousValue, currentValue, index, array) => {});
endTime = +new Date();
console.log(`every: ${endTime - startTime}`);
// ---------- reduceRight --------------
startTime = +new Date();
array.reduceRight((accumulator, currentValue, index, array) => {});
endTime = +new Date();
console.log(`every: ${endTime - startTime}`);
// ---------- find --------------
startTime = +new Date();
array.find((e, i, arr) => {});
endTime = +new Date();
console.log(`every: ${endTime - startTime}`);
// ---------- findIndex --------------
startTime = +new Date();
array.findIndex((e, i, arr) => {});
endTime = +new Date();
console.log(`every: ${endTime - startTime}`);
测试机器正常运行 IDE, 编辑器, 浏览器, 微信等常用应用, 系统空闲. 硬件设备如下:
- 操作系统: Windows 10 专业版
- 处理器: 12th Gen Intel(R) Core(TM) i7-1260P 2.10 GHz
- 内存: 16.0 GB
以上多轮测试结果汇总如下三张表(单位:ms):
数组长度为10^6
| 数组长度为10^6 | chrome 109.0.5414.75 (64-bit) (100次均值) |
|---|---|
| for | 3.56 |
| do while | 4.48 |
| for of | 5.26 |
| for in | 10.47 |
| forEach | 15.74 |
| filter | 15.9 |
| some | 16.38 |
| every | 18.8 |
| map | 40.65 |
| find | 55.7 |
| findIndex | 85.63 |
| reduce | Error(遍历空数组报错) |
| reduceRight | Error(遍历空数组报错) |
测试代码:
let array = Array(10000000); // 10^6
const length = array.length;
let startTime = +new Date(); // 开始时间
let endTime = +new Date(); // 结束时间
let resultTime = []; // 结果集合
let meanTalue = 0; // 平均值
const CYCLE_INDEX = 100; // 循环次数
//------------自行替换-------------
for (let index = 0; index < CYCLE_INDEX; index++) {
startTime = +new Date();
for (let index = 0; index < length; index++) {}
endTime = +new Date();
resultTime.push(endTime - startTime);
}
//--------------------------------
resultTime.sort((a, b) => b - a); // 从大到小排序
meanTalue =
resultTime.reduce((prev, current, index, arr) => {
return prev + current;
}) / CYCLE_INDEX;
console.log(resultTime, `平均时间: ${meanTalue}`);
数组长度为10^7
| 数组长度为10^7 | chrome 109.0.5414.75 (64-bit) (5次 最大最小值平均) |
|---|---|
| for | [35, 36, 37, 42, 50] / 42.5 |
| do while | [95, 100, 100, 102, 108] / 101.5 |
| find | [708, 687, 679, 675, 22] / 554.2 |
| for of | [829, 816, 797, 797, 790] / 805.8 |
| forEach | [915, 917, 927, 939, 948] / 931.5 |
| map | [889, 898, 900, 909, 1123] / 1006 |
| every | [1044, 1053, 1058, 1086, 1113] / 1078.5 |
| filter | [1358, 1349, 1335, 1283, 950] / 1255 |
| some | [1617, 1571, 1103, 1099, 1080] / 1294 |
| findIndex | [4394, 4361, 4314, 4051, 3747] / 4173.4 |
| for in | [0, 0, 0, 0, 0] / 0(数据异常) |
| reduce | Error(遍历空数组报错) |
| reduceRight | Error(遍历空数组报错) |
数组长度为10^8
| 数组长度为10^8 | chrome 109.0.5414.75 (64-bit) (5次 最大最小值平均) |
|---|---|
| for | [329, 335, 347, 352, 373] / 351 |
| do while | [937, 947, 950, 973, 985] / 961 |
| for of | [9747, 9343, 9222, 9189, 9065] / 9313.2 |
| forEach | [9452, 9804, 10023, 10066, 10173] / 9812.5 |
| filter | [14686, 12624, 10168, 9620, 9514] / 11322.4 |
| map | [9997, 10634, 10945, 10822, 13375] / 11686 |
| some | [12267, 11645, 11586, 11521, 11432] / 11690.2 |
| every | [11312, 11602, 11703, 12344, 13189] / 12250.5 |
| find | [53989, 32936, 31072, 30710, 30311] / 35803.6 |
| findIndex | [65669, 61596, 54184, 35482, 34076] / 50201.4 |
| for in | [0, 0, 0, 0, 0] / 0(数据异常) |
| reduce | Error(遍历空数组报错) |
| reduceRight | Error(遍历空数组报错) |
综上, 我们发现for in 循环的性能不稳定, 猜测它可能没有进入循环. 因此将数组各元素进行如下赋值. 重新进行如下两轮测试.
for (let i = 0; i < length; i++) {
array[i] = '不识石务';
}
数组赋值后, 数组长度为10^6
| 数组长度为10^6 | chrome 109.0.5414.75 (64-bit) (100次均值) |
|---|---|
| for | 3.47 |
| for of | 5.13 |
| do while | 11 |
| reduce | 42.03 |
| reduceRight | 44.68 |
| find | 45.68 |
| some | 47.13 |
| filter | 54.38 |
| forEach | 55.5 |
| map | 75 |
| findIndex | 85.48 |
| for in | 2183 |
| every | 0(数据异常) |
数组赋值后, 数组长度为10^7
| 数组长度为10^7 | chrome 109.0.5414.75 (64-bit) (5次 最大最小值平均) |
|---|---|
| for | 36.19 |
| for of | 51.98 |
| do while | 110.5 |
| reduce | 336.8 |
| find | 419.2 |
| filter | 421.4 |
| reduceRight | 434 |
| forEach | 440 |
| some | 520.58 |
| map | 662 |
| findIndex | 848.2 |
| for in | 107307 |
| every | 0(数据异常) |
可见, 对数组进行赋值后, 代码运行基本稳定.(every还不清楚为什么执行时间为0.欢迎大神告知原因.)
分析总结
- array数组默认为空, 依次赋值数组长度为
10^6、10^7、10^8; - 使用 Chrome 浏览器测试, 取测试时间平均值作为比较对象, 时间单位为ms;
- 10^6的数据使用三次100次循环取中位值作为结果
- 10^7数据除了
for遍历,其余遍历方法当前机器均无法带动,会造成浏览器内存崩溃,所以使用三次5次循环取中位值作为结果 - 10^8数据部分,基本5次循环基本全部无法带动,所以手搓5次结果取中位值作为结果
- 通过以上万次运行测试(实际上为了得到比较稳定的数据, 摈弃了许多异常的测试数据), 我们发现在数组长度为10^6, 10^7, 10^8 时, 代码运行基本稳定. 各方法运行需要的时间大致排序如下:
for < do while < forEach < filter < map ~= every < for in
根据统计数据,我们可以分析出
- for 循环当然是最简单的,因为它没有任何额外的函数调用栈和上下文;
- for...of只要具有Iterator接口的数据结构,都可以使用它迭代成员。它直接读取的是键值。
- forEach,因为它其实比我们想象得要复杂一些,它实际上是array.forEach(function(currentValue, index, arr), thisValue)它不是普通的 for 循环的语法糖,还有诸多参数和上下文需要在执行的时候考虑进来,这里可能拖慢性能;
- map() 最慢,因为它的返回值是一个等长的全新的数组,数组创建和赋值产生的性能开销很大。
- for...in需要穷举对象的所有属性,包括自定义的添加的属性也能遍历到。且for...in的key是String类型,有转换过程,开销比较大。
基于测试结果的思考
- 在实际开发中我们要结合语义话、可读性和程序性能,去选择究竟使用哪种方案;
- 如果你需要将数组按照某种规则映射为另一个数组,就应该用 map;
- 如果你需要进行简单的遍历,用 forEach 或者 for of;
- 如果你需要对迭代器进行遍历,用 for of;
- 如果你需要过滤出符合条件的项,用 filterr;
- 如果你需要先按照规则映射为新数组,再根据条件过滤,那就用一个 map 加一个 filter;
- 总之,因地制宜,因时而变。千万不要因为过分追求性能,而忽略了语义和可读性。
声明: 本文所有数据均为单机测试, 难免存在误差, 如果发现本文测试数据不对之处, 欢迎批评斧正.