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 作为键

> 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 迭代映射

让我们设置一个映射来演示如何对其进行迭代。

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

映射记录插入元素的顺序,并在迭代键、值或条目时遵循该顺序。

19.2.4.1 键和值的可迭代对象

keys() 返回映射中键的可迭代对象

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

values() 返回映射中值的可迭代对象

for (const value of map.values()) {
    console.log(value);
}
// Output:
// no
// yes
19.2.4.2 条目的可迭代对象

entries() 将映射的条目作为 [键,值] 对(数组)的可迭代对象返回。

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);
}

迭代映射的默认方式是 entries()

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

因此,您可以使前面的代码段更短

for (const [key, value] of map) {
    console.log(key, value);
}
19.2.4.3 将可迭代对象(包括映射)转换为数组

展开运算符 (...) 可以将可迭代对象转换为数组。这使我们能够将 Map.prototype.keys() 的结果(一个可迭代对象)转换为数组

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

映射也是可迭代的,这意味着展开运算符可以将映射转换为数组

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

19.2.5 循环遍历映射条目

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 映射和过滤映射

您可以 map()filter() 数组,但映射没有这样的操作。解决方案是

  1. 将映射转换为 [键,值] 对的数组。
  2. 映射或过滤数组。
  3. 将结果转换回映射。

我将使用以下映射来演示它是如何工作的。

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 合并映射

没有用于合并映射的方法,这就是为什么必须使用上一节中的方法来实现的原因。

让我们合并以下两个映射

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,我通过展开运算符 (...) 将它们转换为数组,并将这些数组连接起来。之后,我将结果转换回映射。所有这些都在第一行完成。

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

19.2.8 通过键值对数组将任意映射转换为 JSON

如果映射包含任意(JSON 兼容)数据,我们可以通过将其编码为键值对数组(2 元素数组)将其转换为 JSON。让我们首先研究如何实现这种编码。

19.2.8.1 在映射和键值对数组之间转换

展开运算符 允许您将映射转换为键值对数组

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

Map 构造函数允许您将键值对数组转换为映射

> new Map([[true, 7], [{foo: 3}, ['abc']]])
Map {true => 7, Object {foo: 3} => ['abc']}
19.2.8.2 与 JSON 的转换

让我们使用这些知识将任何包含 JSON 兼容数据的映射转换为 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

只要映射仅包含字符串作为键,就可以通过将其编码为对象将其转换为 JSON。让我们首先研究如何实现这种编码。

19.2.9.1 在字符串映射和对象之间转换

以下两个函数在字符串映射和对象之间转换

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 的工作方式如下

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 映射 API

构造函数

处理单个条目

处理所有条目

**迭代和循环:** 按将条目添加到映射中的顺序进行。

19.3 弱映射

弱映射的工作方式与映射基本相同,但有以下区别

以下各节将解释这些差异。

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(作为一种解决方法,您可以创建一个全新的实例)。

这些限制实现了一种安全属性。引用 Mark Miller 的话:“只有同时拥有 WeakMap 和键的人才能观察或影响从 WeakMap/键对到值的映射。使用 clear(),只有 WeakMap 的人就可以影响 WeakMap 和键到值的映射。”

此外,迭代将难以实现,因为您必须保证键保持弱引用。

19.3.4 WeakMap 的用例

WeakMap 对于将数据与您无法(或不想)控制其生命周期的对象相关联非常有用。在本节中,我们将看两个例子

19.3.4.1 通过 WeakMap 缓存计算结果

使用 WeakMap,您可以将先前计算的结果与对象相关联,而不必担心内存管理。以下函数 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 通过 WeakMap 保存私有数据

在以下代码中,WeakMap _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 构造函数有零个或一个参数

其他参数将被忽略,这可能会导致意外结果

> 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):创建一个 Set,其中包含 Set a 中也存在于 Set 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 转换为数组,过滤元素,将结果转换为 Set。

19.4.6.3 差集

差集 (a \ b):创建一个 Set,其中包含 Set a 中不存在于 Set 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 Set API

构造函数

单个 Set 元素

所有 Set 元素

迭代和循环

Map 的对称性:以下两种方法仅存在是为了使 Set 的接口类似于 Map 的接口。每个 Set 元素的处理方式就好像它是一个 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,它不会阻止其元素被垃圾回收。有关 WeakSet 不允许迭代、循环和清除的原因,请参阅有关 WeakMap 的部分。

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 我什么时候会在映射中使用对象作为键?

如果映射键是按值比较的(相同的“内容”意味着两个值被认为是相等的,而不是相同的身份),那么它们才有意义。这排除了对象。有一种用例——将数据外部附加到对象,但这种用例最好由 WeakMap 来处理,在 WeakMap 中,当键消失时,条目也会消失。

下一页:20. 类型化数组