原文链接:http://javascript.info/map-set-weakmap-weakset,translate with ❤️ by zhangbao.
到现在为止,我们已经学习下列复杂的数据结构:
用来存储键名集合的对象。
用来存储有序集合的数组。
但在现实使用中还不够。这就是为什么 Map 和 Set 存在的原因。
Map
Map 用于存储键名数据项集合,类似于 Object。不同的是,Map 的键名允许使用任意类型。
主要方法如下:
new Map():创建一个 map 实例。
map.set(key, value):存储键值。
map.get(key):返回指定键名的键值,如果 key 不存在,就返回 undefiend。
map.has(key):如果 key 存在,就返回 true;否则返回 false。
map.delete(key):删除指定的键。
map.clear():清除 map。
map.size:返回 map 中存储的元素数量。
例如:
let map = new Map();
map.set('1', 'str1'); // 字符串键名
map.set(1, 'num1'); // 数值键名
map.set(true, 'bool1'); // 布尔键名
// 还记得普通对象吗?它会将所有的 key 都转换成字符串的
// 而 Map 会保持键名的原始类型们,所有下面两个是取不同键名所对应的值的
alert( map.get(1) ); // 'num1'
alert( map.get('1') ); // 'str1'
alert( map.size ); // 3
我们看到,不像对象,这里的键名不会转换为字符串。任何类型的键名都是可能的。
Map 也可以使用对象作为键名。
例如:
let john = { name: "John" };
// 这个用来存储每个用户的访问数量
let visitsCountMap = new Map();
// john 是作为 map 的 key
visitsCountMap.set(john, 123);
alert( visitsCountMap.get(john) ); // 123
可以使用对象作为键名是 Map 最为醒目和重要的特性。Object 能存储字符串 key,OK 没问题,但是它没法存储对象类型的键名。
在远古时代,Map 出现之前。大家以下面的方式添加唯一标识:
// 我们会添加一个 id 字段
let john = { name: "John", id: 1 };
let visitsCounts = {};
// 用用户 id 来标识值
visitsCounts[john.id] = 123;
alert( visitsCounts[john.id] ); // 123
……但使用 Map 的方式更加优雅。
⚠️Map 是怎样比较键值的
在比较两个值是否相等时,Map 使用的算法是 SameValueZero。它几乎等同于严格相等运算符 ===,但不同点是 NaN 会被判定为等于 NaN 本身。因此 NaN 也可以作为键名使用。
⚠️链式调用
map.set 方法 map 对象本身,因此可以使用链式调用:
map.set('1', 'str1')
.set(1, 'num1')
.set(true, 'bool1');
用一个对象创建 Map
使用 Map 构造函数创建实例时,可以为构造函数传递一个数组,数组元素是以键-值对形式表达的数组。像这样:
// [key, value] 对形式的数组
let map = new Map([
['1', 'str1'],
[1, 'num1'],
[true, 'bool1']
]);
内置方法 Object.entries(obj) 方法以这种键-值对数组的形式精确返回一个对象的表示形式:
因此,我们用一个对象来初始化一个 Map 实例:
let map = new Map(Object.entries({
name: "John",
age: 30
}));
在这里,Object.entries 会对象这样形式的键值对表示:[ [‘name’, ‘John’], [‘age’, 30] ]。这正是 Map 需要的。
遍历 Map
遍历 Map,一共有 3 中方式:
map.keys():返回键名集合迭代对象,
map.values():返回键值集合迭代对象,
map.entries():返回由 [key, value] 元素形式组成的迭代对象。是 Map 默认使用的遍历器生成函数。
例如:
let recipeMap = new Map([
['cucumber', 500],
['tomatoes', 350],
['onion', 50]
]);
// 遍历键名
for (let vegetable of recipeMap.keys()) {
alert(vegetable); // cucumber, tomatoes, onion
}
// 遍历键值
for (let amount of recipeMap.values()) {
alert(amount); // 500, 350, 50
}
// 以 [key, value] 形式遍历
for (let entry of recipeMap) { // 等同于 recipeMap.entries()
alert(entry); // cucumber,500 (and so on)
}
⚠️会使用插入顺序
遍历顺序会按照插入顺序列出,Map 保留了这个顺序,而不像普通 Object。
除此之外,Map 还提供了内置的 forEach 方法,类似于 Array:
recipeMap.forEach( (value, key, map) => {
alert(`${key}: ${value}`); // cucumber: 500 等等
});
Set
Set 是一系列值的集合,而且每个值只能出现一次。
主要方法如下:
new Set(iterable):创建 Set 实例,可以从一个数组里创建(任何可迭代对象都 OK)。
set.add(value):添加一个值,返回 Set 实例本身。
set.has(value):如果值在 Set 里存在,就返回 true,否则返回 false。
set.delete(value):删除一个值,删除成功返回 true(值存在),否则返回 false。
set.clear():清空 Set 实例中的值。
set.sizse:返回 Set 实例中的元素数量。
Set 集合里比较两个值是否相等的算法使用的也是 SameValueZero。
let set = new Set();
let john = { name: "John" };
let pete = { name: "Pete" };
let mary = { name: "Mary" };
// visits, some users come multiple times
set.add(john);
set.add(pete);
set.add(mary);
set.add(john);
set.add(mary);
// set keeps only unique values
alert( set.size ); // 3
for (let user of set) {
alert(user.name); // John (then Pete and Mary)
}
设置的替代方案可以是一个存储用户列表的数组,加上使用 arr.find 方法在每次插入时检查是否存在。但是性能会更糟糕,因为这个方法遍历整个数组,检查每个元素。Set 在内部针对唯一性检查做了优化。
遍历 Set
我们可以循环遍历 Set 使用 for..of 或者 forEach:
let set = new Set(["oranges", "apples", "bananas"]);
for (let value of set) alert(value);
// 等同于 forEach:
set.forEach((value, valueAgain, set) => {
alert(value);
});
注意有一件有趣的事情,就是 Set 实例的 forEach 方法的回调函数接受 3 个参数:值,值(对,没错)和目标对象。实际上,相同的值出现在两次参数中。
为了达到与 Map 数据结构的兼容,forEach 提供了 3 个参数:
Map 支持的迭代器方法在这里也受到支持:
set.values():跟 set.keys() 返回结果一样。
set.keys():返回由值组成的迭代器,为了兼容 Map。
set.entries():返回由 [value, value] 实体组成的可迭代对象,存在是为了兼容 Map。
WeakMap 和 WeakSet
WeakSet 是一类特殊的 Set,不会阻止 JavaScript 将其从内存中删除。WeakMap 和 Map 关系与此一样。
从《垃圾收集》一章里,我们看到,JavaScript引擎在内存中存储一个值,而它是可访问的(并且可能会被使用)。
例如:
let john = { name: "John" };
// 对象可以被访问,因为 John 这个引用还在
// 重写引用
john = null;
// 对象就会从内存中删除了
通常,当数据结构(对象属性、数组元素或其他数据结构的元素)是可访问的时候,就会保存在内存中。
在普通 Map 结构中,键名可以使用任意类型。即使没有更多的引用,它也被保存在内存中。
例如:
let john = { name: "John" };
let map = new Map();
map.set(john, "...");
john = null; // 重写引用
// john 被存储在 map 中,我们可以使用
// map.keys() 取得它
WeakMap/WeakSet 就不这样。
WeakMap/WeakSet 不会阻止对象从内存中删除。
我们先从 WeakMap 讨论。
与 Map 第一点不同的就是键名只能是对象类型的,而不能是原始类型的:
let weakMap = new WeakMap();
let obj = {};
weakMap.set(obj, "ok"); // works fine (object key)
weakMap.set("test", "Whoops"); // Error, because "test" is a primitive
现在我们使用对象作为键名,并且之后引用也置为空。结果是,这个对象会从内存中删除。
let john = { name: "John" };
let weakMap = new WeakMap();
weakMap.set(john, "...");
john = null; // 重写引用
// john 从内存中删除了
对比上面的 Map 例子,现在 john 只存在于 WeakMap 中——它会被自动删除的。
……当且,WeakMap 不支持 keys(),values() 和 entries() 方法,我们也不能遍历它。所以我们不能获取所有的 key 或者 value。
WeakMap 仅能使用以下的方法:
weakMap.get(key)
weakMap.set(key, value)
weakMap.has(key)
weakMap.delete(key)
为什么会有这样的限制呢?这是因为技术原因。如果对象丢失了所有其他引用(像上面的 john),会被自动删除。但是技术上并没有明确指明何时发生清理操作。
由 JavaScript 引擎决定。它可能会选择立即执行内存清理,或者等待指导更多删除发生时,才进行清理。所以,从技术上讲,WeakMap 的当前元素计数是未知的。引擎可能已经把它清理干净了,或者部分地清理了。因为这个原因,WeakMap 没有提供访问方法。
现在,哪里会需要这样的数据结构呢?
使用 WeakMap 的地方:只存储存在的对象。如果对象不再被引用了,WeakMap 中的引用不会算数,对象会被垃圾收集器清除。
weakMap.put(john, "secret documents");
// john 不再被引用, secret documents 也被销毁了
这对于我们在某个地方有一个主存储空间的情况很有用,并且需要保留那些只在对象生活时才相关的附加信息。
我们看个例子。
例如,我们有为每个用户提供访问计数的代码。信息存储在 Map 中:用户对象作为键名,访问数是键值。当用户离开时,我们不想再存储他的访问次数了。
一种方法是跟踪用户的离开,并手动清理存储空间:
let john = { name: "John" };
// map: user => visits count
let visitsCountMap = new Map();
// john is the key for the map
visitsCountMap.set(john, 123);
// now john leaves us, we don't need him anymore
john = null;
// but it's still in the map, we need to clean it!
alert( visitsCountMap.size ); // 1
// it's also in the memory, because Map uses it as the key
另一种方法是使用 WeakMap:
let john = { name: "John" };
let visitsCountMap = new WeakMap();
visitsCountMap.set(john, 123);
// now john leaves us, we don't need him anymore
john = null;
// there are no references except WeakMap,
// so the object is removed both from the memory and from visitsCountMap automatically
使用常规 Map,在用户离开后进行清理工作将成为一项单调乏味的任务:我们不仅需要将用户从主存储器中删除(无论是变量还是数组),而且还需要清理像 visitsCountMap 这样的额外存储。在更复杂的情况下,当用户在代码的一个地方进行管理时,它会变得很麻烦,而额外的结构在另一个地方,并且没有关于删除的信息。
WeakMap 可以使事情变得更简单,因为它是自动清理的。在上面的例子中,像访问计数这样的信息只有在关键对象存在的时候才会存在。
WeakSet 的行为于此类似:
它类似于 Set,但是我们只能添加对象(不能是原始类型值)。
在 Set 中的对象,也可以从其他地方访问到。
类似 Set,仅支持 add,has,delete,但不支持 size,keys() 等迭代方法。
例如,我们可以使用它来跟踪检查一个对象是否被检查:
let messages = [
{text: "Hello", from: "John"},
{text: "How goes?", from: "John"},
{text: "See you soon", from: "Alice"}
];
// fill it with array elements (3 items)
let unreadSet = new WeakSet(messages);
// we can use unreadSet to see whether a message is unread
alert(unreadSet.has(messages[1])); // true
// remove it from the set after reading
unreadSet.delete(messages[1]); // true
// and when we shift our messages history, the set is cleaned up automatically
messages.shift();
// no need to clean unreadSet, it now has 2 items
// unfortunately, there's no method to get the exact count of items, so can't show it
WeakMap 和 WeakSet 最显著的限制是不提供迭代方法,以及无法获得所有当前内容。这可能看起来很不方便,但实际上并不能阻止 WeakMap/WeakSet 完成它们的主要工作——为在另一个地方存储/管理的对象提供一个“额外”的数据存储。
总结
- Map:是键值对的集合。
不同于普通 Object 的地方是:
键名可以是任意类型,就是说对象也 OK。
迭代顺序就是插入顺序。
额外的便捷方法和 size 属性。
- Set:存储唯一值的集合。
不像数组,不能够重新排序元素。
保持插入顺序。
WeakMap:Map 类型的一个变体,只允许对象类型的键名。一旦包含对象不再引用,就会从集合中自动清除了。
它不支持对整个结构的操作:没有 size,clear(),没有迭代方法。
- WeakSet:Set 类型的一个变体,只允许对象类型的键名。一旦包含对象不再引用,就会从集合中自动清除了。
- 同样不支持 size/clear() 和迭代方法。
WeakMap 和 WeakSet 可以被看做是除“主”对象存储之外的“第二”数据结构。一旦对象从主存储中删除了,那么 WeakMap/WeakSet 集合中也 hold 不住这个对象了,对象会自动从内存中删除。
(完)