JS陣列迴圈的效能和效率分析(for、while、forEach、map、for of)

鄭昊川發表於2018-08-03

本文永久連結:github.com/HaoChuan942…

前言

前端開發中經常涉及到陣列的相關操作:去重、過濾、求和、資料二次處理等等。都需要我們對陣列進行迴圈。為了滿足各種需求,JS除了提供最簡單的for迴圈,在ES6和後續版本中也新增的諸如:map、filter、some、reduce等實用的方法。因為各個方法作用不同,簡單的對所有涉及到迴圈的方法進行執行速度比較,是不公平的,也是毫無意義的。那麼我們就針對最單純的以取值為目的的迴圈進行一次效能和效率測試,用肉眼可見的方式,對JS中常見的這些陣列迴圈方式進行一次探討。

從最簡單的for迴圈說起

for迴圈常見的四種寫法,不囉嗦,直接上程式碼

const persons = ['鄭昊川', '鍾忠', '高曉波', '韋貴鐵', '楊俊', '宋燦']
// 方法一
for (let i = 0; i < persons.length; i++) {
  console.log(persons[i])
}
// 方法二
for (let i = 0, len = persons.length; i < len; i++) {
  console.log(persons[i])
}
// 方法三
for (let i = 0, person; person = persons[i]; i++) {
  console.log(person)
}
// 方法四
for (let i = persons.length; i--;) {
  console.log(persons[i])
}
複製程式碼
  1. 第一種方法是最常見的方式,不解釋。

  2. 第二種方法是將persons.length快取到變數len中,這樣每次迴圈時就不會再讀取陣列的長度。

  3. 第三種方式是將取值與判斷合併,通過不停的列舉每一項來迴圈,直到列舉到空值則迴圈結束。執行順序是:

    • 第一步:先宣告索引i = 0和變數person
    • 第二步:取出陣列的第ipersons[i]賦值給變數person並判斷是否為Truthy
    • 第三步:執行迴圈體,列印person
    • 第四步:i++

      當第二步中person的值不再是Truthy時,迴圈結束。方法三甚至可以這樣寫

      for (let i = 0, person; person = persons[i++];) {
        console.log(person)
      }
      複製程式碼
  4. 第四種方法是倒序迴圈。執行的順序是:

    • 第一步:獲取陣列長度,賦值給變數i
    • 第二步:判斷i是否大於0並執行i--
    • 第三步:執行迴圈體,列印persons[i],此時的i已經-1

      從後向前,直到i === 0為止。這種方式不僅去除了每次迴圈中讀取陣列長度的操作,而且只建立了一個變數i

四種for迴圈方式在陣列淺拷貝中的效能和速度測試

先造一個足夠長的陣列作為要拷貝的目標(如果i值過大,到億級左右,可能會丟擲JS堆疊跟蹤的報錯)

const ARR_SIZE = 6666666
const hugeArr = new Array(ARR_SIZE).fill(1)
複製程式碼

然後分別用四種迴圈方式,把陣列中的每一項取出,並新增到一個空陣列中,也就是一次陣列的淺拷貝。並通過console.timeconsole.timeEnd記錄每種迴圈方式的整體執行時間。通過process.memoryUsage()比對執行前後記憶體中已用到的堆的差值。

/* node環境下記錄方法執行前後記憶體中已用到的堆的差值 */
function heapRecord(fun) {
  if (process) {
    const startHeap = process.memoryUsage().heapUsed
    fun()
    const endHeap = process.memoryUsage().heapUsed
    const heapDiff = endHeap - startHeap
    console.log('已用到的堆的差值: ', heapDiff)
  } else {
    fun()
  }
}
複製程式碼
// 方法一,普通for迴圈
function method1() {
  var arrCopy = []
  console.time('method1')
  for (let i = 0; i < hugeArr.length; i++) {
    arrCopy.push(hugeArr[i])
  }
  console.timeEnd('method1')
}
// 方法二,快取長度
function method2() {
  var arrCopy = []
  console.time('method2')
  for (let i = 0, len = hugeArr.length; i < len; i++) {
    arrCopy.push(hugeArr[i])
  }
  console.timeEnd('method2')
}
// 方法三,取值和判斷合併
function method3() {
  var arrCopy = []
  console.time('method3')
  for (let i = 0, item; item = hugeArr[i]; i++) {
    arrCopy.push(item)
  }
  console.timeEnd('method3')
}
// 方法四,i--與判斷合併,倒序迭代
function method4() {
  var arrCopy = []
  console.time('method4')
  for (let i = hugeArr.length; i--;) {
    arrCopy.push(hugeArr[i])
  }
  console.timeEnd('method4')
}
複製程式碼

分別呼叫上述方法,每個方法重複執行12次,去除一個最大值和一個最小值,求平均值(四捨五入),最終每個方法執行時間的結果如下表(測試機器:MacBook Pro (15-inch, 2017) 處理器:2.8 GHz Intel Core i7 記憶體:16 GB 2133 MHz LPDDR3 執行環境:node v10.8.0):

- 方法一 方法二 方法三 方法四
第一次 152.201ms 156.990ms 152.668ms 152.684ms
第二次 150.047ms 159.166ms 159.333ms 152.455ms
第三次 155.390ms 151.823ms 159.365ms 149.809ms
第四次 153.195ms 155.994ms 155.325ms 150.562ms
第五次 151.823ms 154.689ms 156.483ms 148.067ms
第六次 152.715ms 154.677ms 153.135ms 150.787ms
第七次 152.084ms 152.587ms 157.458ms 152.572ms
第八次 152.509ms 153.781ms 153.277ms 152.263ms
第九次 154.363ms 156.497ms 151.002ms 154.310ms
第十次 153.784ms 155.612ms 161.767ms 153.487ms
平均耗時 152.811ms 155.182ms 155.981ms 151.700ms
用棧差值 238511136Byte 238511352Byte 238512048Byte 238511312Byte

意不意外?驚不驚喜?想象之中至少方法二肯定比方法一更快的!但事實並非如此,不相信眼前事實的我又測試了很多次,包括改變被拷貝的陣列的長度,長度從百級到千萬級。最後發現:在node下執行完成同一個陣列的淺拷貝任務,耗時方面四種方法的差距微乎其微,有時候排序甚至略有波動。 記憶體佔用方面:方法一 < 方法四 < 方法二 < 方法三,但差距也很小。

v8引擎新版本針對物件取值等操作進行了最大限度的效能優化,所以方法二中快取陣列的長度到變數len中,並不會有太明顯的提升。即使是百萬級的資料,四種for迴圈的耗時差距也只是毫秒級,記憶體佔用上四種for迴圈方式也都非常接近。在此感謝YaHuiLiang七秒先生戈尋謀doxP超級大柱子的幫助和指正,如果大佬們有更好的見解也歡迎評論留言。

同樣是v8引擎谷歌瀏覽器,測試發現四種方法也都非常接近。

谷歌

但是在火狐瀏覽器中的測試結果:方法二 ≈ 方法三 ≈ 方法四 < 方法一,表明二三四這三種寫法都可以在一定程度上優化for迴圈

火狐

而在safari瀏覽器下方法四 < 方法一 ≈ 方法二 ≈ 方法三,只有方法四體現出了小幅度的優化效果。

safari

小結

考慮到在不同環境或瀏覽器下的效能和效率:

推薦第四種i--倒序迴圈的方式。在奇舞團的這篇文章——嗨,送你一張Web效能優化地圖2.3 流程控制小節裡也略有提及這種方式。

不推薦第三種方式。主要是因為當陣列裡存在非Truthy的值時,比如0'',會導致迴圈直接結束。

while迴圈以及ES6+的新語法forEachmapfor of,會更快嗎?

不囉嗦,實踐是檢驗真理的唯一標準

// 方法五,while
function method5() {
  var arrCopy = []
  console.time('method5')
  let i = 0
  while (i < hugeArr.length) {
    arrCopy.push(hugeArr[i++])
  }
  console.timeEnd('method5')
}
// 方法六,forEach
function method6() {
  var arrCopy = []
  console.time('method6')
  hugeArr.forEach((item) => {
    arrCopy.push(item)
  })
  console.timeEnd('method6')
}
// 方法七,map
function method7() {
  var arrCopy = []
  console.time('method7')
  arrCopy = hugeArr.map(item => item)
  console.timeEnd('method7')
}
// 方法八,for of
function method8() {
  var arrCopy = []
  console.time('method8')
  for (let item of hugeArr) {
    arrCopy.push(item)
  }
  console.timeEnd('method8')
}
複製程式碼

測試方法同上,測試結果:

- 方法五 方法六 方法七 方法八
第一次 151.380ms 221.332ms 875.402ms 240.411ms
第二次 152.031ms 223.436ms 877.112ms 237.208ms
第三次 150.442ms 221.853ms 876.829ms 253.744ms
第四次 151.319ms 222.672ms 875.270ms 243.165ms
第五次 150.142ms 222.953ms 877.940ms 237.825ms
第六次 155.226ms 225.441ms 879.223ms 240.648ms
第七次 151.254ms 219.965ms 883.324ms 238.197ms
第八次 151.632ms 218.274ms 878.331ms 240.940ms
第九次 151.412ms 223.189ms 873.318ms 256.644ms
第十次 155.563ms 220.595ms 881.203ms 234.534ms
平均耗時 152.040ms 221.971ms 877.795ms 242.332ms
用棧差值 238511400Byte 238511352Byte 53887824Byte 191345296Byte

node下,由上面的資料可以很明顯的看出,forEachmapfor of 這些ES6+的語法並沒有傳統的for迴圈或者while迴圈快,特別是map方法。但是由於map有返回值,無需額外呼叫新陣列的push方法,所以在執行淺拷貝任務上,記憶體佔用很低。而for of語法在記憶體佔用上也有一定的優勢。順便提一下:for迴圈 while迴圈 for of 迴圈是可以通過break關鍵字跳出的,而forEach map這種迴圈是無法跳出的。

但是隨著執行環境和瀏覽器的不同,這些語法在執行速度上也會出現偏差甚至反轉的情況,直接看圖:

谷歌瀏覽器

谷歌

火狐瀏覽器

火狐

safari瀏覽器下

safari

可以看出:

  1. 谷歌瀏覽器中ES6+的迴圈語法會普遍比傳統的迴圈語法慢,但是火狐和safari中情況卻幾乎相反。
  2. 谷歌瀏覽器的各種迴圈語法的執行耗時上差距並不大。但map特殊,速度明顯比其他幾種語法慢,而在火狐和safari中卻出現了反轉,map反而比較快!
  3. 蘋果大法好

總結

之前有聽到過諸如“快取陣列長度可以提高迴圈效率”或者“ES6的迴圈語法更高效”的說法。說者無心,聽者有意,事實究竟如何,實踐出真知。拋開業務場景和使用便利性,單純談效能和效率是沒有意義的。 ES6新增的諸多陣列的方法確實極大的方便了前端開發,使得以往復雜或者冗長的程式碼,可以變得易讀而且精煉,而好的for迴圈寫法,在大資料量的情況下,確實也有著更好的相容和多環境執行表現。當然本文的討論也只是基於觀察的一種總結,並沒有深入底層。而隨著瀏覽器的更新,這些方法的孰優孰劣也可能成為玄學。目前發現在Chrome Canary 70.0.3513.0for of 會明顯比Chrome 68.0.3440.84快。如果你有更深入的見解或者文章,也不妨在評論區分享,小弟的這篇文章也權當拋磚引玉。如果你對陣列的其他迴圈方法的效能和效率也感興趣,不妨自己動手試一試,也歡迎評論交流。

本文的測試環境:node v10.8.0Chrome 68.0.3440.84Safari 11.1.2 (13605.3.8)Firefox 60.0

相關文章