前言
JS作为高级语言,我们在使用它的一些api或语法的时候,由于不了解其背后的实现,有可能会写出运行效率低下的代码,如果有遇到代码性能问题时,不妨可以思考使用的api或语法是否合理。
场景说明
为了提升对数组中某一项的查找效率,有时需要根据数组来创建一份map,比如以下代码:
const data = [
{ id: 1, value: 'value1' },
{ id: 2, value: 'value2' },
{ id: 3, value: 'value3' },
];
const map = data.reduce((memo, item) => ({
...memo,
[item.id]: item,
}), {});
这段平平无奇的代码里,使用了函数式apireduce和...扩展运算符,很简洁地表达创建map的过程,但这段看似简单的代码背后,实则暗藏着一个性能问题。
观察一下代码运行情况,随着data遍历的次数增长,memo对象会变得越来越大,在data长度较小时,代码的性能影响可以忽略不计,但当data的长度较大,达到成千上万条时,memo也会相应的变成大对象,此时,代码的性能影响开始显现,执行效率会变得很低。
const data = new Array(5000).fill(null).map((_, i) => ({
id: `id-${i}`,
value: Math.random(),
}));
console.time('t');
const map = data.reduce((memo, item) => ({
...memo,
[item.id]: item,
}), {});
console.timeEnd('t');
// t: 5613.48291015625 ms
如上例子,data的长度是5000时,生成map的代码块执行的时长大概是3~5秒,很显然如果只是进行5000次的循环,一般来说是不应该耗费这么长时间的,尤其这里看起来只是简单的创建对象。
原因分析
简单分析一下,这段代码没多少内容,唯一可疑的就是扩展运算符...了。
看到它,我很快联想到,在数组中使用扩展语法时,会调用数组的迭代器去「迭代」每一项(数据是可迭代对象,可以创建它的迭代器),放到新数组上。
所以我理想当然的认为,对象也是如此,但转念一想,for...of是不能对object去遍历的,也就是对象不是可迭代对象,和数组不是一样的过程。即便这样,我理解差不多应该也是一个遍历拷贝的过程。
猜得差不多了,接下来看看MDN对于此语法的描述:
MDN链接:构造字面量对象时使用展开语法
再看看经过babel转换的代码:
和猜想的一样,在对象使用扩展运算符的行为背后,就是把已有对象遍历拷贝到新对象的过程。
理解了语法背后的含义之后,再回头看看上面代码的例子。在6000次循环中,每次都会对memo对象进行一次遍历属性拷贝该对象,随着循环次数的增长,memo越来越大,每次拷贝对象的行为需要的时间也越来越长,因此也就造成了相对较大的性能影响。
既然有了性能影响,那就得考虑代码的合理性和该如何优化它。回归到需求的本质,实际上我的需求只是根据数组创建一份map,...memo背后代表的对memo进行循环属性拷贝对象的过程是冗余的,显然造成问题的直接原因以及它本身实际上是多余的步骤,那么代码就可以改成这个样子:
const data = new Array(5000).fill(null).map((_, i) => ({
id: `id-${i}`,
value: Math.random(),
}));
console.time('t');
const map = data.reduce((memo, item) => {
memo[item.id] = item;
return memo;
}, {});
console.timeEnd('t');
// t: 0.967041015625 ms
修改代码之后,不影响代码功能,并且执行效率有了明显提升。
到此,问题就解决了。
总结
这个问题的本质原因是,在循环的过程中,不合理地使用了扩展语法,导致代码执行的次数变多,导致影响了代码执行的效率。这本来其实是一个简单的问题,但经常有看到一些人也会这样写代码,所以我觉得还是有必要把它记录下来。
对于一些新语法和api,我个人觉得还是有必要仔细地阅读一下对应的文档,如果先阅读过文档的话,上面的问题我想也就能避免了。