JavaScript中数组遍历的那些事
发布于 6 年前 作者 blackmatch 3332 次浏览 来自 分享

前言

今天测试MM给我提了个issue:某个页面点击查询后就卡死了。由于查询的数据量比较多,我第一反应就是相关接口的SQL语句需要优化,但是我调试的时候发现几条查询语句都在500毫秒内得到结果返回了,而且这几条查询是并发执行的,所以SQL语句没问题,经过调试发现,主要耗时在程序的循环遍历,最外层的循环有250多次,每次遍历需要消耗500毫秒左右,这样的话接口返回就需要2分钟左右,而前端请求接口的超时默认是80秒,超时后走catch回调,估计前端也没有处理 catch ,所以导致界面一直转圈圈卡死了。言归正传,接口的主要耗时是在程序的循环操作,因为循环中有嵌套,总共3层(代码写得好烂,但是基于现在的业务逻辑,也只能这样了),通过 debug ,发现最耗时的操作在最里层的循环中。

分析问题

在第2层的循环,有一个操作是:从一个数组中找出符合条件的所有元素,取这些元素的值进行一些计算,使用的方式也是循环遍历。我们把这个元素称为arrayData,这个数组的长度为15000左右,遍历这个数组的操作便是第3层循环。第1层循环次数是250次左右,第二层循环次数是4次,第3层循环次数是15000次左右。第3层循环在第2层循环中会执行多次,代码结构是这样的:

 for () {
    // 第1层循环
    for (){
      // 第2层循环

      arrayData.forEach((item) => {
        // 第3层循环
      });

      arrayData.forEach((item) => {
        // 第3层循环
      });
      
      ...
    }
  }

感觉有点不忍直视。。。自己写的代码,哭着也要调试完。第3层循环,完整的走完一遍,大概需要80毫秒左右,然后在第2层循环中有4次这样的循环,所以这部分操作就消耗了320毫秒左右。所以,我把注意力集中在如何优化这320毫秒上。

优化方案

从业务逻辑优化

我首先考虑的是从业务逻辑上进行优化,经过排查代码,发现一个可行的方法是:减少arrayData的长度。arrayData可以从业务逻辑上进行分组,得到几个长度大约 2000 左右的数据,这样第三层循环的时候就大大减少了循环次数,原来是 15000*4=60000 次,现在是 2000*4=8000 次,减少了 52000 次。经过这样优化,第3层循环每次大概需要40毫秒左右,原来的耗时是80毫秒,这样对最外层的循环来说,每次减少了 40*4=160 毫秒,总时间减少了 250*160 = 40000  毫秒,也就是40秒!我滴乖乖!虽然总时间减少了40秒,但还是很慢。

从数组遍历方式上优化

从上面的代码结构中可以看到,第3层循环使用的是forEach来进行遍历的,第1层和第2层其实使用的是ES6的for-of进行遍历的。这个差别让我产生了疑虑:这两种遍历方式会不会存在性能上的差异?于是,我把第3层的遍历方式从forEach换成了最古老的for循环遍历,代码是这样的:

for (let i = 0, len = arrayData.length; i < len; i++) {
    const item = arrayData[i];
}

需要注意一个细节,在数组长度不会动态变化的情况下,要用一个变量来接数组的长度,不要直接写i<arrayData.length,据说也会对性能有细微的影响。

这样一改,奇迹出现了。总时间又减少了20秒左右。然后再东补补西补补,接口总耗时30秒左右,对我们的系统来说已经是可以接受的程度了,然后代码提交发布,打完收工,测试MM对我刮目相看。哈哈哈。。。等等,还没完呢!请继续往下看。

JavaScript中的数组

JavaScript的数组与其他编程语言的数组有较大的差别,首先,JavaScript的数组元素可以是任意类型,一个数组,第1个元素可以是数字,第2个元素可以是字符串,第3个元素可以是对象;其次,JavaScript的数组其实也是对象,有对象就有键和值,默认情况下,JavaScript会给数组每一个元素创建一个从0开始的键,可以通过[key]访问数组中的元素,如果key传的是整型,会自动转换成字符串类型。举例:

const arr1 = [1, 'str', { name: 'blackmatch' }];
console.log(arr1[1]);             // str
console.log(arr1['1']);           // str
console.log(arr1.length);         // 3
console.log(Object.keys(arr1));   // [ '0', '1', '2' ]

const arr2 = ['str1', 'str2', 'str3'];
console.log(arr2[1]);           // str2
console.log(arr2['1']);         // str2
console.log(arr1.length);       // 3
console.log(Object.keys(arr2)); // [ '0', '1', '2' ]

const arr3 = [];
arr3['name'] = 'blackmatch';
arr3['age'] = 18;
console.log(arr3);              // [ name: 'blackmatch', age: 18 ]
console.log(arr3['age']);       // 18
console.log(arr3.age);          // 18
console.log(arr3.length);       // 0
console.log(Object.keys(arr3)); // [ 'name', 'age' ]

const arr4 = [];
console.log(arr4.length);   // 0
arr4['0'] = 1;
console.log(arr4.length);   // 1
console.log(arr4);          // [ 1 ]

const obj = {
  name: 'blackmatch',
  age: 18
};
console.log(obj.length);        // undefined
console.log(Object.keys(obj));  // [ 'name', 'age' ]

这里比较有意思的是,arr3.length 输出的结果是0。其实原因很简单:因为 arr3 中没有元素。通过 arr3['age']=18 的方式并不能给 arr3 添加元素,其原因是:

在ECMAScript中,Object是所有对象的基础,因此所有对象都具有Object的基本属性和方法。

也就是说, arr3[‘age’]=18 只是给 arr3 添加了一个名为 age 的键,并将其值设置为 18 ,并没有往 arr3 中添加元素,而 arr3.length 返回的是 arr3 元素的个数。给数组添加元素可以通过对象字面量、push、unshift等方法实现,也可以通过 arr[key]=value 的形式来添加或者修改元素,需要注意的是这里的 key 必须是字符串的数字,比如: arr[‘1’]=‘element’ ,由于数组的最大长度是 2^32 - 1,所以数组元素的 key 的范围是0 ~ 2^32 - 2 ,即0~4294967294。 JavaScript的 Array 对象还有很多细节值得关注,这里就不一一展开了。

JavaScript中数组的遍历方式

JavaScript数据遍历主要有以下3种方式:

  • 最古老的for循环遍历
for (let i = 0, len = rows.length; i < len; i++) {
  const row = rows[i];
}
  • forEach遍历
row.forEach((row, idx, arr) => {

});
  • ES6的for-of遍历
for (const row of rows) {

}

这3种遍历方式各有千秋,具体该使用哪种方式需要结合具体的业务场景,但是这3种遍历方式在性能上是存在差异的,我在jsPerf平台对这3种方式进行了性能比较。测试方式很简单:先准备一个长度为20000的数组,数组的每个元素是一个对象,然后分别使用这3种方式对数组进行遍历,对每个元素做一个简单的赋值操作,最后比较每种方式的耗时。准备代码如下:

<script>
var rows = [];

for (let i = 0; i < 20000; i++) {
  const row = {
    name: 'blackmatch',
    age: 18
  };

  rows.push(row);
}

function handleRow(row) {
  if (row && row.age > 0) {
    row.age = row.age * 2;
  }
}

</script>

测试结果如下:

image.png | left | 747x392

可以看出,在性能方面:for>for-of>forEach。测试地址为:https://jsperf.com/for-vs-foreach-vs-for-of-by-blackmatch/1 。所以,如果需要遍历的数组长度比较大的时候,需要考虑一下性能问题。另外,不推荐使用for-in对数组进行遍历,具体原因就不赘述了。

后话

大家可能觉得一个接口要耗时几十秒,这能忍?说实话,我也不能忍,我的优化方式可能也不对;但是从结果来看,从原来的一直卡死,到现在的几十秒能出结果,至少这个功能现在能用了。还有最关键的一点就是,我们的客户甚至我的领导,对性能真的一点都不在乎,能用就行了,他们更在乎的是增加需求,修改需求,领导也不希望我在性能调优上花太多时间,其实我做的这次优化更多的是从业务逻辑上优化了,因为我一开始就觉得代码逻辑以及计算等操作不会消耗太多时间,应该优化的是SQL语句和数据库结构,但事实上,性能优化涉及到每一个细节,只是在不同的环境下某些细节会更加突出,某些细节可以忽略不计。不说了,我要去改需求了。。。

参考资料

《JavaScript高级程序设计(第3版)》 https://zhuanlan.zhihu.com/p/23812134 https://juejin.im/post/5a3a59e7518825698e72376b

4 回复

500ms 的db查询,还是优化一下吧…

压力测试部分比较有意思,以前不知道这几种便利方法性能差这么多,建议把map、filter、reduce也都加上。

遍历8000次也有些恐怖,一些遍历和筛选操作可否交给数据库来做?应用索引的话会快很多吧。

另外如果免不了大量数据的遍历考虑用一下Cluster做多进程?或者使用N-API拿C++做? 比较期待Worker Threads,现在还是试验阶段,这个可以用来做多线程,SharedArrayBuffer非常适用于这种情况。

@Fov6363 对我们的系统来说,已经是很快的查询了,数据量也不少。

@libook 涨姿势了,感谢指导。

回到顶部