你知道在Javascript中合併數組最快的方式是什麼嗎?

JavaScript Chrome Firefox CSS Google 軟謀前端 2019-05-16


你知道在Javascript中合併數組最快的方式是什麼嗎?


如果要合併擁有上千個元素的數組,使用 arr1.push(...arr2) 可比 arr1 = arr1.concat(arr2) 節省時間。如果你想要再快一點,你甚至可以編寫自己的函數來實現合併數組的功能。

等一下……用 .concat 合併 15000 個數組要花多長時間呢?

最近,我們有一個用戶抱怨他在使用 UI-licious 對他們的 UI 進行測試時,速度明顯慢了很多。通常,每一個 I.click I.fill

I.see 命令需要大約 1 秒的時間完成(後期處理,例如截屏),現在需要超過 40 秒才能完成,因此通常在 20 分鐘內可以完成的測試現在需要花費數小時才能完成,這嚴重地拖慢了他們的部署進程。

我很快就設置好了定時器,鎖定了導致速度緩慢的那部分代碼,但當我找到罪魁禍首時,我著實吃了一驚:

arr1 = arr1.concat(arr2)

數組的 .concat 方法。

為了允許在編寫測試的時候可以使用簡單的指令,如 I.click("Login"),而不是使用 CSS 或是 XPATH 選擇器,如 I.click("#login-btn"),UI-licious 基於網站的語義、可訪問性屬性以及各種流行但不標準的模式,使用動態代碼分析(模式)來分析 DOM 樹,從而確定網站的測試內容和測試方法。這些 .concat 操作被用來壓扁 DOM 樹進行分析,但是當 DOM 樹非常大而且非常深時,性能非常糟糕,這就是我們的用戶最近更新他們的應用程序時發生的事情,這波更新也導致了他們的頁面明顯臃腫起來(這是他們那邊的性能問題,是另外的話題了)。

使用 .concat 合併 15000 個平均擁有 5 個元素的數組需要花費 6 秒的時間。

納尼?

6 秒……

僅僅是 15000 個數組,而且平均只擁有 5 個元素?

數據量並不是很大。

為什麼這麼慢?合併數組有沒有更快的方法呢?

基準比較

.push vs. .concat,合併 10000 個擁有 10 個元素的數組

所以我開始研究(我指的是谷歌搜索).concat 和 Javascript 中合併數組的其它方式的基準對比。

事實證明,合併數組最快的方式是使用 .push 方法,該方法可以接收 n 個參數:

// 將 arr2 的內容壓(push)入 arr1 中
arr1.push(arr2[0], arr2[1], arr2[3], ..., arr2[n])
// 由於我的數組大小不固定,我使用了 `apply` 方法
Array.prototype.push.apply(arr1, arr2)

相比之下,它的速度更快,簡直是個飛躍。

有多快?

我自己運行了一些性能基準測試來親眼看看。瞧,這是在 Chrome 上執行的差別:

你知道在Javascript中合併數組最快的方式是什麼嗎?

合併擁有大小為 10 的數組 10000 次,.concat 的速度為 0.40 ops/sec(操作每秒),而 .push的速度是 378 ops/sec。也就是說 push 比 concat 快了整整 945 倍!這種差異可能不是線性的,但在這種小規模數據量上已經很明顯了。

在 Firefox 上,執行結果如下:

你知道在Javascript中合併數組最快的方式是什麼嗎?

通常,與 Chrome 的 V8 引擎相比,Firefox 的 SpiderMonkey Javascript 引擎速度較慢,但 .push 仍然排名第一,比 concat 快了 2260 倍。

我們對代碼做了上面的改動,它修復了整個速度變慢的問題。

.push vs. .concat,合併 2 個擁有 50000 個元素的數組

但好吧,如果你合併的不是 10000 個擁有 10 個元素的數組,而是兩個擁有 50000 個元素的龐大數組呢?

下面是在 Chrome 上測試的結果:

你知道在Javascript中合併數組最快的方式是什麼嗎?


.push 仍然比 .concat 快, 但這次是 9 倍.

雖然沒有戲劇性的慢上 945 倍,但已經很慢了。


更優美的擴展運算

如果你覺得 Array.prototype.push.

apply(arr1, arr2) 很囉嗦,你可以使用 ES6 的擴展運算符做一個簡單的改造:

arr1.push(...arr2)

Array.prototype.push.apply(arr1, arr2) 和 arr1.push(...arr2) 之間的性能差異基本可以忽略。

但是為什麼 Array.concat 這麼慢?

它和 Javascript 引擎有很大的關係,我也不知道確切的答案,所以我問了我的朋友 @picocreator—— GPU.js 的聯合創始人,他之前花了很多時間研究 V8 的源碼。因為我的 MacBook 內存不足以運行 .concat 合併兩個長度為 50000 的數組,@picocreator 還把他用來對 GPU.js 做基準測試的寶貝遊戲 PC 借給我跑 JsPerf 的測試。

顯然答案與它們的運行機制有很大的關係:在合併數組的時候,.concat 創建了一個新的數組,而 .push 只是修改了第一個數組。這些額外的操作(將第一個數組的元素添加到返回的數組裡)就是拖慢了 .concat 速度的關鍵。

我:“納尼?不可能吧?就是這樣而已?但為什麼差距這麼大?不可能啊!” @picocreator:“我可沒開玩笑,試著寫下 .concat 和 .push 的原生實現你就知道了!”

所以我按照他說的試了試,寫了幾種實現方式,又加上了和 lodash 的 _.concat 的對比:

你知道在Javascript中合併數組最快的方式是什麼嗎?

原生實現方式 1

讓我們來討論下第一套原生實現方式:

.concat 的原生實現

// 創建結果數組
var arr3 = []
// 添加 arr1
for(var i = 0; i < arr1Length; i++){
arr3[i] = arr1[i]
}
// 添加 arr2
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 ops/sec
  • .push: 793 ops/sec (快 10 倍)


原生實現方法 1 的結果:

  • .concat : 536 ops/sec
  • .push : 11,104 ops/sec (快 20 倍)

結果證明我自己寫的 concat 和 push 比它們的常規實現方法還快……但我們可以看到,僅僅是簡單地創建一個新數組並將第一個數組的內容複製給它就可以使整個過程明顯變慢。

原生實現方式 2(預分配最終數組的大小)

通過在添加元素之前預先分配數組的大小,我們可以進一步改進原生實現方法,這會產生巨大的差異。

帶預分配的 .concat 的原生實現

// 創建結果數組並給它預先分配大小
var arr3 = Array(arr1Length + arr2Length)
// 添加 arr1
for(var i = 0; i < arr1Length; i++){
arr3[i] = arr1[i]
}
// 添加 arr2
for(var i = 0; i < arr2Length; i++){
arr3[arr1Length + i] = arr2[i]
}

帶預分配的 .push 的原生實現

// 預分配大小
arr1.length = arr1Length + arr2Length
// 將 arr2 的元素添加給 arr1
for(var i = 0; i < arr2Length; i++){
arr1[arr1Length + i] = arr2[i]
}

原生實現方法 1 的結果:

  • .concat : 536 ops/sec
  • .push : 11,104 ops/sec (快 20 倍)

原生實現方法 2 的結果:

  • .concat : 1,578 ops/sec
  • .push : 18,996 ops/sec (快 12 倍)

預分配最終數組的大小可以使每種方法的性能提高 2-3 倍。

.push 數組 vs. .push 單個元素

那假如我們每次只 .push 一個元素呢?它會比 Array.prototype.push.apply(arr1, arr2) 快嗎?

for(var i = 0; i < arr2Length; i++){
arr1.push(arr2[i])
}

結果

  • .push 整個數組:793 ops/sec
  • .push 單個元素: 735 ops/sec (慢)

所以 .push 單個元素要比 .push 整個數組慢,這也說得通。

結論

為什麼 .push 比 .concat 更快總而言之,concat 比 .push 慢這麼多的主要原因就是它創建了一個新數組,還需要額外將第一個數組的元素複製給這個新數組。現在對我來說還有另外一個迷……

現在對我來說還有另外一個迷……

另一個迷

為什麼常規實現要比原生實現方式慢呢?我再次向 @picocreator 尋求幫助。

我們看了一下 lodash 的 _.concat 實現,想要獲得一些關於 .concat 常規實現方法的提示,因為它們在性能上相當(lodash 要快一點點)。

事實證明,根據 .concat 常規實現方式的規範,這個方法被重載,並且支持兩種傳參方式:

  1. 傳遞要添加的 n 個值作為參數,例如:[1,2].concat(3,4,5)
  2. 傳遞要合併的數組作為參數,例如:[1,2].concat([3,4,5])

你甚至可以這樣寫:[1,2].concat(3,4,[5,6])

Lodash 一樣做了重載,支持兩種傳參方式,lodash 將所有的參數放入一個數組,然後將它拍平。所以如果你給它傳遞多個數組的也可以說得通。但是當你傳遞一個需要合併的數組時,它將不僅僅使用數組本身,而是將它複製到一個新的數組中,然後再把它拍平。

……好吧……

所以絕對可以對性能做優化。這也是你為什麼想要自己實現合併數組的原因。

此外,這只是我和 @picocreator 基於 Lodash 的源碼以及他對 V8 源碼略微過時的瞭解,對 .concat 的常規實現如何在引擎中工作的理解。

你可以在空閒的時間點擊這裡閱讀 lodash 的源碼。


補充說明

  1. 我們的測試僅僅使用了包含整數的數組。我們都知道 Javascript 引擎使用規定類型的數組可以更快地執行。如果數組中有對象,結果預計會更慢。
  2. 以下是用於運行基準測試的 PC 的規格:
你知道在Javascript中合併數組最快的方式是什麼嗎?

為什麼我們在 UI-licious 測試期間會進行如此大的數組操作呢?

你知道在Javascript中合併數組最快的方式是什麼嗎?

從工作原理上來說,UI-licious 測試引擎掃描目標應用程序的 DOM 樹,評估語義、可訪問屬性和其他常見模式,來確定目標元素以及測試方法。

這樣我們就可以確保像下面這樣簡單地編寫測試:

// 跳轉到 dev.to
I.goTo("https://dev.to")
// 在搜索框進行輸入和搜索
I.fill("Search", "uilicious")
I.pressEnter()
// 我應該可以看見我自己和我的聯合創始人
I.see("Shi Ling")
I.see("Eugene Cheah")

沒有使用 CSS 或 XPATH 選擇器,這樣可以使測試更易讀,對 UI 中的更改也不太敏感,並且更易於維護。

注意:公共服務公告 —— 請保持小數量的 DOM!

不幸的是,由於人們正在使用現代前端框架來構建越來越複雜和動態的應用程序,DOM 樹有越來越大的趨勢。框架是一把雙刃劍,它允許我們更快地開發,但是人們常常忘記框架平添了多少累贅。在檢查各種網站的源代碼時,那些單純為了包裹其他元素而存在的元素的數量經常會嚇到我。

如果你想知道你的網站是否有太多 DOM 節點,你可以運行 Lighthouse 查看。

你知道在Javascript中合併數組最快的方式是什麼嗎?

根據 Google 的說法,最佳 DOM 樹是:

  • 少於 1500 個節點
  • 深度少於 32 級
  • 父節點擁有少於 60 個子節點

對 Dev.to feed 的快速檢查表明它的 DOM 樹的大小非常好:

  • 總計 941 個節點
  • 最大深度為 14
  • 子元素的最大數量為 49 個

還不錯!

評論轉發有驚喜~

相關推薦

推薦中...