[譯] Javascript 中 Array.push 要比 Array.concat 快 945 倍!??

徐二斤發表於2019-05-11

如果要合併擁有上千個元素的陣列,使用 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 上執行的差別:

JsPerf - .push vs. .concat 10000 size-10 arrays (Chrome)

? 連結到 JsPerf 上的測試

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

在 Firefox 上,執行結果如下:

JsPerf - .push vs. .concat 10000 size-10 arrays (Firefox)

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

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

.push vs. .concat,合併 2 個擁有 50000 個元素的陣列

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

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

JsPerf - .push vs. .concat 2 size-50000 arrays (chrome)

? 連結到 JsPerf 上的測試

.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 的對比:

JsPerf - Various ways to merge arrays (Chrome)

? 連結到 JsPerf 上的測試

原生實現方式 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 倍)

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

原生實現方式 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 的規格:

PC specs for the performance tests


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

Uilicious Snippet dev.to test

從工作原理上來說,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 檢視。

Google Lighthouse

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

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

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

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

還不錯!

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章