19. 地圖和集合
目錄
請支持這本書:購買它(PDF、EPUB、MOBI)捐款
(廣告,請不要封鎖。)

19. 地圖和集合



19.1 概觀

在 ECMAScript 6 中,以下四個資料結構是新增的:MapWeakMapSetWeakSet

19.1.1 地圖

地圖的金鑰可以是任意值

> const map = new Map(); // create an empty Map
> const KEY = {};

> map.set(KEY, 123);
> map.get(KEY)
123
> map.has(KEY)
true
> map.delete(KEY);
true
> map.has(KEY)
false

您可以使用包含 [金鑰、值] 配對的陣列(或任何可迭代物件)來設定地圖中的初始資料

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

19.1.2 集合

集合是唯一元素的集合

const arr = [5, 1, 5, 7, 7, 5];
const unique = [...new Set(arr)]; // [ 5, 1, 7 ]

如您所見,如果將可迭代物件 (範例中的 arr) 傳遞給建構函式,您便可以使用元素初始化集合。

19.1.3 弱映射

弱映射是一種映射,不會防止其鍵被垃圾回收。這表示您可以將資料與物件關聯,而不用擔心記憶體外洩。例如

//----- Manage listeners

const _objToListeners = new WeakMap();

function addListener(obj, listener) {
    if (! _objToListeners.has(obj)) {
        _objToListeners.set(obj, new Set());
    }
    _objToListeners.get(obj).add(listener);
}

function triggerListeners(obj) {
    const listeners = _objToListeners.get(obj);
    if (listeners) {
        for (const listener of listeners) {
            listener();
        }
    }
}

//----- Example: attach listeners to an object

const obj = {};
addListener(obj, () => console.log('hello'));
addListener(obj, () => console.log('world'));

//----- Example: trigger listeners

triggerListeners(obj);

// Output:
// hello
// world

19.2 映射

JavaScript 一直都有非常精簡的標準函式庫。最遺憾的是缺少用於將值對應到值的資料結構。ECMAScript 5 中最好的方法是透過濫用物件,將字串對應到任意值。即使如此,仍有 幾個陷阱 會讓您跌倒。

ECMAScript 6 中的 Map 資料結構讓您可以使用任意值作為鍵,而且非常受歡迎。

19.2.1 基本操作

使用單一項目

> const map = new Map();

> map.set('foo', 123);
> map.get('foo')
123

> map.has('foo')
true
> map.delete('foo')
true
> map.has('foo')
false

判斷映射的大小並清除它

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

> map.size
2
> map.clear();
> map.size
0

19.2.2 設定映射

您可以透過可迭代的鍵值「配對」(具有 2 個元素的陣列) 設定映射。一種可能性是使用陣列 (可迭代)

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

或者,set() 方法是可以串聯的

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

19.2.3

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

const map = new Map();

const KEY1 = {};
map.set(KEY1, 'hello');
console.log(map.get(KEY1)); // hello

const KEY2 = {};
map.set(KEY2, 'world');
console.log(map.get(KEY2)); // world
19.2.3.1 哪些鍵被視為相等?

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

讓我們先看看 === 如何處理 NaN

> NaN === NaN
false

反之,你可以將 NaN 用作 Maps 中的鍵,就像任何其他值一樣

> const map = new Map();

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

=== 一樣,-0+0 被視為相同的值。這通常是處理兩個零的最佳方式(詳細資訊說明於「Speaking JavaScript」)。

> map.set(-0, 123);
> map.get(+0)
123

不同的物件總是視為不同。這是無法設定(目前)的,如後面的常見問題集中所述

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

取得未知的鍵會產生 undefined

> new Map().get('asfddfsasadf')
undefined

19.2.4 反覆運算 Maps

讓我們設定一個 Map 來示範如何反覆運算它。

const map = new Map([
    [false, 'no'],
    [true,  'yes'],
]);

Maps 記錄元素插入的順序,並在反覆運算鍵、值或項目時遵循該順序。

19.2.4.1 鍵和值的 Iterable

keys() 會傳回 Map 中鍵的 Iterable

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

values() 會傳回 Map 中值的 Iterable

for (const value of map.values()) {
    console.log(value);
}
// Output:
// no
// yes
19.2.4.2 項目的 Iterable

entries() 會傳回 Map 的項目,作為 [key,value] 成對的 Iterable(陣列)。

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

解構讓你能夠直接存取鍵和值

for (const [key, value] of map.entries()) {
    console.log(key, value);
}

反覆運算 Map 的預設方式是 entries()

> map[Symbol.iterator] === map.entries
true

因此,你可以讓先前的程式碼片段更簡短

for (const [key, value] of map) {
    console.log(key, value);
}
19.2.4.3 將 Iterable(包含 Maps)轉換為陣列

展開運算子(... 可以將 Iterable 轉換為陣列。這讓我們可以將 Map.prototype.keys()(一個 Iterable)的結果轉換為陣列

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

Maps 也是 Iterable,這表示展開運算子可以將 Maps 轉換為陣列

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

19.2.5 反覆運算 Map 項目

Map 方法 forEach 具有下列簽章

Map.prototype.forEach((value, key, map) => void, thisArg?) : void

第一個參數的簽章反映 Array.prototype.forEach 回呼的簽章,這就是為什麼值會先出現。

const map = new Map([
    [false, 'no'],
    [true,  'yes'],
]);
map.forEach((value, key) => {
    console.log(key, value);
});
// Output:
// false no
// true yes

19.2.6 對 Maps 進行對應和篩選

你可以對陣列進行 map()filter(),但 Maps 沒有這些運算。解決方案是

  1. 將 Map 轉換成 [key,value] 成對的陣列。
  2. 對陣列進行對應或篩選。
  3. 將結果轉換回 Map。

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

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

對應 originalMap

const mappedMap = new Map( // step 3
    [...originalMap] // step 1
    .map(([k, v]) => [k * 2, '_' + v]) // step 2
);
// Resulting Map: {2 => '_a', 4 => '_b', 6 => '_c'}

篩選 originalMap

const filteredMap = new Map( // step 3
    [...originalMap] // step 1
    .filter(([k, v]) => k < 3) // step 2
);
// Resulting Map: {1 => 'a', 2 => 'b'}

步驟 1 由散佈運算子 (...) 執行,我先前已說明過

19.2.7 合併 Map

沒有用於合併 Map 的方法,因此必須使用前一節的方法來執行此操作。

讓我們合併以下兩個 Map

const map1 = new Map()
.set(1, 'a1')
.set(2, 'b1')
.set(3, 'c1');

const map2 = new Map()
.set(2, 'b2')
.set(3, 'c2')
.set(4, 'd2');

若要合併 map1map2,我透過散佈運算子 (...) 將它們轉換成陣列,並串接這些陣列。之後,我將結果轉換回 Map。所有這些都在第一行完成。

> const combinedMap = new Map([...map1, ...map2])
> [...combinedMap] // convert to Array to display
[ [ 1, 'a1' ],
  [ 2, 'b2' ],
  [ 3, 'c2' ],
  [ 4, 'd2' ] ]

19.2.8 透過成對陣列作為 JSON 的任意 Map

如果 Map 包含任意(與 JSON 相容)資料,我們可以透過將其編碼為 key-value 成對(2 元素陣列)的陣列,將其轉換成 JSON。讓我們先探討如何達成此編碼。

19.2.8.1 將 Map 轉換成成對陣列,反之亦然

散佈運算子可讓您將 Map 轉換成成對陣列

> const myMap = new Map().set(true, 7).set({foo: 3}, ['abc']);
> [...myMap]
[ [ true, 7 ], [ { foo: 3 }, [ 'abc' ] ] ]

Map 建構函式可讓您將成對陣列轉換成 Map

> new Map([[true, 7], [{foo: 3}, ['abc']]])
Map {true => 7, Object {foo: 3} => ['abc']}
19.2.8.2 轉換成 JSON 和從 JSON 轉換

讓我們使用此知識將任何具有與 JSON 相容資料的 Map 轉換成 JSON,反之亦然

function mapToJson(map) {
    return JSON.stringify([...map]);
}
function jsonToMap(jsonStr) {
    return new Map(JSON.parse(jsonStr));
}

以下互動示範如何使用這些函式

> const myMap = new Map().set(true, 7).set({foo: 3}, ['abc']);

> mapToJson(myMap)
'[[true,7],[{"foo":3},["abc"]]]'

> jsonToMap('[[true,7],[{"foo":3},["abc"]]]')
Map {true => 7, Object {foo: 3} => ['abc']}

19.2.9 透過物件作為 JSON 的字串 Map

只要 Map 僅有字串作為 key,您就可以透過將其編碼為物件,將其轉換成 JSON。讓我們先探討如何達成此編碼。

19.2.9.1 將字串 Map 轉換成物件,反之亦然

以下兩個函式將字串 Map 轉換成物件,反之亦然

function strMapToObj(strMap) {
    const obj = Object.create(null);
    for (const [k,v] of strMap) {
        // We don’t escape the key '__proto__'
        // which can cause problems on older engines
        obj[k] = v;
    }
    return obj;
}
function objToStrMap(obj) {
    const strMap = new Map();
    for (const k of Object.keys(obj)) {
        strMap.set(k, obj[k]);
    }
    return strMap;
}

讓我們使用這兩個函式

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

> strMapToObj(myMap)
{ yes: true, no: false }

> objToStrMap({yes: true, no: false})
[ [ 'yes', true ], [ 'no', false ] ]
19.2.9.2 轉換為 JSON 和從 JSON 轉換

使用這些輔助函式,轉換為 JSON 的運作方式如下

function strMapToJson(strMap) {
    return JSON.stringify(strMapToObj(strMap));
}
function jsonToStrMap(jsonStr) {
    return objToStrMap(JSON.parse(jsonStr));
}

這是使用這些函式的範例

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

> strMapToJson(myMap)
'{"yes":true,"no":false}'

> jsonToStrMap('{"yes":true,"no":false}');
Map {'yes' => true, 'no' => false}

19.2.10 Map API

建構函式

處理單一項目

處理所有項目

反覆運算和迴圈:會按照項目新增至 Map 的順序進行。

19.3 WeakMap

WeakMaps 的運作方式與 Maps 大致相同,有以下差異

以下各節說明這些差異。

19.3.1 WeakMap 鍵是物件

如果將項目新增到 WeakMap,則鍵必須是物件

const wm = new WeakMap()

wm.set('abc', 123); // TypeError
wm.set({}, 123); // OK

19.3.2 WeakMap 鍵是弱持有

WeakMap 中的鍵是弱持有:一般來說,未由任何儲存位置(變數、屬性等)參照的物件可以被垃圾回收。WeakMap 鍵在此意義上不算是儲存位置。換句話說:物件成為 WeakMap 中的鍵不會阻止物件被垃圾回收。

此外,一旦鍵消失,其項目也會消失(最終會消失,但無法偵測何時消失)。

19.3.3 無法取得 WeakMap 的概觀或清除 WeakMap

無法檢查 WeakMap 的內部結構,以取得其概觀。這包括無法迭代鍵、值或項目。換句話說:要從 WeakMap 中取得內容,需要一個鍵。也無法清除 WeakMap(作為解決方法,可以建立一個全新的執行個體)。

這些限制啟用了安全性屬性。引用 Mark Miller:「只能由同時擁有 WeakMap 和鍵的人觀察或影響 WeakMap/鍵對應值。如果使用 clear(),只有 WeakMap 的人就可以影響 WeakMap 和鍵對應值的對應。」

此外,迭代難以實作,因為必須保證鍵保持弱持有。

19.3.4 WeakMaps 的使用案例

WeakMaps 對於將資料與無法(或不想)控制其生命週期的物件關聯很有用。在本節中,我們來看兩個範例

19.3.4.1 透過 WeakMaps 快取計算結果

透過 WeakMaps,您可以將先前計算的結果與物件關聯,而不用擔心記憶體管理。以下函式 countOwnKeys 就是一個範例:它將先前的結果快取在 WeakMap cache 中。

const cache = new WeakMap();
function countOwnKeys(obj) {
    if (cache.has(obj)) {
        console.log('Cached');
        return cache.get(obj);
    } else {
        console.log('Computed');
        const count = Object.keys(obj).length;
        cache.set(obj, count);
        return count;
    }
}

如果我們對一個物件 obj 使用這個函式,您會看到只有在第一次呼叫時計算結果,而第二次呼叫則使用快取值

> const obj = { foo: 1, bar: 2};
> countOwnKeys(obj)
Computed
2
> countOwnKeys(obj)
Cached
2
19.3.4.2 管理監聽器

假設我們想要附加監聽器到物件,而不改變物件。您可以在物件 obj 中新增監聽器

const obj = {};
addListener(obj, () => console.log('hello'));
addListener(obj, () => console.log('world'));

您也可以觸發監聽器

triggerListeners(obj);

// Output:
// hello
// world

兩個函式 addListener()triggerListeners() 可以實作如下。

const _objToListeners = new WeakMap();

function addListener(obj, listener) {
    if (! _objToListeners.has(obj)) {
        _objToListeners.set(obj, new Set());
    }
    _objToListeners.get(obj).add(listener);
}

function triggerListeners(obj) {
    const listeners = _objToListeners.get(obj);
    if (listeners) {
        for (const listener of listeners) {
            listener();
        }
    }
}

在此使用 WeakMap 的好處是,一旦物件被垃圾回收,其監聽器也會被垃圾回收。換句話說:不會有任何記憶體外洩。

19.3.4.3 透過 WeakMaps 保留私密資料

在以下程式碼中,WeakMaps _counter_action 用於儲存 Countdown 實例的虛擬屬性的資料

const _counter = new WeakMap();
const _action = new WeakMap();
class Countdown {
    constructor(counter, action) {
        _counter.set(this, counter);
        _action.set(this, action);
    }
    dec() {
        let counter = _counter.get(this);
        if (counter < 1) return;
        counter--;
        _counter.set(this, counter);
        if (counter === 0) {
            _action.get(this)();
        }
    }
}

有關此技術的更多資訊,請參閱 類別章節

19.3.5 WeakMap API

WeakMap 的建構函式和四個方法與其 Map 等效項的工作方式相同

new WeakMap(entries? : Iterable<[any,any]>)

WeakMap.prototype.get(key) : any
WeakMap.prototype.set(key, value) : this
WeakMap.prototype.has(key) : boolean
WeakMap.prototype.delete(key) : boolean

19.4 Set

ECMAScript 5 也不具備 Set 資料結構。有兩種可能的解決方法

ECMAScript 6 具有資料結構 Set,它適用於任意值,速度快且正確處理 NaN

19.4.1 基本操作

管理單一元素

> const set = new Set();
> set.add('red')

> set.has('red')
true
> set.delete('red')
true
> set.has('red')
false

確定 Set 的大小並清除它

> const set = new Set();
> set.add('red')
> set.add('green')

> set.size
2
> set.clear();
> set.size
0

19.4.2 設定 Set

您可以透過一個可迭代的 Set 組成元素來設定 Set。例如,透過陣列

const set = new Set(['red', 'green', 'blue']);

或者,add 方法可以串聯

const set = new Set().add('red').add('green').add('blue');
19.4.2.1 陷阱:new Set() 最多只有一個參數

Set 建構函式有 0 或 1 個參數

其他參數將被忽略,可能會導致意外的結果

> Array.from(new Set(['foo', 'bar']))
[ 'foo', 'bar' ]
> Array.from(new Set('foo', 'bar'))
[ 'f', 'o' ]

對於第二個 Set,只有 'foo'(可迭代)用於定義 Set。

19.4.3 比較 Set 元素

與 Map 類似,元素的比較方式類似於 ===,但例外情況是 NaN 與任何其他值都相同。

> const set = new Set([NaN]);
> set.size
1
> set.has(NaN)
true

再次新增元素不會產生任何效果

> const set = new Set();

> set.add('foo');
> set.size
1

> set.add('foo');
> set.size
1

=== 類似,兩個不同的物件永遠不會被視為相等(目前無法自訂,稍後在常見問題解答中會說明

> const set = new Set();

> set.add({});
> set.size
1

> set.add({});
> set.size
2

19.4.4 迭代

Set 是可迭代的,而 for-of 迴圈會如你預期般運作

const set = new Set(['red', 'green', 'blue']);
for (const x of set) {
    console.log(x);
}
// Output:
// red
// green
// blue

如你所見,Set 保留迭代順序。也就是說,元素總是按照插入順序進行迭代。

先前說明的展開運算子 (...) 可用於可迭代項目,因此你可以將 Set 轉換為陣列

const set = new Set(['red', 'green', 'blue']);
const arr = [...set]; // ['red', 'green', 'blue']

現在我們有簡潔的方法可以將陣列轉換為 Set,反之亦然,這可以消除陣列中的重複項目

const arr = [3, 5, 2, 2, 5, 5];
const unique = [...new Set(arr)]; // [3, 5, 2]

19.4.5 對應和篩選

與陣列不同,Set 沒有 map()filter() 方法。解決方法是將它們轉換為陣列,再轉換回來。

對應

const set = new Set([1, 2, 3]);
set = new Set([...set].map(x => x * 2));
// Resulting Set: {2, 4, 6}

篩選

const set = new Set([1, 2, 3, 4, 5]);
set = new Set([...set].filter(x => (x % 2) == 0));
// Resulting Set: {2, 4}

19.4.6 聯集、交集、差集

ECMAScript 6 Set 沒有用於計算聯集(例如 addAll)、交集(例如 retainAll)或差集(例如 removeAll)的方法。本節說明如何解決此限制。

19.4.6.1 聯集

聯集(ab):建立一個包含 Set a 和 Set b 中元素的 Set。

const a = new Set([1,2,3]);
const b = new Set([4,3,2]);
const union = new Set([...a, ...b]);
    // {1,2,3,4}

模式總是相同

展開運算子 (...) 會將可迭代項目(例如 Set)的元素插入陣列中。因此,[...a, ...b] 表示將 ab 轉換為陣列並串接。它等於 [...a].concat([...b])

19.4.6.2 交集

交集 (ab):建立一個集合,包含集合 a 中也存在於集合 b 中的元素。

const a = new Set([1,2,3]);
const b = new Set([4,3,2]);
const intersection = new Set(
    [...a].filter(x => b.has(x)));
    // {2,3}

步驟:將 a 轉換為陣列,篩選元素,將結果轉換為集合。

19.4.6.3 差集

差集 (a \ b):建立一個集合,包含集合 a 中不存在於集合 b 中的元素。此運算有時也稱為減號 (-)。

const a = new Set([1,2,3]);
const b = new Set([4,3,2]);
const difference = new Set(
    [...a].filter(x => !b.has(x)));
    // {1}

19.4.7 集合 API

建構函式

單一集合元素

所有集合元素

迭代和迴圈

Map 的對稱性:以下兩個方法的存在只是為了讓集合的介面與映射的介面類似。每個集合元素都處理為一個映射條目,其鍵和值都是該元素。

entries() 允許您將 Set 轉換為 Map

const set = new Set(['a', 'b', 'c']);
const map = new Map(set.entries());
    // Map { 'a' => 'a', 'b' => 'b', 'c' => 'c' }

19.5 WeakSet

WeakSet 是一個 Set,不會阻止其元素被垃圾回收。請參閱 WeakMap 部分,了解為什麼 WeakSet 不允許迭代、迴圈和清除。

19.5.1 WeakSet 的使用案例

由於您無法迭代其元素,因此 WeakSet 的使用案例並不多。它們確實使您能夠標記物件。

19.5.1.1 標記由工廠函式建立的物件

例如,如果您有一個用於代理的工廠函式,則可以使用 WeakSet 來記錄由該工廠建立的物件

const _proxies = new WeakSet();

function createProxy(obj) {
    const proxy = ···;
    _proxies.add(proxy);
    return proxy;
}

function isProxy(obj) {
    return _proxies.has(obj);
}

完整的範例顯示在 代理章節 中。

_proxies 必須是 WeakSet,因為一旦不再參考常規 Set,它就會阻止代理被垃圾回收。

19.5.1.2 標記物件以安全地與方法搭配使用

Domenic Denicola 說明 Foo 類別如何確保其方法僅套用於由它建立的執行個體

const foos = new WeakSet();

class Foo {
    constructor() {
        foos.add(this);
    }

    method() {
        if (!foos.has(this)) {
            throw new TypeError('Incompatible object!');
        }
    }
}

19.5.2 WeakSet API

WeakSet 的建構函式和三個方法與其 Set 等效項的工作方式相同

new WeakSet(elements? : Iterable<any>)

WeakSet.prototype.add(value)
WeakSet.prototype.has(value)
WeakSet.prototype.delete(value)

19.6 常見問題:Map 和 Set

19.6.1 為什麼 Map 和 Set 具有屬性 size 而不是 length

陣列具有屬性 length 來計算條目的數量。Map 和 Set 有不同的屬性 size

造成此差異的原因是 length 適用於序列,也就是可編索引的資料結構,例如陣列。size 適用於主要為未排序的集合,例如映射和集合。

19.6.2 為何我無法設定映射和集合比較鍵和值的規則?

如果有一種方法可以設定映射鍵和集合元素的相等條件,這將會很方便。但此功能已被延後,因為要正確且有效率地實作這項功能很困難。

19.6.3 是否有方法在從映射中取得資料時指定預設值?

如果您使用鍵從映射中取得資料,偶爾會想要指定一個預設值,以便在映射中找不到鍵時傳回。ES6 映射不允許您直接執行此操作。但您可以使用 Or 運算子 (||),如下列程式碼所示。countChars 傳回一個映射,將字元對應到出現次數。

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

在 A 行中,如果 ch 不在 charCounts 中,且 get() 傳回 undefined,則會使用預設值 0

19.6.4 我應該在什麼時候使用映射,什麼時候使用物件?

如果您要將字串以外的任何內容對應到任何類型的資料,您別無選擇:您必須使用映射。

然而,如果您要將字串對應到任意資料,您必須決定是否要使用物件。一個粗略的通用準則為

19.6.5 我什麼時候會在映射中使用物件作為鍵?

映射鍵主要在透過值進行比較時才有意義(相同的「內容」表示兩個值被視為相等,而不是相同的識別碼)。這排除了物件。有一個使用案例:將資料外部附加到物件,但這個使用案例更適合使用弱映射,其中在鍵消失時,條目也會消失。

下一篇:20. 型化陣列