我應該使用 Object 還是 Map?

前言

在日常的 JavaScript 項目中,我們最常用到的數據結構就是各種形式的鍵值對格式了(key-value pair)。在 JavaScript 中,除了最基礎的 Object 是該格式外,ES6 新增的 Map 也同樣是鍵值對格式。它們的用法在很多時候都十分接近。不知道有沒有人和我一樣糾結過該選擇哪個去使用呢?在本菜最近的項目中,我又遇到了這樣的煩惱,索性一不做二不休,去對比一下究竟該使用哪一個。

本文將會探討一下 Object 和 Map 的不同,從多個角度對比一下 Object 和 Map

希望讀完本文的你可以在日後的項目中做出更爲合適的選擇。

用法對比

  1. 對於 Object 而言,它鍵(key)的類型只能是字符串,數字或者 Symbol;而對於 Map 而言,它可以是任何類型。(包括 Date,Map,或者自定義對象)

  2. Map 中的元素會保持其插入時的順序;而 Object 則不會完全保持插入時的順序,而是根據如下規則進行排序:

  1. 讀取 Map 的長度很簡單,只需要調用其 .size() 方法即可;而讀取 Object 的長度則需要額外的計算:Object.keys(obj).length

  2. Map 是可迭代對象,所以其中的鍵值對是可以通過 for of 循環或 .foreach() 方法來迭代的;而普通的對象鍵值對則默認是不可迭代的,只能通過 for in 循環來訪問(或者使用 Object.keys(o)、Object.values(o)、Object.entries(o) 來取得表示鍵或值的數字)迭代時的順序就是上面提到的順序。

    const o = {};
    const m = new Map();
    o[Symbol.iterator] !== undefined; // false
    m[Symbol.iterator] !== undefined; // true
  3. 在 Map 中新增鍵時,不會覆蓋其原型上的鍵;而在 Object 中新增鍵時,則有可能覆蓋其原型上的鍵:

    Object.prototype.x = 1;
    const o = {x:2};
    const m = new Map([[x,2]]);
    o.x; // 2,x = 1 被覆蓋了
    m.x; // 1,x = 1 不會被覆蓋
  4. JSON 默認支持 Object 而不支持 Map。若想要通過 JSON 傳輸 Map 則需要使用到 .toJSON() 方法,然後在 JSON.parse() 中傳入復原函數來將其復原。

    對於 JSON 這裏就不具體展開了,有興趣的朋友可以看一下這:JSON 的序列化和解析

    const o = {x:1};
    const m = new Map([['x', 1]]);
    const o2 = JSON.parse(JSON.stringify(o)); // {x:1}
    const m2 = JSON.parse(JSON.stringify(m)) // {}

句法對比

創建時的區別

Obejct

const o = {}; // 對象字面量
const o = new Object(); // 調用構造函數
const o = Object.create(null); // 調用靜態方法 Object.create

對於 Object 來說,我們在 95%+ 的情況下都會選擇對象字面量,它不僅寫起來最簡單,而且相較於下面的函數調用,在性能方面會更爲高效。對於構建函數,可能唯一使用到的情況就是顯式的封裝一個基本類型;而 Object.create 可以爲對象設定原型。

Map

const m = new Map(); // 調用構造函數

和 Object 不同,Map 沒有那麼多花裏胡哨的創建方法,通常只會使用其構造函數來創建。

除了上述方法之外,我們也可以通過 Function.prototype.apply()、Function.prototype.call()、reflect.apply()、Reflect.construct() 方法來調用 Object 和 Map 的構造函數或者  Object.create() 方法,這裏就不展開了。

新增 / 讀取 / 刪除元素時的區別

Obejct

const o = {};
//新增/修改
o.x = 1;
o['y'] = 2;
//讀取
o.x; // 1
o['y']; // 2
//或者使用 ES2020 新增的條件屬性訪問表達式來讀取
o?.x; // 1
o?.['y']; // 2
//刪除
delete o.b;

對於新增元素,看似使用第一種方法更爲簡單,不過它也有些許限制:

對於條件屬性訪問表達式的更多內容可以看一下這:條件屬性訪問表達式

Map

const m = new Map();
//新增/修改
m.set('x', 1);
//讀取
map.get('x');
//刪除
map.delete('b');

對於簡單的增刪查改來說,Map 上的方法使用起來也是十分便捷的;不過在進行聯動操作時,Map 中的用法則會略顯臃腫:

const m = new Map([['x',1]]);
// 若想要將 x 的值在原有基礎上加一,我們需要這麼做:
m.set('x', m.get('x') + 1);
m.get('x'); // 2

const o = {x: 1};
// 在對象上修改則會簡單許多:
o.x++;
o.x // 2

性能對比

接下來我們來討論一下 Object 和 Map 的性能。不知道各位有沒有聽說過 Map 的性能優於 Object 的說法,我反正是見過不少次,甚至在 JS 高程四中也提到了 Map 對比 Object 時性能的優勢;不過對於性能的概括都十分的籠統,所以我打算做一些測試來對比一下它們的區別。

測試方法

在這裏我進行的對於性能測試的都是基於 v8 引擎的。速度會通過 JS 標準庫自帶的 performance.now() 函數來判斷,內存使用情況會通過 Chrome devtool 中的 memory 來查看。

對於速度測試,因爲單一的操作速度太快了,很多時候 performance.now() 會返回 0。所以我進行了 10000 次的循環然後判斷時間差。因爲循環本身也會佔據一部分時間,所以以下的測試只能作爲一個大致的參考。

創建時的性能

測試用的代碼如下

let n,  n2 = 5;
// 速度
while (n2--) {
  let p1 = performance.now();
  n = 10000;
  while (n--) { let o = {}; }
  let p2 = performance.now();
  n = 10000;
  while (n--) { let m = new Map(); }
  let p3 = performance.now();
  console.log(`Object: ${(p2 - p1).toFixed(3)}ms, Map: ${(p3 - p2).toFixed(3)}ms`);
}
// 內存
class Test {}
let test = new Test();
test.o = o;
test.m = m;

首先進行對比的是創建 Object 和 Map 時的表現。對於創建的速度表現如下:

我們可以發現創建 Object 的速度會快於 Map。對於內存使用情況則如下:

我們主要關注其 Retained Size,它表示了爲其分配的空間。(即刪除時釋放的內存大小)

通過對比我們可以發現,空的 Object 會比空的 Map 佔用更少的內。所以這一輪 Object 贏得一籌。

新增元素時的性能

測試用的代碼如下

console.clear();
let n,  n2 = 5;
let o = {}m = new Map();
// 速度
while (n2--) {
  let p1 = performance.now();
  n = 10000;
  while (n--) { o[Math.random()] = Math.random(); }
  let p2 = performance.now();
  n = 10000;
  while (n--) { m.set(Math.random(), Math.random()); }
  let p3 = performance.now();
  console.log(`Object: ${(p2 - p1).toFixed(3)}ms, Map: ${(p3 - p2).toFixed(3)}ms`);
}
// 內存
class Test {}
let test = new Test();
test.o = o;
test.m = m;

對於新建元素時的速度表現如下:

我們可以發現新建元素時,Map 的速度會快於 Object。對於內存使用情況則如下:

通過對比我們可以發現,在擁有一定數量的元素時, Object 會比 Map 佔用多了約 78% 的內存。我也進行了多次的測試,發現在擁有足夠的元素時,這個百分比是十分穩定的。所以說,在需要進行很多新增操作,且需要儲存許多數據的時候,使用 Map 會更高效。

讀取元素時的性能

測試用的代碼如下

let n;
let o = {}m = new Map();

n = 10000;
while (n--) { o[Math.random()] = Math.random(); }
n = 10000;
while (n--) { m.set(Math.random(), Math.random()); }

let p1 = performance.now();
for (key in o) { let k = o[key]; }
let p2 = performance.now();
for ([key] of m) { let k = m.get(key); }
let p3 = performance.now();
`Object: ${(p2 - p1).toFixed(3)}ms, Map: ${(p3 - p2).toFixed(3)}ms`

對於讀取元素時的速度表現如下:

通過對比,我們可以發現 Object 略佔優勢,但總體差別不大。

刪除元素時的性能

不知道大家是否聽說過 delete 操作符性能低下,甚至有很多時候爲了性能,會寧可將值設置爲 undefined 而不使用 delete 操作符的說法。但其實在 v8 近來的優化下,它的效率已經提升許多了。

測試用的代碼如下

let n;
let o = {}m = new Map();

n = 10000;
while (n--) { o[Math.random()] = Math.random(); }
n = 10000;
while (n--) { m.set(Math.random(), Math.random()); }

let p1 = performance.now();
for (key in o) { delete o[key]; }
let p2 = performance.now();
for ([key] of m) { m.delete(key); }
let p3 = performance.now();
`Object: ${(p2 - p1).toFixed(3)}ms, Map: ${(p3 - p2).toFixed(3)}ms`

對於刪除元素時的速度表現如下:

我們可以發現在進行刪除操作時,Map 的速度會略佔優,但整體差別其實並不大。

特殊情況

其實除了最基本的情況之外,還有一種特殊的情況。還記得我們在前面提到的 Object 中鍵的排序嗎?我們提到了其中的非負整數會被最先列出。其實對於非負整數作爲鍵的值和其餘類型作爲鍵的值來說,v8 是會對它們進行區別對待的。負整數作爲鍵的部分會被當成數組對待,即非負整數具有一定的連續性時,會被當成快數組,而過於稀疏時會被當成慢數組。

對於快數組,它擁有連續的內存,所以在進行讀寫時會更快,且佔用更少的內存。更多的內容可以看一下這: 探究 JS V8 引擎下的 “數組” 底層實現

在鍵爲連續非負整數時,性能如下:

我們可以看到 Object 不僅平均速度更快了,其佔用的內存也大大減少了。

總結

通過對比我們可以發現,Map 和 Object 各有千秋,對於不同的情況下,我們應當作出不同的選擇。所以我總結了一下我認爲使用 Map 和 Object 更爲合適的時機。

使用 Map

使用 Object

雖然 Map 在很多情況下會比 Object 更爲高效,不過 Object 永遠是 JS 中最基本的引用類型,它的作用也不僅僅是爲了儲存鍵值對。

參考

探究 JS V8 引擎下的 “數組” 底層實現

Fast properties in V8

Shallow, Retained, and Deep Size

Slow delete of object properties in JS in V8

ES6 — Map vs Object — What and when?

JavaScript 高級程序設計(第 4 版)

JavaScript: The Definitive Guide (7th Edition)

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/TKBEqoULaDkWaHsa25hRVQ