JavaScript 中什麼時候使用 Map 更好

Map 用作 Hash Map

ES6 給我們帶來瞭 Map,它更適合當做 hash map 的用例。

首先,它並不像 Object 那樣隻允許 key 為 string 和 symobol,Map 的 key 支持任何數據類型。

可是如果你使用 Map 為對象存儲元數據,應該使用 WeakMap 取而代之以此避免內存泄漏。

更重要的是,Map 為用戶自定義和內置對象屬性提供瞭清晰的界限,使用一個額外的方法 Map.prototype.get 獲取條目。

Map 同樣也提供瞭更人性化的方法,Map 默認就可迭代,意味著你可以輕松的與 for...of 一起使用,同樣使用嵌套的解構獲取第一個條目。

const [[firstKey, firstValue]] = map

與 Object 對比,Map 為各種常見任務提供瞭具體的方法:

  • Map.prototype.has 檢查一個給定條目的存在,與對象上的 Object.prototype.hasOwnProperty / Object.hasOwn 對比還是方便許多。
  • Map.prototype.get 返回與提供的 key 相關的值。可能會有人覺得比對象上的 . 和 [] 稍顯笨重。然後它為用戶自定義的數據和內置的方法提供瞭清晰的界限。
  • Map.prototype.clear 可以清除 Map 上的所有條目且比 delete 操作更快。

性能

在 JavaScript 社區中似乎有一種共同的認知:Map 比 Object 快,在多數情況下,有些人在從 Object 切換到 Map 時看到瞭性能的提升。

從我在磨人的 LeetCode 中刷題的經驗中似乎更加確認瞭這個觀點:LeetCode 使用瞭大量的數據來對你的解決方案做測試用例,若你的答案花費瞭太長時間則會超時。像這種問題一般在你使用 Object 時出現幾次,而 Map 則沒有。

可是,我相信隻簡單的說 Map 比 Object 快很籠統,肯定有一些細微的差別需要我去發現。因此,我創建瞭一個小應用來運行一些基準測試。

基準測試的實現細節

這個應用有一個表格用來展示分別對 Object 和 Map 作用插入、迭代和刪除的速度。

插入和迭代的性能是以每秒來測量的,我寫瞭一個工具方法 measureFor 用來重復執行目標函數,直到設定的最小閾值(就是傳入的 duration 值)。它返回的是每秒函數執行的平均次數。

function measureFor(f, duration) {
  let iterations = 0;
  const now = performance.now();
  let elapsed = 0;
  while (elapsed < duration) {
    f();
    elapsed = performance.now() - now;
    iterations++;
  }

  return ((iterations / elapsed) * 1000).toFixed(4);
}

對於刪除,我打算簡單的對比一下從長度相同的 Object 、 Map 分別使用 delete 和 Map.protoype.delete 移除所有屬性所花費時間的對比。我知道有 Map.protype.clear 但據我所知它非常的快,這有悖於設置基礎測試的目的。

在這三種操作中,因為每天的工作中經常使用到插入操作,所以我更關心它。對於迭代的性能,因為有很多方法用於對象的迭代,所以很難包含所有的基準測試。我在這裡隻測試瞭 for ... in 循環。

我這裡使用瞭三種類型的 key:

  • 字符串,例如:'yekwl7caqejth7aawelo4'
  • 整數字符串,例如:123
  • 通過 Math.random().toString() 生產的數字字符串,例如:'0.6514674912156457'

所有的 key 都是隨機生成的,所以不會觸發 V8 內部實現的緩存機制。在把屬性添加到對象上之前,我還在顯性的把整數和數值類型的 key 通過 toString 轉換為字符串以此避免隱式開銷。

最後,在基準測試開始之前,還有一個 100ms 的熱身階段,就是重復創建新的 object 和 map 並立即丟棄掉所耗費的時間。

代碼放到瞭 CodeSandbox 如果你想試玩一下。

從 100 個屬性/條目大小的 Object 和 Map 開始,一直到5000000,每種類型的操作都執行瞭 10000ms 來對比它們之間的表現,

如下:

為何把條目數達到 5000000時才停止? 這是 JavaScript 中能得到的最大對象,根據 StackOverflow 上一名活躍的 V8 工程師 @jmrk 所說:"如果 key 為字符串,當一個普通的對象元素達到 8.3M 時會各種對它的操作會變得非常慢(這是有技術原因的:某個位域有23位寬,當超過時采取非常緩慢的回退路徑。)"

字符串類型的鍵

通常來說,當 key 為字符串(非數值型),在各種操作中 Map 的表現勝過 Object

但是當條目數並沒有特別大的時候(100000 以下的時候),在插入操作的速度上 Map 基本是 Object 的兩倍,但當大小超過 100000 時,性能差距會開始縮小。

我制作瞭一個圖表來說明我的發現:

上面的圖表演示瞭插入速率隨著條目數(x-axis)的增加是如何下降的(y-axis)。可是,因為 x-axis 擴展的越來越大(從 100 到 1000000),很難區分出兩條線之間的間隔差距。

然後我用對數比例來處理數據,做出瞭下面的圖表:

你會清楚的分不出兩條線漸漸地重疊。

我又用另一個圖表來展示當插入操縱時 Map 比 Object 快多少。你可以看到期初 Map 比 Object 快 2倍,隨著時間的推移性能差距開始縮小。最終,當大小達到 5000000 時,Map 隻快瞭 30%。

我們的 object 和 map 的條目永遠不可能多餘 1百萬。成百上千的條目,Map 至少比 Object 快 2 倍。因此,我們是否應該開始使用 Map 來重構我們的代碼?

當然不是,至少不能期望我們的程序變得比之前快 2倍。記住我們還沒有探索其它類型的 key,接下來讓我們一起看看整數類型的 key。

整數類型的鍵

我特別想運行對象上鍵為整數類型的原因是 V8 內部為整數索引的屬性做瞭優化以及把它們存儲在一個分開的數組中,然後可以線性和連續的獲取。可是我沒有找到任何資料來證明 V8 對 Map 做瞭同樣的優化。

我們先在 [0,1000] 之間嘗試整數類型的 key。

就像我預期的一樣,這次 Object 比 Map 做得好,在插入方面快 65%以及循環方面快 16%。

讓我們把范圍調整到最大 1200。

現在看起來 Map 在插入方面快一些以及循環方便快 5 倍。

現在我們僅僅增加瞭鍵的范圍,而不是 Object 和 Map 的實際大小。讓我們來增加它們的大小看看如何影響性能。

當大小為 1000 時,插入方面 Object 比 Map 快 70% 以及循環方面快 2 倍。

我嘗試瞭很多種 Object / Map 大小與整數鍵范圍的組合但沒有得到一個清晰的模式。但我看到的一般趨勢是,隨著大小的增長,以一些相對較小的整數為鍵,對象在插入方面的性能比 Map 更強,在刪除方面總是大致相同,迭代速度要慢 4 或 5 倍。對象在插入時開始變慢的最大整數鍵的閾值會隨著對象的大小而增長。例如,當對象隻有 100 個條目時,閾值是 1200 ;當它有 10000 個條目時,閾值似乎是 24000 左右。

數值類型的鍵

最後,我們一起看一看最後一種類型的鍵–數值型。

從技術上來說,前面的整數類型的鍵也是數值型,不過這裡的數值型特指通過 Math.random().toString() 生成的數值字符串。

結果有點類似字符串類型的鍵:Map 開始時比 Object 快得多(插入和刪除快2倍,迭代快4-5倍),但隨著我們規模的增加,這個差距也越來越小。

嵌套的 Object / Map 呢? 你可能已經發現瞭我隻講瞭深度隻有一層的 Object 和 Map。我確實增加瞭一些嵌套深度但是我發現隻要總條目數相同性能特性大體一致,不管嵌套瞭多少層。

例如,我們有一個寬度為 100 和深度為 3 總數為 100 萬的條目,結果與寬度為 1000000 深度為 1 的性能幾乎相同。

內存使用

基準測試的另一個重要因素為內存利用。

由於我無法控制瀏覽器環境中的垃圾收集器,我決定在Node中運行基準測試。

我創建瞭一個小腳本來測量在每個測試中通過手動觸發完全垃圾回收時各自的內存使用情況。

與 node –expose-gc 一起運行將會得到如下結果:

{
  object: {
    'string-key': {
      '10000': 3.390625,
      '50000': 19.765625,
      '100000': 16.265625,
      '500000': 71.265625,
      '1000000': 142.015625
    },
    'numeric-key': {
      '10000': 1.65625,
      '50000': 8.265625,
      '100000': 16.765625,
      '500000': 72.265625,
      '1000000': 143.515625
    },
    'integer-key': {
      '10000': 0.25,
      '50000': 2.828125,
      '100000': 4.90625,
      '500000': 25.734375,
      '1000000': 59.203125
    }
  },
  map: {
    'string-key': {
      '10000': 1.703125,
      '50000': 6.765625,
      '100000': 14.015625,
      '500000': 61.765625,
      '1000000': 122.015625
    },
    'numeric-key': {
      '10000': 0.703125,
      '50000': 3.765625,
      '100000': 7.265625,
      '500000': 33.265625,
      '1000000': 67.015625
    },
    'integer-key': {
      '10000': 0.484375,
      '50000': 1.890625,
      '100000': 3.765625,
      '500000': 22.515625,
      '1000000': 43.515625
    }
  }
}

很明顯,Map 比 Object 消耗的內存少20%到50%不等,結果並不意外因為 Map 不存儲屬性的描述類似 Object 上的 writable 、enumerable 、configurable

總結

那麼,我們從其中能得到什麼?

  • Map 比 Object 更快,除非你有小的整數、數組索引為鍵,而且它更節省內存。
  • 若 Hash Map 需要經常更新你應該使用 Map;若你的集合為固定的鍵值(例如:記錄)則使用 Object,但是請註意原型繼承帶來的陷阱。

如果你知道 V8 優化 Map 的細節,或者隻是想指出我的基準測試中的缺陷,請與我聯系。我很樂意根據你的信息來更新這個帖子。

瀏覽器兼容性筆記

Map 是 ES6 引入的特性,目前為止我們不需要擔心其兼容性除非你的客戶使用的舊瀏覽器。舊指比 IE11 版本低,即使 IE11 支持 Map 但它已經  瞭。默認情況下,我們不需要費盡心思的添加轉換器和墊片來支持 ES5 ,因為那樣會增加你的打包體積,同樣與現代瀏覽器比更慢。最重要的是,那樣會對 99.999% 使用現代瀏覽器的用戶帶來負擔。

另外,我們不必放棄對傳統瀏覽器的支持–通過 nomodule 提供回退包來服務傳統代碼,這樣我們就可以避免降低使用現代瀏覽器的訪問者的體驗。如果你需要更多的說服力,請參考《過渡到現代JavaScript》。

JavaScript語言在不斷發展,平臺在優化現代JavaScript方面也不斷完善。我們不應該以瀏覽器兼容性為借口,忽視所有已經取得的改進。

到此這篇關於JavaScript 中什麼時候使用 Map 更好的文章就介紹到這瞭,更多相關JavaScript 使用 Map 內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: