本文首發於微信公眾號:大遷世界, 我的微信:qq449245884,我會第一時間和你分享前端行業趨勢,學習途徑等等。
更多開源作品請看 GitHub https://github.com/qq449245884/xiaozhi ,包含一線大廠面試完整考點、資料以及我的系列文章。
在 JavaScript 中,物件是很方便的。它們允許我們輕鬆地將多個資料塊組合在一起。 在ES6之後,又出了一個新的語言補充-- Map。在很多方面,它看起來像是一個功能更強的物件,但介面卻有些笨拙。
然而,大多數開發者在需要 hash map 的時候還是會使用物件,只有當他們意識到鍵值不能只是字串的時候才會轉而使用 Map。因此,Map 在當今的 JavaScript 社群中仍然沒有得到充分的使用。
在本文字中,我會列舉一些應該更多考慮使用 Map 的一些原因。
為什麼物件不符合 Hash Map 的使用情況
在 Hash Map 中使用物件最明顯的缺點是,物件只允許鍵是字串和 symbol。任何其他型別的鍵都會透過 toString
方法被隱含地轉換為字串。
const foo = []
const bar = {}
const obj = {[foo]: 'foo', [bar]: 'bar'}
console.log(obj) // {"": 'foo', [object Object]: 'bar'}
更重要的是,使用物件做 Hash Map 會造成混亂和安全隱患。
不必要的繼承
在ES6之前,獲得 hash map 的唯一方法是建立一個空物件:
const hashMap = {}
然而,在建立時,這個物件不再是空的。儘管 hashMap
是用一個空的物件字面量建立的,但它自動繼承了 Object.prototype
。這就是為什麼我們可以在 hashMap
上呼叫hasOwnProperty
、toString
、constructor
等方法,儘管我們從未在該物件上明確定義這些方法。
由於原型繼承,我們現在有兩種型別的屬性被混淆了:存在於物件本身的屬性,即它自己的屬性,以及存在於原型鏈的屬性,即繼承的屬性。
因此,我們需要一個額外的檢查(例如hasOwnProperty
)來確保一個給定的屬性確實是使用者提供的,而不是從原型繼承的。
除此之外,由於屬性解析機制在 JavaScrip t中的工作方式,在執行時對 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
產生影響。因此,我們不知道哪個方法會在執行時被準確呼叫。
可以做一些防禦性程式設計來防止這種情況。例如,我們可以從 Object.prototype
中 "借用""真正的 hasOwnProperty
來代替:
function foo(obj) {
//...
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
// ...
}
}
}
還有一個更簡短的方法就是在一個物件的字面量上呼叫該方法,如{}.hasOwnProperty.call(key)
,不過這也挺麻煩的。這就是為什麼還會新出一個靜態方法Object.hasOwn
的原因了。
次優的人機工程學
Object
沒有提供足夠的人機工程學,不能作為 hash map 使用,許多常見的任務不能直觀地執行。
size
Object
並沒有提供方便的API來獲取 size
,即屬性的數量。而且,對於什麼是一個物件的 size ,還有一些細微的差別:
- 如果只關心字串、可列舉的鍵,那麼可以用
Object.keys()
將鍵轉換為陣列,並獲得其length - 如果k只想要不可列舉的字串鍵,那麼必須得使用
Object.getOwnPropertyNames
來獲得一個鍵的列表並獲得其 length
- 如果只對 symbol 鍵感興趣,可以使用
getOwnPropertySymbols
來顯示 symbol 鍵。或者可以使用Reflect.ownKeys
來一次獲得字串鍵和 symbol 鍵,不管它是否是可列舉的。
上述所有選項的執行時複雜度為O(n),因為我們必須先構造一個鍵的陣列,然後才能得到其長度。
iterate
迴圈遍歷物件也有類似的複雜性
我們可以使用 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.entry
來獲得一個可列舉的字串鍵(或/和值)的列表,並透過該列表進行迭代,這引入了一個額外的開銷步驟。
還有一個是 插入物件的鍵的順序並不是按我們的順序來的,這是一個很蛋疼的地方。在大多數瀏覽器中,整數鍵是按升序排序的,並優先於字串鍵,即使字串鍵是在整數鍵之前插入的:
const obj = {}
obj.foo = 'first'
obj[2] = 'second'
obj[1] = 'last'
console.log(obj) // {1: 'last', 2: 'second', foo: 'first'}
clear
沒有簡單的方法來刪除一個物件的所有屬性,我們必須用 delete
運算子一個一個地刪除每個屬性,這在歷史上是眾所周知的慢。
檢查屬性是否存在
最後,我們不能依靠點/括號符號來檢查一個屬性的存在,因為值本身可能被設定為 undefined
。相反,得使用 Object.prototype.hasOwnProperty
或 Object.hasOwn
。
const obj = {a: undefined}
Object.hasOwn(obj, 'a') // true
Map
ES6 為我們帶來了 Map,首先,與只允許鍵值為字串和 symbols 的 Object 不同,Map 支援任何資料型別的鍵。
但更重要的是,Map 在使用者定義的和內建的程式資料之間提供了一個乾淨的分離,代價是需要一個額外的 Map.prototype.get
來獲取對應的項。
Map 也提供了更好的人機工程學。Map 預設是一個可迭代的物件。這說明可以用 for ... of
輕鬆地迭代一個 Map,並做一些事情,比如使用巢狀的解構來從 Map 中取出第一個項。
const [[firstKey, firstValue]] = map
與 Object 相比,Map 為各種常見任務提供了專門的API:
Map.prototype.has
檢查一個給定的項是否存在,與必須在物件上使用Object.prototype.hasOwnProperty/Object.hasOwn
相比,不那麼尷尬了。- Map.prototype.get 返回與提供的鍵相關的值。有的可能會覺得這比物件上的點符號或括號符號更笨重。不過,它提供了一個乾淨的使用者資料和內建方法之間的分離。
Map.prototype.size
返回 Map 中的項的個數,與獲取物件大小的操作相比,這明顯好太多了。此外,它的速度也更快。Map.prototype.clear
可以刪除 Map 中的所有項,它比 delete 運算子快得多。
效能差異
在 JavaScript 社群中,似乎有一個共同的信念,即在大多數情況下,Map
要比 Object
快。有些人聲稱透過從 Object 切換到 Map 可以看到明顯的效能提升。
我在 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);
}
至於刪除,只是要測量使用 delete
運算子從一個物件中刪除所有屬性所需的時間,並與相同大小的 Map 使用 Map.prototype.delete
的時間進行比較。也可以使用Map.prototype.clear
,但這有悖於基準測試的目的,因為我知道它肯定會快得多。
在這三種操作中,我更關注插入操作,因為它往往是我在日常工作中最常執行的操作。對於迭代效能,很難有一個全面的基準,因為我們可以對一個給定的物件執行許多不同的迭代變體。這裡我只測量 for ... in
迴圈。
在這裡使用了三種型別的 key。
- 字串,例如:Yekwl7caqejth7aawelo4。
- 整數字符串,例如:123
- 由
Math.random().toString()
生成的數字字串,例如:0.4024025689756525。
所有的鍵都是隨機生成的,所以我們不會碰到V8實現的內聯快取。我還在將整數和數字鍵新增到物件之前,使用 toString
明確地將其轉換為字串,以避免隱式轉換的開銷。
最後,在基準測試開始之前,還有一個至少100ms的熱身階段,在這個階段,我們反覆建立新的物件和 Map,並立即丟棄。
如果你也想玩,程式碼已經放在 CodeSandbox 上。
我從大小為 100 個屬性/項的 Object
和 Map
開始,一直到 5000000,並讓每種型別的操作持續執行 10000ms,看看它們之間的表現如何。下面是測試結果:
string keys
一般來說,當鍵為(非數字)字串時,Map
在所有操作上都優於 Object
。
但細微之處在於,當數量並不真正多時(低於100000
),Map 在插入速度上 是Object 的兩倍,但當規模超過 100000
時,效能差距開始縮小。
上圖顯示了隨著條目數的增加(x軸),插入率如何下降(y軸)。然而,由於X軸擴充套件得太寬(從100 到 1000000),很難分辨這兩條線之間的差距。
然後用對數比例來處理資料,做出了下面的圖表。
可以清楚地看出這兩條線正在重合。
這裡又做了一張圖,畫出了在插入速度上 Map 比 Object 快多少。你可以看到 Map 開始時比 Object 快 2 倍左右。然後隨著時間的推移,效能差距開始縮小。最終,當大小增長到 5000000時
,Map 只快了 30%。
雖然我們中的大多數人永遠不會在一個 Object 或 Map 中擁有超過1 00 萬的條資料。對於幾百或幾千個資料的規模,Map 的效能至少是 Object 的兩倍。因此,我們是否應該就此打住,並開始重構我們的程式碼庫,全部採用 Map?
這不太靠譜......或者至少不能期望我們的應用程式變得快 2 倍。記住我們還沒有探索其他型別的鍵。下面我們看一下整數鍵。
integer keys
我之所以特別想在有整數鍵的物件上執行基準,是因為V8在內部最佳化了整數索引的屬性,並將它們儲存在一個單獨的陣列中,可以線性和連續地訪問。但我找不到任何資源來證實它對 Map 也採用了同樣的最佳化方式。
我們首先嚐試在 [0, 1000]
範圍內的整數鍵。
如我所料,Object 這次的表現超過了 Map。它們的插入速度比 Map 快65%
,迭代速度快16%
。
接著, 擴大範圍,使鍵中的最大整數為 1200。
似乎現在 Map 的插入速度開始比 Object 快一點,迭代速度快 5 倍。
現在,我們只增加了整數鍵的範圍,而不是 Object 和 Map 的實際大小。讓我們加大 size,看看這對效能有什麼影響。
當屬性 size 為 1000 時,Object 最終比 Map 的插入速度快 70%,迭代速度慢2倍。
我玩了一堆 Object/Map
size 和整數鍵範圍的不同組合,但沒有想出一個明確的模式。但我看到的總體趨勢是,隨著 size 的增長,以一些相對較小的整數作為鍵值,Object
在插入方面比Map
更有效能,在刪除方面總是大致相同,迭代速度慢4或5倍。
Object 在插入時開始變慢的最大整數鍵的閾值會隨著 Object 的大小而增長。例如,當物件只有100個條資料,閾值是1200;當它有 10000 個條目時,閾值似乎是 24000 左右。
numeric keys
最後,讓我們來看看最後一種型別的按鍵--數字鍵。
從技術上講,之前的整數鍵也是數字鍵。這裡的數字鍵特指由 Math.random().toString()
生成的數字字串。
結果與那些字串鍵的情況類似。Map 開始時比 Object 快得多(插入和刪除快2倍,迭代快4-5倍),但隨著我們規模的增加,差距也越來越小。
記憶體使用情況
基準測試的另一個重要方面是記憶體利用率.
由於我無法控制瀏覽器環境中的垃圾收集器,這裡決定在 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,並注意原型繼承帶來的陷阱。
程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug。
交流
有夢想,有乾貨,微信搜尋 【大遷世界】 關注這個在凌晨還在刷碗的刷碗智。
本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。