JavaScript for impatient programmers (ES2022 edition)
請支持這本書:購買捐款
(廣告,請不要封鎖。)

33 Map (Map)



在 ES6 之前,JavaScript 沒有字典的資料結構,並將物件(濫)用為從字串到任意值的字典。ES6 帶來了 Map,它是從任意值到任意值的字典。

33.1 使用 Map

Map 的一個實例會將鍵對應到值。單一鍵值對應稱為項目

33.1.1 建立 Map

建立 Map 有三種常見的方式。

首先,你可以使用不帶任何參數的建構函式建立一個空的 Map

const emptyMap = new Map();
assert.equal(emptyMap.size, 0);

其次,你可以傳遞一個可迭代物件(例如陣列)給建構函式,其中包含鍵值「配對」(包含兩個元素的陣列)

const map = new Map([
  [1, 'one'],
  [2, 'two'],
  [3, 'three'], // trailing comma is ignored
]);

第三,.set() 方法會將項目加入 Map 中,而且可以串連使用

const map = new Map()
  .set(1, 'one')
  .set(2, 'two')
  .set(3, 'three');

33.1.2 複製 Map

正如我們稍後將看到的,Map 也是可迭代的鍵值配對。因此,你可以使用建構函式建立 Map 的副本。這個副本是淺層的:鍵和值是相同的;它們不會被複製。

const original = new Map()
  .set(false, 'no')
  .set(true, 'yes');

const copy = new Map(original);
assert.deepEqual(original, copy);

33.1.3 處理單一項目

.set().get() 用於寫入和讀取值(給定鍵)。

const map = new Map();

map.set('foo', 123);

assert.equal(map.get('foo'), 123);
// Unknown key:
assert.equal(map.get('bar'), undefined);
// Use the default value '' if an entry is missing:
assert.equal(map.get('bar') ?? '', '');

.has() 檢查 Map 是否有具有給定鍵的項目。.delete() 會移除項目。

const map = new Map([['foo', 123]]);

assert.equal(map.has('foo'), true);
assert.equal(map.delete('foo'), true)
assert.equal(map.has('foo'), false)

33.1.4 判斷 Map 的大小並清除它

.size 包含 Map 中項目的數量。.clear() 會移除 Map 中的所有項目。

const map = new Map()
  .set('foo', true)
  .set('bar', false)
;

assert.equal(map.size, 2)
map.clear();
assert.equal(map.size, 0)

33.1.5 取得 Map 的鍵和值

.keys() 會傳回 Map 中鍵的可迭代物件

const map = new Map()
  .set(false, 'no')
  .set(true, 'yes')
;

for (const key of map.keys()) {
  console.log(key);
}
// Output:
// false
// true

我們使用 Array.from().keys() 傳回的可迭代物件轉換為陣列

assert.deepEqual(
  Array.from(map.keys()),
  [false, true]);

.values() 的運作方式與 .keys() 相同,但處理的是值,而不是鍵。

33.1.6 取得 Map 的項目

.entries() 會傳回 Map 中項目的可迭代物件

const map = new Map()
  .set(false, 'no')
  .set(true, 'yes')
;

for (const entry of map.entries()) {
  console.log(entry);
}
// Output:
// [false, 'no']
// [true, 'yes']

Array.from() 會將 .entries() 傳回的可迭代物件轉換為陣列

assert.deepEqual(
  Array.from(map.entries()),
  [[false, 'no'], [true, 'yes']]);

Map 實例也是可迭代的項目。在以下程式碼中,我們使用 解構 來存取 map 的鍵和值

for (const [key, value] of map) {
  console.log(key, value);
}
// Output:
// false, 'no'
// true, 'yes'

33.1.7 依插入順序列出:項目、鍵、值

Map 會記錄項目建立的順序,並在列出項目、鍵或值時遵循此順序

const map1 = new Map([
  ['a', 1],
  ['b', 2],
]);
assert.deepEqual(
  Array.from(map1.keys()), ['a', 'b']);

const map2 = new Map([
  ['b', 2],
  ['a', 1],
]);
assert.deepEqual(
  Array.from(map2.keys()), ['b', 'a']);

33.1.8 在 Map 和物件之間轉換

只要 Map 只使用字串和符號作為鍵,你就可以將它轉換為物件(透過 Object.fromEntries()

const map = new Map([
  ['a', 1],
  ['b', 2],
]);
const obj = Object.fromEntries(map);
assert.deepEqual(
  obj, {a: 1, b: 2});

你也可以將物件轉換為具有字串或符號鍵的 Map(透過 Object.entries()

const obj = {
  a: 1,
  b: 2,
};
const map = new Map(Object.entries(obj));
assert.deepEqual(
  map, new Map([['a', 1], ['b', 2]]));

33.2 範例:計算字元

countChars() 會傳回一個 Map,將字元對應到出現次數。

function countChars(chars) {
  const charCounts = new Map();
  for (let ch of chars) {
    ch = ch.toLowerCase();
    const prevCount = charCounts.get(ch) ?? 0;
    charCounts.set(ch, prevCount+1);
  }
  return charCounts;
}

const result = countChars('AaBccc');
assert.deepEqual(
  Array.from(result),
  [
    ['a', 2],
    ['b', 1],
    ['c', 3],
  ]
);

33.3 關於 Map 鍵的更多詳細資訊(進階)

任何值都可以是鍵,甚至是物件

const map = new Map();

const KEY1 = {};
const KEY2 = {};

map.set(KEY1, 'hello');
map.set(KEY2, 'world');

assert.equal(map.get(KEY1), 'hello');
assert.equal(map.get(KEY2), 'world');

33.3.1 哪些鍵被視為相等?

大多數 Map 操作都需要檢查值是否等於其中一個鍵值。它們透過內部操作 SameValueZero 來執行此操作,其運作方式類似於 ===,但將 NaN 視為等於自身。

因此,你可以將 NaN 用作 Map 中的鍵值,就像任何其他值一樣

> const map = new Map();

> map.set(NaN, 123);
> map.get(NaN)
123

不同的物件總是會被視為不同。這是無法變更的事情(目前尚無法變更 – 設定鍵值相等性是 TC39 的長期規劃藍圖)。

> new Map().set({}, 1).set({}, 2).size
2

33.4 遺失的 Map 操作

33.4.1 對 Map 進行對應和篩選

你可以對陣列執行 .map().filter(),但 Map 沒有此類操作。解決方案是

  1. 將 Map 轉換為 [鍵值、值] 成對的陣列。
  2. 對陣列進行對應或篩選。
  3. 將結果轉換回 Map。

我將使用下列 Map 來示範其運作方式。

const originalMap = new Map()
.set(1, 'a')
.set(2, 'b')
.set(3, 'c');

對應 originalMap

const mappedMap = new Map( // step 3
  Array.from(originalMap) // step 1
  .map(([k, v]) => [k * 2, '_' + v]) // step 2
);
assert.deepEqual(
  Array.from(mappedMap),
  [[2,'_a'], [4,'_b'], [6,'_c']]);

篩選 originalMap

const filteredMap = new Map( // step 3
  Array.from(originalMap) // step 1
  .filter(([k, v]) => k < 3) // step 2
);
assert.deepEqual(Array.from(filteredMap),
  [[1,'a'], [2,'b']]);

Array.from() 會將任何可迭代物件轉換為陣列。

33.4.2 合併 Map

沒有用於合併 Map 的方法,因此我們必須使用與前一節類似的解決方法。

讓我們合併下列兩個 Map

const map1 = new Map()
  .set(1, '1a')
  .set(2, '1b')
  .set(3, '1c')
;

const map2 = new Map()
  .set(2, '2b')
  .set(3, '2c')
  .set(4, '2d')
;

若要合併 map1map2,我們會建立一個新的陣列,並將 map1map2 的項目(鍵值對)散佈(...)到其中(透過反覆運算)。然後,我們將陣列轉換回 Map。所有這些都在 A 行中完成

const combinedMap = new Map([...map1, ...map2]); // (A)
assert.deepEqual(
  Array.from(combinedMap), // convert to Array for comparison
  [ [ 1, '1a' ],
    [ 2, '2b' ],
    [ 3, '2c' ],
    [ 4, '2d' ] ]
);

  練習:合併兩個 Map

exercises/maps/combine_maps_test.mjs

33.5 快速參考:Map<K,V>

注意:為了簡潔起見,我假設所有鍵值都有相同的類型 K,而所有值都有相同的類型 V

33.5.1 建構函式

33.5.2 Map<K,V>.prototype:處理單一項目

33.5.3 Map<K,V>.prototype:處理所有項目

33.5.4 Map<K,V>.prototype:反覆運算和迴圈

反覆運算和迴圈都會按照項目加入 Map 的順序執行。

33.5.5 本節的來源

33.6 常見問題:Map

33.6.1 我應該在什麼時候使用 Map,什麼時候使用物件?

如果你需要一個字典類型的資料結構,其 key 既不是字串也不是符號,那麼你別無選擇:你必須使用 Map。

但是,如果你的 key 是字串或符號,你必須決定是否要使用物件。一個粗略的通用準則如下

33.6.2 我什麼時候會在 Map 中使用物件作為 key?

你通常希望 Map key 能夠透過值來比較(如果兩個 key 具有相同的內容,則會視為相等)。這排除了物件。但是,有一種使用案例會將物件作為 key:將資料從外部附加到物件。但使用 WeakMaps 會更適合這種使用案例,其中項目不會阻止 key 被垃圾回收(有關詳細資訊,請參閱 下一章)。

33.6.3 為什麼 Map 會保留項目的插入順序?

原則上,Map 是無序的。排序項目的主要原因是,列出項目、key 或值的操作是確定性的。例如,這有助於測試。

33.6.4 為什麼 Map 有 .size,而陣列有 .length

在 JavaScript 中,可索引序列(例如陣列和字串)具有 .length,而未索引的集合(例如 Map 和 Set)具有 .size

  測驗

請參閱 測驗應用程式