ES6 的 Map Set WeakMap 和 WeakSet

Clloz · · 71次浏览 ·

前言

本文讲一讲 ES6 新增的两种数据结构,SetMap 以及和它们相对应的 WeakSetWeakMapMapSet 是两种非常中要的数据结构,在其他语言中都是一种预设机制。JavaScriptES6 中也引入了这种数据结构。它们都有对应的数学概念,Map 表示的是映射,而 Set 表示的是集合,其实理清这两个概念也就大致了解了 MapSet 是什么样的结构了,Map 是一种双射形式的映射,集合的概念就不同多接受了,三大特性:确定性,互异性,无序性。

ES6 之前,我们能使用的主要数据结构就是:

  • 存储带键的数据(keyed)集合的对象。
  • 存储有序集合的数组。

其实在底层的实现中,它们都是 Map 这种数据结构。下面我们来讲一讲这个新的 MapSet 具体有哪些不同。

Set

Set 对象允许你存储任何类型的唯一值,无论是原始值或者是对象引用。Set 对象是值的集合,你可以按照插入的顺序迭代它的元素。 Set 中的元素只会出现一次,即 Set 中的元素是唯一的。

创建 Set 对象的方法是 new Set([iterable])new 是必须的,Set 构造函数可以接受一个数组(或者具有 iterable 接口的其他数据结构)作为参数, 用来初始化,它的所有元素将不重复地被添加到新的 Set 中。如果不指定此参数或其值为 null,则新的 Set 为空。

Set 加入值的时候,不会发生类型转换,所以 5"5" 是两个不同的值。Set 内部判断两个值是否不同,使用的算法叫做 Same-value equality,它类似于精确相等运算符( === ),主要的区别是 NaN 等于自身,而精确相等运算符认
NaN 不等于自身。

Set 结构的实例有以下属性。

  • Set.prototype.constructor:构造函数,默认就是 Set 函数。
  • Set.prototype.size :返回实例的成员总数。
  • add(value):添加某个值,返回 Set 结构本身。支持链式添加
  • delete(value):删除某个值,返回一个布尔值,表示删除是否成功。
  • has(value):返回一个布尔值,表示该值是否为 Set 的成员。
  • clear():清除所有成员,没有返回值。
  • keys():返回键名的遍历器
  • value():返回键值的遍历器
  • entires():返回键值对的遍历器
  • forEach():使用回调函数遍历每个成员

Array.from 方法可以将 Set 结构转为数组。去除数组的重复成员 [...new Set(array)].

Set 的遍历顺序就是插入顺序。这个特性有时非常有用,比如使用 Set 保存一个回调函数列表,调用时就能保证按照添加顺序调用。

keys 方法、 values 方法、 entries 方法返回的都是遍历器对象。由于 Set 结构没有键名,只有键值(或者说键名和键值 是同一个值),所以 keys 方法和 values 方法的行为完全一致。Set 结构的实例默认可遍历,它的默认遍历器生成函数就是它的 values 方法。

Set 结构的实例的 forEach 方法,用于对每个成员执行某种操作,没有返回值。 forEach 方法的参数就是一个处理函数。该函数的参数依次为键 值、键名、集合本身。另外, forEach 方法还可以有第二个参数,表示绑定的 this 对象。

let set = new Set([1, 2, 3]);
set.forEach((value, key) => console.log(value * 2) ) // 2
// 4
// 6

扩展运算符,数组的 mapfilter 都可以用于 Set

如果想在遍历操作中,同步改变原来的 Set 结构,目前没有直接的方法,但有两种 变通方法。一种是利用原 Set 结构映射出一个新的结构,然后赋值给原来的 Set 结 构;另一种是利用 Array.from 方法。

Set 转数组可以用 Array.from() 和 扩展运算符。

WeakSet

WeakSet 结构与 Set 类似,也是不重复的值的集合。但是,它与 Set 有两个区 别。

  • WeakSet 的成员只能是对象,而不能是其他类型的值,null 也不是一个合法的值。
  • WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象 的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。这也意味着 WeakSet 中没有存储当前对象的列表。 正因为这样,WeakSet 是不可枚举的(没有方法能给出所有的值)。

因此,WeakSet 适合临时存放一组对象,以及存放跟对象绑定的信息。只要这些对象在外部消失,它在 WeakSet 里面的引用就会自动消失。由于上面这个特点,WeakSet 的成员是不适合引用的,因为它会随时消失。另外, 由于 WeakSet 内部有多少个成员,取决于垃圾回收机制有没有运行,运行前后很可能成员个数是不一样的,而垃圾回收机制何时运行是不可预测的,因此 ES6 规定 WeakSet 不可遍历。

创建一个 WeakMap 使用 new WeakMap(value)new 不可以省略。如果传入一个可迭代对象作为参数, 则该对象的所有迭代值都会被自动添加进生成的 WeakSet 对象中。null 会被当做 undefined,即和没有传入参数一样,创建一个空的 WeakSet

WeakSet 结构有以下三个方法。

  • WeakSet.prototype.add(value):向 WeakSet 实例添加一个新成员。
  • WeakSet.prototype.delete(value):清除 WeakSet 实例的指定成员。
  • WeakSet.prototype.has(value):返回一个布尔值,表示某个值是否在 WeakSet 实例之中。

WeakMap 一个简单用法:

const foos = new WeakSet()
class Foo {
    constructor() { 
        foos.add(this)
    }
    method () {
        if (!foos.has(this)) {
            throw new TypeError('Foo.prototype.method 只能在Foo的实例上调用!');
        }
    }
}

WeakSet 的一个应用是可以用来检测对象的循环引用。

// 对 传入的subject对象 内部存储的所有内容执行回调
function execRecursively(fn, subject, _refs = null) {
    if (!_refs) _refs = new WeakSet();

    // 避免无限递归
    if (_refs.has(subject)) return;

    fn(subject);
    if ('object' === typeof subject) {
        _refs.add(subject);
        for (let key in subject) execRecursively(fn, subject[key], _refs);
    }
}

const foo = {
    foo: 'Foo',
    bar: {
        bar: 'Bar',
    },
};

foo.bar.baz = foo; // 循环引用!
execRecursively(obj => console.log(obj), foo);
// { foo: 'Foo', bar: { bar: 'Bar', baz: [Circular] } }
// Foo
// { bar: 'Bar', baz: { foo: 'Foo', bar: [Circular] } }
// Bar

Map

JavaScript 的对象(Object),本质上是键值对的集合(Hash 结构),但是传统上只能用字符串(现在加入了 Symbol)当作键。这给它的使用带来了很大的限制。为了解决这个问题,ES6 提供了 Map 数据结构。它类似于对象,也是键值对的集合,但是“键”的范围不限于字符串或者 Symbol,各种类型的值(包括对象)都可以当作键。也就是说,Object 结构提供了“字符串为 key 的映射,Map 结构提供了任意值为 key 的映射,是一种更完善的 Hash 结构实现。如果你需要键值对的数据结构,MapObject 更合适。Map 对象能够记住键的原始插入顺序。

MapObject 的区别如下:

Map Object
意外的键 Map 默认情况不包含任何键。只包含显式插入的键。 一个 Object 有一个原型, 原型链上的键名有可能和你自己在对象上的设置的键名产生冲突。(也可以使用 Object.create(null) 创建一个没有原型的对象
键的类型 一个 Map 的键可以是任意值,包括函数、对象或任意基本类型。 一个 Object 的键必须是一个 String 或是Symbol
键的顺序 Map 中的 key 是有序的。因此,当迭代的时候,一个 Map 对象以插入的顺序返回键值。 ECMAScript 2015 规范以来,对象保留了字符串和 Symbol 键的创建顺序; 因此,在只有字符串键的对象上进行迭代将按插入顺序产生键。
Size Map 的键值对个数可以轻易地通过 size 属性获取 Object 的键值对个数只能手动计算
迭代 Mapiterable 的,所以可以直接被迭代。 迭代一个 Object 需要以某种方式获取它的键然后才能迭代。
性能 在频繁增删键值对的场景下表现更好。 在频繁添加和删除键值对的场景下未作出优化。

Map 的键实际上是跟内存地址绑定的,只要内存地址不一样,就视为两个键。这就解决了同名属性碰撞(clash)的问题,我们扩展别人的库的时候,如果使用对象作为键名,就不用担心自己的属性与原作者的属性同名。

如果 Map 的键是一个简单类型的值(数字、字符串、布尔值),则只要两个值严格相等,Map 将其视为一个键,比如 0-0 就是一个键,布尔值 true 和字符串 'true' 则是两个不同的键。另外, undefinednull 也是两个不同的键。虽然 NaN 不严格相等于自身,但 Map 将其视为同一个键。

Map 的属性和方法:

  • size:返回 Map 结构的成员总数。
  • set(key, value):设置键名 key 对应的键值为 value ,然后返回整个 Map 结构。如 果 已经有值,则键值会被更新,否则就新生成该键。
  • get(key):读取 key 对应的键值,如果找不到 key ,返回 undefined 。
  • has(key):返回一个布尔值,表示某个键是否在当前 Map 对象之中。
  • delete(key):删除某个键,返回 true 。如果删除失败,返回 false 。
  • clear():清除所有成员,没有返回值。
  • key():返回键名的遍历器。
  • value():返回键值的遍历器。
  • entries():返回所有成员的遍历器。
  • forEach():遍历 Map 的所有成员。

Map 的遍历顺序就是插入顺序。map[Symbol.iterator] === map.entries, Map 结构的默认遍历器接口 ( Symbol.iterator属性),就是 entries 方法。一个 Map 对象在迭代时会根据对象中元素的插入顺序来进行, 一个 for...of 循环在每次迭代后会返回一个形式为 [key,value] 的数组。

Map 结构转为数组结构,比较快速的方法是使用扩展运算符。结合数组的 map 方法、filter 方法,可以实现 Map 的遍历和过滤(Map 本身没有 mapfilter 方法)。

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']
// }

如果所有 Map 的键都是字符串,它可以转为对象。

function strMapToObj(strMap) {
    let obj = Object.create(null);
    for (let [k, v] of strMap) {
        obj[k] = v;
    }
    return obj;
}
const myMap = new Map().set('yes', true).set('no', false);
strMapToObj(myMap);
// { yes: true, no: false }

Map 转为 JSON 要区分两种情况。一种情况是,Map 的键名都是字符串,这时可以选择转为对象 JSON

function strMapToJson(strMap) {
    return JSON.stringify(strMapToObj(strMap));
}
let myMap = new Map().set('yes', true).set('no', false); 
strMapToJson(myMap)
// '{"yes":true,"no":false}'

另一种情况是,Map 的键名有非字符串,这时可以选择转为数组 JSON

function mapToArrayJson(map) { 
    return JSON.stringify([...map]);
}
let myMap = new Map().set(true, 7).set({foo: 3}, ['abc']); 
mapToArrayJson(myMap)
// '[[true,7],[{"foo":3},["abc"]]]'

Map 的迭代:

let myMap = new Map();
myMap.set(0, "zero");
myMap.set(1, "one");
for (let [key, value] of myMap) { //解构
  console.log(key + " = " + value);
}
// 将会显示两个log。一个是"0 = zero"另一个是"1 = one"

for (let key of myMap.keys()) {
  console.log(key);
}
// 将会显示两个log。 一个是 "0" 另一个是 "1"

for (let value of myMap.values()) {
  console.log(value);
}
// 将会显示两个log。 一个是 "zero" 另一个是 "one"

for (let [key, value] of myMap.entries()) {
  console.log(key + " = " + value);
}
// 将会显示两个log。 一个是 "0 = zero" 另一个是 "1 = one"

myMap.forEach(function(value, key) {
  console.log(key + " = " + value);
})
// 将会显示两个logs。 一个是 "0 = zero" 另一个是 "1 = one"

合并 Map:

let original = new Map([
  [1, 'one']
]);

let clone = new Map(original);

console.log(clone.get(1)); // one
console.log(original === clone); // false. 浅比较 不为同一个对象的引用

//合并的 Map 中存在重复的键名,后面的会覆盖前者
let first = new Map([
  [1, 'one'],
  [2, 'two'],
  [3, 'three'],
]);

let second = new Map([
  [1, 'uno'],
  [2, 'dos']
]);

// 合并两个Map对象时,如果有重复的键值,则后面的会覆盖前面的。
// 展开运算符本质上是将Map对象转换成数组。
let merged = new Map([...first, ...second]);

console.log(merged.get(1)); // uno
console.log(merged.get(2)); // dos
console.log(merged.get(3)); // three

//也可以与数组合并
let first = new Map([
  [1, 'one'],
  [2, 'two'],
  [3, 'three'],
]);

let second = new Map([
  [1, 'uno'],
  [2, 'dos']
]);

// Map对象同数组进行合并时,如果有重复的键值,则后面的会覆盖前面的。
let merged = new Map([...first, ...second, [1, 'eins']]);

console.log(merged.get(1)); // eins
console.log(merged.get(2)); // dos
console.log(merged.get(3)); // three

WeakMap

WeakMap 结构与 Map 结构类似,也是用于生成键值对的集合,其中的键是弱引用的。WeakMapMap 的区别有两点。首先, WeakMap 只接受对象作为键名( null 除外),不接受其他类型的值作为键名。其次,WeakMap 的键名所指向的对象,不计入垃圾回收机制。WeakMapkey 是不可枚举的 (没有方法能给出所有的 key)。

创建 WeakMap 的语法 new WeakMap([iterable])Iterable 是一个数组(二元数组)或者其他可迭代的且其元素是键值对的对象。每个键值对会被加到新的 WeakMap 里。null 会被当做 undefined,即和没有传入参数一样,创建一个空的 WeakMap

WeakMap 的设计目的在于,有时我们想在某个对象上面存放一些数据,但是这会形成对于这个对象的引用。请看下面的例子。

const e1 = document.getElementById('foo');
const e2 = document.getElementById('bar');
const arr = [[e1, 'foo 元素'],[e2, 'bar 元素'], ];

上面代码中, e1e2 是两个对象,我们通过 arr 数组对这两个对象添加一些 文字说明。这就形成了 arre1e2 的引用。一旦不再需要这两个对象,我们就必须手动删除这个引用,否则垃圾回收机制就不会释放 e1e2 占用的内存。

// 不需要 e1 和 e2 的时候 // 必须手动删除引用
arr [0] = null;
arr [1] = null;

上面这样的写法显然很不方便。一旦忘了写,就会造成内存泄露。WeakMap 就是为了解决这个问题而诞生的,它的键名所引用的对象都是弱引用, 即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清 除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap

一个典型应用场景是,在网页的 DOM 元素上添加数据,就可以使 用 WeakMap 结构。当该 DOM 元素被清除,其所对应的 WeakMap 记录就会自动移除。

const wm = new WeakMap();
const element = document.getElementById('example');
wm.set(element, 'some information'); 
wm.get(element) // "some information"

总之,WeakMap 的专用场合就是,它的键所对应的对象,可能会在将来消失。该数据结构有助于防止内存泄漏。注意,WeakMap 弱引用的只是键名,而不是键值。键值依然是正常引用。

WeakMap 只有四个方法可用: get()、set() 、has() 、 delete()

WeakMap 的一个例子: myElement 是一个 DOM 节点,每当发生 click 事件,就更新一 下状态。我们将这个状态作为键值放在 WeakMap 里,对应的键名就是 myElement 。一旦这个 DOM 节点删除,该状态就会自动消失,不存在内存泄漏风险。

let myElement = document.getElementById('logo'); 
let myWeakmap = new WeakMap();

myWeakmap.set(myElement, {timesClicked: 0});

myElement.addEventListener('click', function() { 
    let logoData = myWeakmap.get(myElement); 
    logoData.timesClicked++;
}, false);

实现一个带有 clear() 方法的 WeakMap 类:

class ClearableWeakMap {
    constructor(init) {
        this._wm = new WeakMap(init);
    }
    clear() {
        this._wm = new WeakMap();
    }
    delete(k) {
        return this._wm.delete(k);
    }
    get(k) {
        return this._wm.get(k);
    }
    has(k) {
        return this._wm.has(k);
    }
    set(k, v) {
        this._wm.set(k, v);
        return this;
    }
}

参考文章

  1. 《ES6 标准入门》 —— 阮一峰
  2. MDN
  3. 深入理解 Set Map WeakSet WeakMap

Clloz

人生をやり直す

发表评论

电子邮件地址不会被公开。 必填项已用*标注

我不是机器人*

 

00:00/00:00