JavaScript Array.push 比 Array.concat 快 945 倍🤯🤔
TDLR
等一下...合并 15,000 个数组需要多长时间.concat
...
基准比较
但为什么Array.concat
这么慢?
结论:为什么.push
更快.concat
另一个谜团
为什么我们在 UI-licious 测试期间要进行如此大的数组操作?
TDLR
arr1.push(...arr2)
如果要合并包含数千个元素的数组,可以使用而不是来缩短合并过程arr1 = arr1.concat(arr2)
。如果您真的想要更快,您甚至可能想要编写自己的数组合并实现。
等一下...合并 15,000 个数组需要多长时间.concat
...
最近,一位用户抱怨他们在UI-licious上执行 UI 测试的速度非常慢。每个I.click
I.fill
I.see
通常只需约 1 秒即可完成的命令(例如截屏等后期处理)现在需要超过 40 秒才能完成,因此通常 20 分钟内即可完成的测试套件却需要数小时才能完成,这严重限制了他们的部署流程。
我很快就设置了计时器来缩小导致速度变慢的代码部分的范围,但当我找到罪魁祸首时,我感到非常惊讶:
arr1 = arr1.concat(arr2)
Array 的.concat
方法。
为了允许使用简单的命令(例如I.click("Login")
CSS 或 XPATH 选择器)来编写测试I.click("#login-btn")
,UI-licious 使用动态代码分析来分析 DOM 树,从而根据语义、可访问性属性以及流行但非标准的模式来确定网站的测试内容和方法。这些.concat
操作用于展平 DOM 树以进行分析,但当 DOM 树非常大且非常深时,效果会非常差。例如,我们的用户最近向其应用程序推送了更新,导致页面严重膨胀(这是他们这边的另一个性能问题,但这是另一个话题)。
合并 15,000 个平均大小为 5 个元素的数组需要 6 秒.concat
。
什么?
6秒……
对于平均大小为 5 个元素的 15,000 个数组?
这些数据并不多。
为什么这么慢?有没有更快的数组合并方法?
基准比较
.push 与 .concat 分别用于 10000 个包含 10 个元素的数组
因此我开始研究(我的意思是谷歌搜索).concat
与 Javascript 中合并数组的其他方法相比的基准。
事实证明,合并数组最快的方法是使用.push
接受 n 个参数的:
// Push contents of arr2 to arr1
arr1.push(arr2[0], arr2[1], arr2[3], ..., arr2[n])
// Since my arrays are not fixed in size, I used `apply` instead
Array.prototype.push.apply(arr1, arr2)
相比之下,它的速度要快得多。
多快?
我自己运行了一些性能基准测试来亲眼看看。瞧,Chrome 上的差异如下:
对大小为 10 的数组进行 10,000 次合并,.concat
执行速度为 0.40 次/秒,而.push
执行速度为 378 次/秒。push
比 快 945 倍concat
!这种差异可能不是线性的,但在如此小的规模下,它已经非常显著了。
在 Firefox 上,结果如下:
Firefox 的 SpiderMonkey Javascript 引擎与 Chrome 的 V8 引擎相比通常较慢,但.push
仍然名列前茅,速度快 2260 倍。
我们对代码的这一改变解决了整个速度变慢的问题。
.push 与 .concat 分别用于 2 个包含 50,000 个元素的数组
但是,如果您不是合并 10,000 个大小为 10 的数组,而是合并 2 个各有 50,000 个元素的巨型数组,会怎么样呢?
以下是 Chrome 上的结果以及结果:
.push
仍然比 快.concat
,但是快了 9 倍。
虽然没有慢 945 倍那么明显,但还是很慢。
使用 rest 展开来使语法更美观
如果你觉得Array.prototype.push.apply(arr1, arr2)
太冗长,你可以使用一个简单的变体,即使用 rest spread ES6 语法:
arr1.push(...arr2)
Array.prototype.push.apply(arr1, arr2)
和之间的性能差异arr1.push(...arr2)
可以忽略不计。
但为什么Array.concat
这么慢?
这在很大程度上与 Javascript 引擎有关,但我不知道确切的答案,所以我问了我的好友@picocreator ,他是GPU.js的共同创建者,因为他之前花了相当多的时间研究 V8 源代码。@picocreator还借给我他的游戏 PC,他用它来对 GPU.js 进行基准测试以运行 JsPerf 测试,因为我的 MacBook 甚至没有足够的内存来运行.concat
两个大小为 50000 的数组。
.concat
显然,答案与在修改第一个数组的同时创建新数组有很大关系.push
。将第一个数组中的元素添加到返回数组中所做的额外工作.concat
是导致速度变慢的主要原因。
我:“什么?真的吗?就这些?但差那么多?不可能!”
@picocreator:“说真的,那就试试写一些 .concat 和 .push 的简单实现吧!”
因此,我尝试编写一些简单的.concat
和实现.push
。实际上,我实现了好几个,并与lodash的进行了比较_.concat
:
简单实现 1
我们来谈谈第一组简单的实现:
天真的实现.concat
// Create result array
var arr3 = []
// Add Array 1
for(var i = 0; i < arr1Length; i++){
arr3[i] = arr1[i]
}
// Add Array 2
for(var i = 0; i < arr2Length; i++){
arr3[arr1Length + i] = arr2[i]
}
天真的实现.push
for(var i = 0; i < arr2Length; i++){
arr1[arr1Length + i] = arr2[i]
}
如您所见,两者之间的唯一区别是.push
实现直接修改了第一个数组。
香草方法的结果:
.concat
:75 次操作/秒.push
:793 次操作/秒(速度提高 10 倍)
简单实现 1 的结果
.concat
:536 次操作/秒.push
:11,104 次操作/秒(速度提高 20 倍)
事实证明,我的 DIYconcat
比push
原始实现更快......但在这里我们可以看到,简单地创建一个新的结果数组并复制第一个数组的内容会显著减慢该过程的速度。
简单实现 2(预先分配最终数组的大小)
我们可以在添加元素之前预先分配数组的大小,从而进一步改进简单的实现,这会带来巨大的变化。
.concat
预分配的简单实现
// Create result array with preallocated size
var arr3 = Array(arr1Length + arr2Length)
// Add Array 1
for(var i = 0; i < arr1Length; i++){
arr3[i] = arr1[i]
}
// Add Array 2
for(var i = 0; i < arr2Length; i++){
arr3[arr1Length + i] = arr2[i]
}
.push
预分配的简单实现
// Pre allocate size
arr1.length = arr1Length + arr2Length
// Add arr2 items to arr1
for(var i = 0; i < arr2Length; i++){
arr1[arr1Length + i] = arr2[i]
}
简单实现 1 的结果
.concat
:536 次操作/秒.push
:11,104 次操作/秒(速度提高 20 倍)
简单实现 2 的结果
.concat
:1,578 次操作/秒.push
:18,996 次操作/秒(速度提高 12 倍)
预先分配最终数组的大小可将每种方法的性能提高 2-3 倍。
.push
数组与.push
单独元素
好吧,如果我们直接 .push 单个元素呢?这样会比Array.prototype.push.apply(arr1, arr2)
for(var i = 0; i < arr2Length; i++){
arr1.push(arr2[i])
}
结果
.push
整个阵列:793 次操作/秒.push
单个元素:735 操作/秒(较慢)
因此,对单个元素执行操作比对整个数组.push
执行操作要慢。这很有道理。.push
结论:为什么.push
更快.concat
总而言之,之所以速度concat
如此之慢,主要原因.push
确实在于它创建了一个新数组,并做了额外的工作来复制第一个数组。
话虽如此,现在对我来说又有一个谜团……
另一个谜团
为什么原始实现比简单实现慢得多?🤔我再次请求@picocreator的帮助。
我们研究了 lodash 的_.concat
实现,以了解 vanilla.concat
在底层还做了什么,因为它们的性能相当(lodash 的速度稍快一些)。
事实证明,根据 vanilla 的.concat
规范,该方法是重载的,并且支持两种签名:
- 要附加的值作为 n 个参数,例如
[1,2].concat(3,4,5)
- 要附加自身的数组,例如
[1,2].concat([3,4,5])
你甚至可以像这样同时做这两件事:[1,2].concat(3,4,[5,6])
Lodash 也支持这两种重载签名,为此,lodash 会将所有参数放入一个数组中,然后将其展开。如果您要传入多个数组作为参数,这样做是合理的。但是,当传入一个数组进行 append 操作时,它不会直接使用该数组,而是将其复制到另一个数组中,然后展开。
... 好的...
绝对可以进一步优化。这就是为什么你可能想自己动手实现合并数组。
此外,这只是我和@picocreator.concat
根据 Lodash 的源代码和他对 V8 源代码略微过时的了解对 vanilla 内部工作原理的理论。
您可以在此处随意阅读 lodash 的源代码。
附加说明
为什么我们在 UI-licious 测试期间要进行如此大的数组操作?
在底层,UI-licious 测试引擎会扫描目标应用程序的 DOM 树,评估语义、可访问属性和其他常见模式,以确定目标元素是什么以及如何测试它。
这样我们就可以确保测试可以写得像这样简单:
// Lets go to dev.to
I.goTo("https://dev.to")
// Fill up search
I.fill("Search", "uilicious")
I.pressEnter()
// I should see myself or my co-founder
I.see("Shi Ling")
I.see("Eugene Cheah")
无需使用 CSS 或 XPATH 选择器,从而使测试更具可读性、对 UI 变化的敏感度更低,并且更易于维护。
注意:公共服务公告 - 请保持您的 DOM 数量较少!
不幸的是,如今人们使用现代前端框架构建越来越复杂、动态的应用程序,DOM 树变得越来越庞大。这是一把双刃剑,框架让我们开发速度更快,但人们常常忘记框架带来了多少臃肿。我有时会在检查各种网站的源代码时,看到那些只是为了包裹其他元素的元素的数量而感到畏惧。
如果您想知道您的网站是否有太多 DOM 节点,您可以运行Lighthouse审核。
根据 Google 的说法,最佳的 DOM 树是:
- 少于 1500 个节点
- 深度大小小于 32 级
- 父节点的子节点少于 60 个
对 Dev.to feed 的快速审核表明 DOM 树的大小非常好:
- 节点总数 941 个
- 最大深度 14
- 子元素的最大数量为 49
不错!
文章来源:https://dev.to/uilicious/javascript-array-push-is-945x-faster-than-array-concat-1oki