JS遍历效率思考

477 阅读11分钟

前言

最近在扒拉着Lodash的源码仓库在看各个函数的实现,发现在array|object的方法实现上充斥着大量的whilefor...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^6chrome 109.0.5414.75 (64-bit) (100次均值)
for3.56
do while4.48
for of5.26
for in10.47
forEach15.74
filter15.9
some16.38
every18.8
map40.65
find55.7
findIndex85.63
reduceError(遍历空数组报错)
reduceRightError(遍历空数组报错)

测试代码:

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^7chrome 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(数据异常)
reduceError(遍历空数组报错)
reduceRightError(遍历空数组报错)

数组长度为10^8

数组长度为10^8chrome 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(数据异常)
reduceError(遍历空数组报错)
reduceRightError(遍历空数组报错)

综上, 我们发现for in 循环的性能不稳定, 猜测它可能没有进入循环. 因此将数组各元素进行如下赋值. 重新进行如下两轮测试.

for (let i = 0; i < length; i++) {
  array[i] = '不识石务';
}

数组赋值后, 数组长度为10^6

数组长度为10^6chrome 109.0.5414.75 (64-bit) (100次均值)
for3.47
for of5.13
do while11
reduce42.03
reduceRight44.68
find45.68
some47.13
filter54.38
forEach55.5
map75
findIndex85.48
for in2183
every0(数据异常)

数组赋值后, 数组长度为10^7

数组长度为10^7chrome 109.0.5414.75 (64-bit) (5次 最大最小值平均)
for36.19
for of51.98
do while110.5
reduce336.8
find419.2
filter421.4
reduceRight434
forEach440
some520.58
map662
findIndex848.2
for in107307
every0(数据异常)

可见, 对数组进行赋值后, 代码运行基本稳定.(every还不清楚为什么执行时间为0.欢迎大神告知原因.)

分析总结

  • array数组默认为空, 依次赋值数组长度为10^610^710^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;
  • 总之,因地制宜,因时而变。千万不要因为过分追求性能,而忽略了语义和可读性。

声明: 本文所有数据均为单机测试, 难免存在误差, 如果发现本文测试数据不对之处, 欢迎批评斧正.

干货

image

参考文章

详解JS遍历

JavaScript 数组遍历方法的对比

mdn技术文档

JS几种数组遍历方式以及性能分析对比