写给不耐烦程序员的 JavaScript(ES2022 版)
请支持本书:购买捐赠
(广告,请勿屏蔽。)

33 映射 (Map)



在 ES6 之前,JavaScript 没有用于字典的数据结构,并且(滥用)对象作为从字符串到任意值的字典。ES6 带来了映射,它是从任意值到任意值的字典。

33.1 使用映射

Map 的实例将键映射到值。单个键值映射称为_条目_。

33.1.1 创建映射

创建映射有三种常用方法。

首先,您可以使用不带任何参数的构造函数来创建一个空映射

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

其次,您可以将键值“对”(包含两个元素的数组)的可迭代对象(例如,数组)传递给构造函数

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

33.1.2 复制映射

正如我们稍后将看到的,映射也是键值对的可迭代对象。因此,您可以使用构造函数来创建映射的副本。该副本是_浅_副本:键和值相同;它们不会被复制。

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() 检查映射是否具有给定键的条目。 .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 确定映射的大小并清空它

.size 包含映射中的条目数。 .clear() 删除映射的所有条目。

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 获取映射的键和值

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

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 获取映射的条目

.entries() 返回映射条目的可迭代对象

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 的键和值

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

33.1.7 按插入顺序排列:条目、键、值

映射记录创建条目的顺序,并在列出条目、键或值时遵循该顺序

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 在映射和对象之间转换

只要映射仅使用字符串和符号作为键,您就可以将其转换为对象(通过 Object.fromEntries()

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

您还可以将具有字符串或符号键的对象转换为映射(通过 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() 返回一个映射,该映射将字符映射到出现次数。

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 关于映射键的更多细节(高级)

任何值都可以是键,甚至是对象

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 哪些键被认为是相等的?

大多数映射操作都需要检查值是否等于其中一个键。它们通过内部操作 SameValueZero 来实现,该操作的工作方式与 === 类似,但将 NaN 视为与其自身相等。

因此,您可以在映射中使用 NaN 作为键,就像任何其他值一样

> const map = new Map();

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

不同的对象始终被认为是不同的。这是无法更改的(目前还不能 - 配置键相等性在 TC39 的长期路线图上)。

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

33.4 缺失的映射操作

33.4.1 映射和过滤映射

您可以对数组进行 .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
  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 合并映射

没有用于合并映射的方法,这就是为什么我们必须使用与上一节类似的解决方法。

让我们合并以下两个映射

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 的条目(键值对)扩展(...)到其中(通过迭代)。然后我们将数组转换回映射。所有这些都在 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' ] ]
);

  练习:合并两个映射

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:迭代和循环

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

33.5.5 本节资料来源

33.6 常见问题解答:映射

33.6.1 我应该何时使用映射,何时使用对象?

如果您需要一个字典式的数据结构,其键既不是字符串也不是符号,那么您别无选择:您必须使用映射。

但是,如果您的键是字符串或符号,则必须决定是否使用对象。一个粗略的通用准则是

33.6.2 我什么时候会在映射中使用对象作为键?

您通常希望通过值比较映射键(如果两个键具有相同的内容,则认为它们相等)。这排除了对象。但是,有一种情况下可以使用对象作为键:将数据从外部附加到对象。但是,弱映射可以更好地满足这种用例,在弱映射中,条目不会阻止对键进行垃圾回收(有关详细信息,请参阅下一章)。

33.6.3 为什么映射要保留条目的插入顺序?

原则上,映射是无序的。对条目进行排序的主要原因是为了使列出条目、键或值的操作具有确定性。例如,这有助于测试。

33.6.4 为什么映射使用 .size 表示大小,而数组使用 .length

在 JavaScript 中,可索引序列(例如数组和字符串)具有 .length 属性,而未索引集合(例如映射和集合)具有 .size 属性

  测验

参见 测验应用程序