ES6 的 Map

ES6Map 类型是键值对的有序列表,而键和值都可以是任意类型。键的比较使用的是 Object.is(),因此你能将 5"5" 同时作为键,因为它们类型不同。这与使用对象属性作为键的方式(指的是用对象来模拟 Map)截然不同,因为对象的属性会被强制转换为字符串。

你可以调用 set() 方法并给它传递一个键与一个关联的值,来给 Map 添加项;此后使用键名来调用 get() 方法便能提取对应的值。例如:

let map = new Map();
map.set("title", "Understanding ES6");
map.set("year", 2016);

console.log(map.get("title"));      // "Understanding ES6"
console.log(map.get("year"));       // 2016

此例存储了两个键值对。"title" 键存储了一个字符串,而 "year" 键则存储了一个数值,此后调用 get() 方法提取出了二者的值。如果任意一个键不存在于 Map 中,则 get() 方法就会返回特殊值 undefined

你也可以将对象作为键,这也是从前使用对象属性来创建 Map 的变通方法所无法做到的。 此处有个例子:

let map = new Map(),
    key1 = {},
    key2 = {};

map.set(key1, 5);
map.set(key2, 42);

console.log(map.get(key1));         // 5
console.log(map.get(key2));         // 42

此代码使用了对象 key1key2 作为 Map 的键,并存储了两个不同的值。由于这些键不会被强制转换成其他形式,每个对象就都被认为是唯一的。这允许你给对象关联额外数据,而无须修改对象自身。

Map 的方法

MapSet 共享了几个方法,这是有意的,允许你使用相似的方式来与 MapSet 进行交互。以下三个方法在 MapSet 上都存在:

  • has(key):判断指定的键是否存在于 Map 中;

  • delete(key):移除 Map 中的键以及对应的值;

  • clear():移除 Map 中所有的键与值。

Map 同样拥有 size 属性,用于指明包含了多少个键值对。以下代码用不同方式使用了这三种方法以及 size 属性:

let map = new Map();
map.set("name", "Nicholas");
map.set("age", 25);

console.log(map.size);          // 2

console.log(map.has("name"));   // true
console.log(map.get("name"));   // "Nicholas"

console.log(map.has("age"));    // true
console.log(map.get("age"));    // 25

map.delete("name");
console.log(map.has("name"));   // false
console.log(map.get("name"));   // undefined
console.log(map.size);          // 1

map.clear();
console.log(map.has("name"));   // false
console.log(map.get("name"));   // undefined
console.log(map.has("age"));    // false
console.log(map.get("age"));    // undefined
console.log(map.size);          // 0

与用于 Set 时一样,size 属性总是包含了 Map 中键值对的数量。此例中的 Map 实例起初有 "name""age" 两个键,因此传递这两个键给 has() 方法都会返回 true。在 "name" 键被使用 delete() 方法移除后,has() 方法在接收 "name" 的时候就会返回 false 了,并且 size 属性表明 Map 的项减少了一个。之后 clear() 方法移除了残存的键,has() 方法此时对这两个键都会返回 false,而 size 属性则变成了 0

clear() 方法是从 Map 中移除大量数据的快速方法,但同时也有一次性将大量数据添加到 Map 的方法:

Map 的初始化

依然与 Set 类似,你能将数组传递给 Map 构造器,以便使用数据来初始化一个 Map。该数组中的每一项也必须是数组,内部数组的首个项会作为键,第二项则为对应值。因此整个 Map 就被这些双项数组所填充。例如:

let map = new Map([["name", "Nicholas"], ["age", 25]]);

console.log(map.has("name"));   // true
console.log(map.get("name"));   // "Nicholas"
console.log(map.has("age"));    // true
console.log(map.get("age"));    // 25
console.log(map.size);          // 2

通过构造器中的初始化,"name""age" 这两个键就被添加到 map 变量中。虽然由数组构成的数组看起来有点奇怪,这对于准确表示键来说却是必要的:因为键允许是任意数据类型,将键存储在数组中,是确保它们在被添加到 Map 之前不会被强制转换为其他类型的唯一方法。

Map 上的 forEach 方法

MapforEach() 方法类似于 Set 与数组的同名方法,它接受一个能接收三个参数的回调函数:

  1. Map 中下个位置的值;

  2. 该值所对应的键;

  3. 目标 Map 自身。

回调函数的这些参数更紧密契合了数组 forEach() 方法的行为,即:第一个参数是值、 第二个参数则是键(数组中的键是数值索引)。此处有个示例:

let map = new Map([ ["name", "Nicholas"], ["age", 25]]);

map.forEach(function(value, key, ownerMap) {
    console.log(key + " " + value);
    console.log(ownerMap === map);
});

forEach() 的回调函数输出了传给它的信息。其中 valuekey 被直接输出,ownerMapmap 进行了比较,说明它们是相等的。这就输出了:

name Nicholas
true
age 25
true

传递给 forEach() 的回调函数接收了每个键值对,按照键值对被添加到 Map 中的顺序。这种行为与在数组上调用 forEach() 方法有所不同,后者的回调函数会按数值索引的顺序接收到每一个项。

你也可以给 forEach() 提供第二个参数来指定回调函数中的 this 值,其行为与 Set 版本的 forEach() 一致。

Weak Map

Weak MapMap 而言,就像 Weak SetSet 一样:Weak 版本都是存储对象弱引用的方式。在 Weak Map 中,所有的键都必须是对象(尝试使用非对象的键会抛出错误),而且这些对象都是弱引用,不会干扰垃圾回收。当 Weak Map 中的键在 Weak Map 之外不存在引用时,该键值对会被移除。

Weak Map 的最佳用武之地,就是在浏览器中创建一个关联到特定 DOM 元素的对象。例如,某些用在网页上的 JS 库会维护一个自定义对象,用于引用该库所使用的每一个 DOM 元素,并且其映射关系会存储在内部的对象缓存中。

该方法的困难之处在于:如何判断一个 DOM 元素已不复存在于网页中,以便该库能移除此元素的关联对象。若做不到,该库就会继续保持对 DOM 元素的一个无效引用,并造成内存泄漏。使用 Weak Map 来追踪 DOM 元素,依然允许将自定义对象关联到每个 DOM 元素,而在此对象所关联的 DOM 元素不复存在时,它就会在 Weak Map 中被自动销毁。

必须注意的是,Weak Map 的键才是弱引用,而值不是。在 Weak Map 的值中存储对象会阻止垃圾回收,即使该对象的其他引用已全都被移除。

使用 Weak Map

ES6WeakMap 类型是键值对的无序列表,其中键必须是非空的对象,值则允许是任意类型。WeakMap 的接口与 Map 的非常相似,都使用 set()get() 方法来分别添加与提取数据:

let map = new WeakMap(),
    element = document.querySelector(".element");

map.set(element, "Original");

let value = map.get(element);
console.log(value);             // "Original"

// remove the element
element.parentNode.removeChild(element);
element = null;

// the weak map is empty at this point

此例存储了一个键值对。element 键是一个 DOM 元素,用于存储一个有关联的字符串值。将此 DOM 元素传递给 get() 方法,就能提取对应的值。随后将此 DOM 元素从页面文档中移除、并且将引用它的变量设置为 null,则对应的数据也就会在 Weak Map 中被移除。

类似于 Weak Set,没有任何办法可以确认 Weak Map 是否为空,因为它没有 size 属性。在其他引用被移除后,由于对键的引用不再有残留,也就无法调用 get() 方法来提取对应的值。Weak Map 已经切断了对于该值的访问,其所占的内存在垃圾回收器运行时便会被释放。

Weak Map 的初始化

为了初始化 Weak Map,需要把一个由数组构成的数组传递给 WeakMap 构造器。就像正规 Map 构造器那样,每个内部数组都应当有两个项,第一项是作为键的非空的对象,第二项则是对应的值(任意类型)。例如:

let key1 = {},
    key2 = {},
    map = new WeakMap([[key1, "Hello"], [key2, 42]]);

console.log(map.has(key1));     // true
console.log(map.get(key1));     // "Hello"
console.log(map.has(key2));     // true
console.log(map.get(key2));     // 42

对象 key1key2 被用作 Weak Map 的键,get()has() 方法则能访问它们。在传递给 WeakMap 构造器的参数中,若任意键值对使用了非对象的键,构造器就会抛出错误。

Weak Map 的方法

Weak Map 只有两个附加方法能用来与键值对交互。has() 方法用于判断指定的键是否存在于 Map 中,而 delete() 方法则用于移除一个特定的键值对。clear() 方法不存在,这是因为没必要对键进行枚举,并且枚举 Weak Map 也是不可能的,这与 Weak Set 相同。以下例子同时用到了 has()delete() 方法:

let map = new WeakMap(),
    element = document.querySelector(".element");

map.set(element, "Original");

console.log(map.has(element));   // true
console.log(map.get(element));   // "Original"

map.delete(element);
console.log(map.has(element));   // false
console.log(map.get(element));   // undefined

此处一个 DOM 元素再次在 Weak Map 中被作为键来使用。对于查看一个引用是否正被用作 Weak Map 的键,has() 是非常有用的。但需要注意,必须要有对于该键的另一个非空引用,才能使用此方法。而使用 delete() 方法则会把键从 Weak Map 中强制移除,此后 has() 方法就会对该键返回 falseget() 方法则会返回 undefined

对象的私有数据

虽然大多数开发者认为 Weak Map 的主要用途是关联数据与 DOM 元素,但仍然还存在许多可能的用法(并且毫无疑问,仍有一些用法尚未被发现)。Weak Map 的一个实际应用就是在对象实例中存储私有数据。在 ES6 中对象的所有属性都是公开的,因此若想让数据对于对象自身可访问、而在其他条件下不可访问, 那么你就需要使用一些创造力。研究以下例子:

function Person(name) {
    this._name = name;
}

Person.prototype.getName = function() {
    return this._name;
};

此代码使用了下划线这种表示私有属性的公共约定,来表明一个成员应当被认为是私有的, 不应从对象实例外进行修改,此处意图是:只允许用 getName() 来访问 this._name,而不允许 _name 的值被修改。然而,毫无办法阻止任何人写入 _name 属性,所以它依然能够被有意或无意地改写。

ES5 中能够创建几乎真正私有的数据,只要在创建对象时使用类似下面的模式:

var Person = (function() {

    var privateData = {},
        privateId = 0;

    function Person(name) {
        Object.defineProperty(this, "_id", { value: privateId++ });

        privateData[this._id] = {
            name: name
        };
    }

    Person.prototype.getName = function() {
        return privateData[this._id].name;
    };

    return Person;
}());

此例用 IIFE 包裹了 Person 的定义,其中含有两个私有属性:privateDataprivateIdprivateData 对象存储了每个实例的私有信息,而 privateId 则被用于为每个实例产生一个唯一 ID。当 Person 构造器被调用时,一个不可枚举、不可配置、不可写入的 _id 属性就被添加了。

接下来在 privateData 对象中建立了与实例 ID 对应的一个入口,其中存储着 name 的值。随后在 getName() 函数中,就能使用 this._id 作为 privateData 的键来提取该值。由于 privateData 无法从 IIFE 外部进行访问,实际的数据就是安全的,尽管 this._idprivateData 对象上依然是公开暴露的。

此方式的最大问题在于 privateData 中的数据永不会消失,因为在对象实例被销毁时没有任何方法可以获知该数据,privateData 对象就将永远包含多余的数据。这个问题现在可以换用 Weak Map 来解决了,如下:

let Person = (function() {

    let privateData = new WeakMap();

    function Person(name) {
        privateData.set(this, { name: name });
    }

    Person.prototype.getName = function() {
        return privateData.get(this).name;
    };

    return Person;
}());

此版本的 Person 范例使用了 Weak Map 而不是对象来保存私有数据。由于 Person 对象的实例本身能被作为键来使用,于是也就无须再记录单独的 ID。当 Person 构造器被调用时,将 this 作为键在 Weak Map 上建立了一个入口,而包含私有信息的对象成为了对应的值,其中只存放了 name 属性。通过将 this 传递给 privateData.get() 方法,以获取值对象并访问其 name 属性,getName() 函数便能提取私有信息。这种技术让私有信息能够保持私有状态,并且当与之关联的对象实例被销毁时,私有信息也会被同时销毁。

Weak Map 的用法与局限性

当决定是要使用 Weak Map 还是使用正规 Map 时,首要考虑因素在于你是否只想使用对象类型的键。如果你打算这么做,那么最好的选择就是 Weak Map。因为它能确保额外数据在不再可用后被销毁,从而能优化内存使用并规避内存泄漏。

要记住 Weak Map 只为它们的内容提供了很小的可见度,因此你不能使用 forEach() 方法、size 属性或 clear() 方法来管理其中的项。如果你确实需要一些检测功能,那么正规 Map 会是更好的选择,只是一定要确保留意内存的使用。

当然,若你想使用非对象的键,那么正规 Map 就是唯一选择。