什麼時候你應該在 JavaScript 中更喜歡 Map 而不是 Object
在 JavaScript 中選擇 Object 和 Map 的缺失指南
檢視關於reddit的討論
在 JavaScript 中,物件很方便。它們使我們能夠輕鬆地將多條資料組合在一起。在 ES6 之後,我們為該語言添加了一個新功能 - Map
. 在很多方面,它似乎更強大Object
,但介面有點笨拙。然而,大多數人在需要雜湊對映Map
時仍然會使用物件,並且只有在他們意識到鍵不能只是他們用例的字串時才切換到使用。因此,在當今的 JavaScript 社群中Map
仍未得到充分利用。
在這篇文章中,我將分解您應該考慮使用Map
more 的所有原因及其效能特徵與基準測試。
在 JavaScript 中,Object 是一個相當寬泛的術語。幾乎所有東西都可以是一個物件,除了兩種底部型別 -
null
和undefined
. 在這篇博文中,Object 僅指普通的舊物件,由左大括號{
和右大括號分隔}
。
TL;博士:#
- 用於
Object
在作者時已知的屬性/欄位數量固定且有限的記錄,例如配置物件。以及一般一次性使用的任何東西。 - 用於
Map
字典或雜湊對映,其中條目數量可變,更新頻繁,其鍵在作者時可能未知,例如事件發射器。 - 根據我的基準測試,除非鍵是小整數字符串,否則在插入、刪除和迭代速度
Map
方面確實比鍵更Object
高效,並且它消耗的記憶體比Object
相同大小的要少。
為什麼 Object 缺少雜湊對映用例#
將物件用於雜湊對映最明顯的缺點可能是物件只允許作為字串和符號的鍵。任何其他型別都將通過這些toString
方法隱式轉換為字串。
``` const foo = [] const bar = {} const obj = {[foo]: 'foo', [bar]: 'bar'}
console.log(obj) // {"": 'foo', [object Object]: 'bar'} ```
更重要的是,將物件用於雜湊對映可能會導致混淆和安全隱患。
不需要的繼承#
在 ES6 之前,獲取雜湊對映的唯一方法是建立一個空物件。
const hashMap = {}
但是,在建立後,此物件不再為空。雖然hashMap
是用一個空的物件字面量製作的,但它會自動繼承自Object.prototype
. 這就是為什麼我們可以呼叫像hasOwnProperty
, toString
, constructor
on這樣的方法,hashMap
即使我們從未在物件上明確定義這些方法。
由於原型繼承,我們現在混合了兩種型別的屬性:存在於物件本身中的屬性,即它自己的屬性,以及存在於原型鏈中的屬性,即繼承的屬性。因此,我們需要額外的檢查(例如hasOwnProperty
)來確保給定的屬性確實是使用者提供的,而不是從原型繼承的。
最重要的是,由於屬性解析機制在 JavaScript 中的工作方式 Object.prototype
,執行時的任何更改都會在所有物件中產生連鎖反應。 這為原型汙染攻擊打開了大門,這對於大型 JavaScript 應用程式來說可能是一個嚴重的安全問題。
幸運的是,我們可以通過使用來解決這個問題Object.create(null)
,這會生成一個不繼承任何內容的物件Object.prototype
。
名稱衝突##
當一個物件自己的屬性與其原型上的屬性發生名稱衝突時,它會破壞預期並因此使您的程式崩潰。
例如,我們有一個foo
接受物件的函式:
``` function foo(obj) { //... for(const key in obj) { if(obj.hasOwnProperty(key)) {
}
}
} ```
存在可靠性風險obj.hasOwnProperty(key)
:考慮到屬性解析機制在 JavaScript 中的工作方式,如果obj
包含使用者提供的同名屬性,則會hasOwnProperty
隱藏Object.prototype.hasOwnProperty
. 結果,我們不知道在執行時會準確呼叫哪個方法。
可以進行一些防禦性程式設計來防止這種情況。例如,我們可以“借用”“真實”hasOwnProperty
來Object.prototype
代替:
function foo(obj) {
//...
for(const key in obj) {
if(Object.prototype.hasOwnProperty.call(obj, key)) {
// ...
}
}
}
一種更短的方法可能是在物件文字上呼叫該方法,{}.hasOwnProperty.call(key)
但它仍然很麻煩。這就是為什麼有一個新新增的靜態方法Object.hasOwn
。
次優的人體工程學##
Object
沒有提供足夠的人體工程學來用作雜湊圖。許多常見的任務無法直觀地執行。
尺寸##
Object
沒有提供方便的 API 來獲取大小,即屬性的數量。構成物件大小的因素也有細微差別:
- 如果您只關心字串、可列舉鍵,那麼您可以將鍵轉換為陣列
Object.keys()
並獲取其length
. - 如果您想考慮不可列舉的字串鍵,那麼您必須使用
Object.getOwnPropertyNames
來獲取鍵列表並獲取其長度。 - 如果您對符號鍵感興趣,可以使用
getOwnPropertySymbols
來顯示符號鍵。或者,您可以使用Reflect.ownKeys
同時獲取字串鍵和符號鍵,無論它是否可列舉。
上述所有選項的執行時複雜度為 ,O(n)
因為我們必須先構造一個鍵陣列,然後才能獲得它的長度。
迭代#
遍歷物件也有類似的複雜性。
我們可以使用良好的舊for...in
迴圈。但它揭示了繼承的可列舉屬性:
``` Object.prototype.foo = 'bar'
const obj = {id: 1}
for(const key in obj) { console.log(key) // 'id', 'foo' } ```
我們不能for...of
與物件一起使用,因為預設情況下它不是可迭代的,除非我們Symbol.iterator
在其上顯式定義方法。
我們可以使用Object.keys
,Object.values
和Object.entries
來獲取可列舉的字串鍵(或/和值)列表,然後對其進行迭代,這會引入額外的開銷步驟。
最後,臭名昭著的插入順序沒有得到充分尊重。在大多數瀏覽器中,整數鍵按升序排序並且優先於字串鍵,即使字串鍵插入到整數鍵之前也是如此。
``` const obj = {}
obj.foo = 'first' obj[2] = 'second' obj[1] = 'last'
console.log(obj) // {1: 'last', 2: 'second', foo: 'first'} ```
清除#
沒有簡單的方法可以從物件中刪除所有屬性,您必須使用delete
操作符一個一個地刪除每個屬性,這在歷史上被認為是緩慢的。但是我的基準測試表明,它的效能實際上並不比Map.prototype.delete
. 稍後再談。
檢查屬性是否存在##
最後,我們不能依賴點/括號符號來檢查屬性是否存在,因為值本身可以設定為undefined
. 相反,我們必須使用Object.prototype.hasOwnProperty
or Object.hasOwn
。
``` const obj = {a: undefined}
Object.hasOwn(obj, 'a') // true ```
雜湊對映的對映#
ES6 帶來了 Map。它更適合雜湊對映用例。
首先,與Object
只允許字串和符號的鍵不同,它Map
支援任何資料型別的鍵。
但是,如果您
Map
用於儲存物件的元資料,那麼您應該使用它WeakMap
來避免記憶體洩漏。
但更重要的是,Map
它提供了使用者定義和內建程式資料之間的清晰分離Map.prototype.get
,但代價是額外的檢索條目。
Map
還提供了更好的人體工程學:Map
預設情況下,A 是可迭代的。這意味著您可以使用 輕鬆迭代地圖for...of
,並執行諸如使用巢狀解構從地圖中提取第一個條目之類的操作。
const [[firstKey, firstValue]] = map
與 相比Object
,Map
為各種常見任務提供專用 API:
Map.prototype.has
檢查給定條目的存在,與必須Object.prototype.hasOwnProperty
/Object.hasOwn
在物件上相比不那麼尷尬Map.prototype.get
返回與提供的鍵關聯的值。人們可能會覺得這比物件上的點表示法或括號表示法更笨拙。然而,它在使用者資料和內建方法之間提供了清晰的分離。Map.prototype.size
返回 a 中的條目數,Map
它顯然是獲得物件大小所必須執行的操作的贏家。此外,它要快得多。Map.prototype.clear
刪除 a 中的所有條目,Map
它比運算子快得多delete
。
表演盛會#
在大多數情況下,JavaScript 社群似乎普遍認為Map
比Object
. 有些人聲稱通過切換Object
到Map
.
我磨練 Leetcode 的經驗似乎證實了這個信念:Leetcode 將大量資料作為測試用例提供給您的解決方案,如果您的解決方案耗時過長,它就會超時。像這樣的問題只有在你使用時才會超時Object
,而不是在Map
.
但是,我相信只是說“Map
比物件更快”是簡化的。一定有一些細微差別是我想自己找出來的。所以。我構建了一個小應用程式來執行一些基準測試。
重要免責宣告
[](http://en.wikipedia.org/wiki/Blind_men_and_an_elephant)
基準測試實施細節#
該應用程式有一個表格,顯示在 和 上測量的插入、迭代和刪除Object
速度Map
。
插入和迭代的效能以每秒運算元來衡量。我編寫了一個 util 函式measureFor
,它重複執行目標函式,直到達到指定的最小時間閾值(即duration
UI 上的輸入欄位)。它返回每秒執行此類函式的平均次數。
``` 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); } ```
至於刪除,我只是要測量使用delete
運算子從物件中刪除所有屬性所需的時間,並將其與 Map.prototype.delete
相同大小的 Map 的時間進行比較。我可以使用Map.prototype.clear
,但它違背了基準測試的目的,因為我確信它會更快。
在這三個操作中,我更加關注插入,因為它往往是我在日常工作中執行的最常見的操作。對於迭代效能,很難提出一個包羅永珍的基準,因為我們可以在給定物件上執行許多不同的迭代變體。這裡我只測量for ... in
迴圈。
我在這裡使用了三種類型的鍵:
- 字串,例如
yekwl7caqejth7aawelo4
. - 整數字符串,例如
123
. - 由 生成的數字字串
Math.random().toString()
,例如0.4024025689756525
。
所有的鍵都是隨機生成的,所以我們不會碰到 V8 實現的內聯快取。我還顯式地將整數和數字鍵轉換為字串,toString
然後再將它們新增到物件以避免隱式轉換的開銷。
最後,在基準測試開始之前,還有一個至少 100 毫秒的預熱階段,我們反覆建立新的物件和地圖,這些新物件和地圖會立即被丟棄。
如果你想玩,我把程式碼放在Codesandbox上。
我從 100 個屬性/條目的大小開始,Object
一直Map
到 5000000,並讓每種型別的操作持續執行 10000 毫秒,以檢視它們如何相互執行。這是我的發現...
為什麼我們在條目數達到 5000000 時停止?
字串鍵#
一般來說,當鍵是(非數字)字串時,在所有操作上都Map
優於。Object
但細微差別在於,當條目數量不是很大(低於 100000)時,插入速度是插入速度Map
的兩倍Object
,但隨著大小增長超過 100000,效能差距開始縮小。
我製作了一些圖表來更好地說明我的發現。
上圖顯示了隨著條目數量的增加(x 軸),插入率如何下降(y 軸)。但是因為 X 軸擴充套件得太寬(從 100 到 1000000),所以很難分辨這兩條線之間的差距。
然後我使用對數刻度來處理資料並製作下面的圖表。
您可以清楚地看出兩條線正在匯合。
我製作了另一個圖表,繪製了Map
與Object
插入速度相關的速度。您可以看到Map
開始時比Object
. 然後隨著時間的推移,效能差距開始縮小。Map
隨著規模增長到 5000000,最終速度僅快 30%。
但是,我們大多數人在一個物件或對映中永遠不會有超過 100 萬個條目。具有數百或數千個條目的大小,其Map
效能至少是Object
. 因此,我們是否應該把它留在那兒,然後全力以赴開始重構我們的程式碼庫Map
?
絕對不會……或者至少沒有期望我們的應用程式會快 2 倍。請記住,我們還沒有探索過其他型別的鍵。讓我們看一下整數鍵。
整數鍵#
我特別想對具有整數鍵的物件執行基準測試的原因是 V8 在內部優化了整數索引屬性並將它們儲存在可以線性和連續訪問的單獨陣列中。我找不到任何資源來確認它對Map
s 採用了相同型別的優化。
讓我們首先嚐試 [0, 1000] 範圍內的整數鍵。
正如我所料,這次Object
跑贏大盤。 Map
它們的插入速度比地圖快 65%,迭代速度快 16%。
讓我們擴大範圍,使鍵中的最大整數為 1200。
現在似乎Map
開始比插入物件快一點,迭代快 5 倍。
現在我們只增加了整數鍵的範圍,而不是和的實際Object
大小Map
。讓我們增大尺寸,看看它如何影響效能。
當屬性大小為 1000 個時,Object
最終比Map
插入快 70%,迭代慢 2 倍。
我玩了一堆Object
/Map
大小和整數鍵範圍的不同組合,但未能提出清晰的模式。但是我看到的總體趨勢是,隨著大小的增長,以一些相對較小的整數作為鍵,物件在插入方面可能比s效能Map
更高,總是與刪除大致相同,並且慢 4 或 5 倍迭代。物件插入速度開始變慢的最大整數鍵的閾值隨著物件的大小而增長。例如,當物件只有 100 個條目時,閾值為 1200;當它有 10000 個條目時,閾值似乎在 24000 左右。
數字鍵#
最後,我們來看看最後一種鍵——數字鍵。
從技術上講,以前的整數鍵也是數字的。這裡的數字鍵特指生成的數字字串Math.random().toString()
。
結果類似於那些字串鍵的情況:Map
s 開始時比物件快得多(插入和刪除快 2 倍,迭代快 4-5 倍),但是隨著我們增加大小,增量變得越來越小。
巢狀物件/地圖呢?
記憶體使用情況#
基準測試的另一個重要方面是記憶體利用率。
由於我無法控制瀏覽器環境中的垃圾收集器,因此我決定在 Node.js 中執行基準測試。
我建立了一個小指令碼來測量它們各自的記憶體使用情況,並在每次測量中手動觸發完全垃圾收集。執行它,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
它不儲存屬性描述符,例如writable
// like 。enumerable``configurable``Object
結論#
那麼我們能從這一切中得到什麼?
Map
比Object
除非你有小整數、陣列索引的鍵更快,而且它更節省記憶體。Map
如果您需要頻繁更新的雜湊對映,請使用;Object
如果您想要一個固定的鍵值集合(即記錄),請使用,並注意原型繼承帶來的陷阱。
如果您確切瞭解 V8 如何優化的細節,
Map
或者只是想指出我的基準測試中的缺陷,請聯絡我。我很樂意根據您的資訊更新這篇文章!
瀏覽器相容性注意事項#
Map
是 ES6 的一個特性。到目前為止,我們大多數人都不應該擔心它的相容性,除非你的目標使用者群是一些小眾的舊瀏覽器。“舊”是指比 IE 11 更早,因為即使 IE 11 也支援Map而此時 IE 11已死。我們不應該在預設情況下盲目地轉譯和新增 polyfill 到目標 ES5,因為它不僅會膨脹你的包大小,而且與現代 JavaScript 相比執行起來很慢。最重要的是,它會懲罰 99.999% 的使用現代瀏覽器的使用者。
另外,我們不必放棄對舊版瀏覽器的支援——nomodule
通過提供後備包來提供舊版程式碼,這樣我們就可以避免使用現代瀏覽器降低訪問者的體驗。如果您需要更有說服力,請參閱過渡到現代 JavaScript 。
JavaScript 語言在不斷髮展,平臺在優化現代 JavaScript 方面也越來越好。我們不應該以瀏覽器相容性為藉口忽略所有已做出的改進。
轉載於http://www.zhenghao.io/posts/object-vs-map