WeakMap and WeakSet

我们从前面的垃圾回收章节中知道,JavaScript 引擎在内存充足的情况下(或者可能被使用完)存储一个值

例如:

let john = { name: "John" };

// 该对象能被访问, john 是它的引用

// 覆盖引用
john = null;

// 该对象将会从内存中被清除

通常,当对象的属性或者数组的元或者其它数据结构被认为是可访问的,并在该数据结构处于内存中时驻留在内存中。

例如, 如果把一个对象放入到数组中去, 然后当数组留存在内存中时,甚至该对象在没有其它引用的情况下依旧也是可访问的 。

就像这样:

let john = { name: "John" };

let array = [ john ];

john = null; // 覆盖引用

// john 被存储在数组里, 所以它不会被垃圾回收机制回收
// 我们可以通过 array[0] 来访问

类似地, 如果我们只用对象作为常规 Map 的键的时候, 然后当 Map 存在时, 那个对象也是存在的. 它会占用内存并且可能不会被(垃圾回收机制)回收.

For instance:

let john = { name: "John" };

let map = new Map();
map.set(john, "...");

john = null; // 覆盖引用

// john 被存在 map 里面了,
// 我们可以使用 map.keys() 来得到它

WeakMap 在这方面有着根本的区别。它不会阻止垃圾回收对关键对象进行回收操作。

让我们来看看例子里究竟是什么意思

WeakMap

相对于 MapWeakMap 的第一个不同点就是它键必须是对象,不能是原始值

let weakMap = new WeakMap();

let obj = {};

weakMap.set(obj, "ok"); // 正常 (键对象)

// 不能使用一个字符串作为键
weakMap.set("test", "Whoops"); // 错误, 因为 "test" 不是一个对象

现在, 如果我们在 weakMap 里使用对象作为键,并且当这个对象没有其它引用 – 该对象将会从内存中被自动清除 ( map 也类似) 。

let john = { name: "John" };

let weakMap = new WeakMap();
weakMap.set(john, "...");

john = null; // 覆盖引用

// john 从内存中被移除!

与上面的常规 Map 例子比起来。 现在如果 john 仅仅是作为 WeakMap 的键而存在时 – 它将会从 map (从内存中)自动删除。

WeakMap 不支持迭代和keys(), values(), entries()方法, 所以没法从它里面获取键或者值。

WeakMap 只有以下方法:

  • weakMap.get(key)
  • weakMap.set(key, value)
  • weakMap.delete(key)
  • weakMap.has(key)

为什么会有这种限制呢? 那是因为技术原因。如果一个对象丢失了其它多有引用(就像上面的 john), 然后它会被自动回收. 但是在从技术的角度并不能准确知道 何时开始清理

这些都由 JavaScript 决定。为了优化内存,它可能会立刻开始清除或者等待并在稍后更多清理的时候才开始执行清理。 所以, 从技术上来说,WeakMap 的当前成员的数量是未知的,引擎既有可能清理又可能不清理,或者只是清理一部分。 由于这些原因,暂不支持访问所有键或者值的方法。

那这种数据结构用在何处呢?

使用案例: 附加数据

WeakMap 的主要应用领域是 附加数据存储

假如我们在处理一个 “属于” 其它代码的对象,也可能是第三方库,想存储一些与其相关的数据,这就要求与这个对象共存亡 — 这时候 WeakMap 就是所我们多需要的利器

我们利用对象作为键并把数据存在到 WeakMap中,当该对象被回收时,该数据也会自动消失。

weakMap.set(john, "secret documents");
// 如果 john 消失, secret documents 将会被自动删除

让我们来看一个例子。

例如,我们有这样的代码需要记录访客的来访次数,信息被存储在弱集合中:某个用户对象作为键,来访次数作为值。当某个用户出去了(该对象被回收),我们就不需要再存储他们的来访次数了

下面有个类似的使用 Map 的函数:

// 📁 visitsCount.js
let visitsCountMap = new Map(); // map: user => visits count

// 递增游客来访次数
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

下面是其它部分的代码, 其它代码也使用它:

// 📁 main.js
let john = { name: "John" };

countUser(john); // count his visits
countUser(john);

// later john leaves us
john = null;

现在 john 这个对象应该被回收,但因为他的键还在visitsCountMap 中导致它依然留存在内存中。

对我们移除某个用户的时候需要清理 visitsCountMap , 否则它们会在内存中无限增加。这种清理在复杂的架构系统中将会是很乏味的任务。

我们可以通过使用 WeakMap 来避免这样的问题:

// 📁 visitsCount.js
let visitsCountMap = new WeakMap(); // weakmap: user => visits count

// 递增游客来访次数
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

现在我们不需要去清理visitsCountMap了。WeakMap 里的 john 对象以及它携带的信息将会被回收(清除),其它所有途径都不能访问它除非是作为 WeakMap 的键。

使用案例: 缓存

另外一个很普遍的例子是缓存: 当函数的结果需要被记住(“缓存”),这样在后续的同一个对象调用的时候可以重用该被缓存的结果。

我们可以使用 Map 来存储结果,就像这样:

// 📁 cache.js
let cache = new Map();

// 计算并记住结果
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* 计算 obj 值的结果 */ obj;

    cache.set(obj, result);
  }

  return cache.get(obj);
}

// 现在我们在其它文件中使用 process() :

// 📁 main.js
let obj = {/* 假设有个对象 */};

let result1 = process(obj); // 计算中...

// ...之后, 来自另外一个地方的代码...
let result2 = process(obj); // remembered result taken from cache

// ...之后, 改对象不再需要使用时:
obj = null;

alert(cache.size); // 1 (啊! 该对象依然在 cache 中, 并占据着内存!)

对于同一个对象多次调用,它只是计算第一次,之后直接从 cache 中获取,这样做的缺点是当我们不再需要这个对象的时候需要清理 cache

如果我们用 WeakMap 代替Map这个问题便会消失: 缓存的结果在该对象背回收之后会自动从内存中释放。

// 📁 cache.js
let cache = new WeakMap();

// 计算并记结果
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* 计算 obj 之后得出的结果 */ obj;

    cache.set(obj, result);
  }

  return cache.get(obj);
}

// 📁 main.js
let obj = {/* some object */};

let result1 = process(obj);
let result2 = process(obj);

// ...之后, 当该对象不再需要的时候:
obj = null;

// 不能使用 cache.size, 因为它是一个 WeakMap,
// 要么是 0 或者 很快变成 0
// 当 obj 被回收时, 缓存的数据也会被清除

WeakSet

WeakSet 的作用类似:

  • 它跟 Set 类似, 但是我们只能添加对象到 WeakSet (非原始值)中。
  • 某个对象只有在其它任何地方都能访问的时候才能留在 set 里。
  • Set 一样, WeakSet 支持 add, has and delete 等方法, 但不支持 size, keys() 并且没有迭代。 变 “弱” 的同时, 它也可以作为额外的存储空间,但并非任意数据,而是针对“是/否”的事实,在 WeakSet 里的成员代表着对象里的某个属性。 例如, 我们可以添加 users 到 WeakSet 里来追踪谁访问了我们的网站:
let visitedSet = new WeakSet();

let john = { name: "John" };
let pete = { name: "Pete" };
let mary = { name: "Mary" };

visitedSet.add(john); // John visited us
visitedSet.add(pete); // Then Pete
visitedSet.add(john); // John again

// 现在 visitedSet 有2个用户

// 检查 John 是否访问过?
alert(visitedSet.has(john)); // true

// 检查 Mary 是否访问过?
alert(visitedSet.has(mary)); // false

john = null;

// visitedSet 将被自动清理

WeakMapWeakSet 最出名的限制是不能迭代,获取多有的当前内容。那样可能会造成不便,但是依旧不能阻止 WeakMap/WeakSet 做很重要的事情 – 成为在其它地方管理或者存储的对象 “额外的” 存储数据

总结

WeakMap 是类似于 Map 的集合,它仅允许对象作为键,并在其他方式无法访问它们时将其与关联值一起删除。

WeakSet 是类似于Set的集合,它仅存储对象,并在其他方式无法访问它们时将其删除。

它们都不支持引用所有键或其计数的方法和属性。 仅允许单个操作。

WeakMapWeakSet还用作“辅助”数据结构。 一旦将对象从主存储器中删除,如果仅将其作为“ WeakMap”或“ WeakSet”的键,那么则将自动清除该对象。

任务

重要程度: 5

这里有一个 messages 数组:

let messages = [
    {text: "Hello", from: "John"},
    {text: "How goes?", from: "John"},
    {text: "See you soon", from: "Alice"}
];

你的代码可以访问它,但是消息被其他代码管理。这段代码有规律的添加新消息,删除旧消息,而且你不知道这些操作发生的时间。

现在,你应该是用什么数据结构来保存消息是否已读这个信息?这个结构必须很适合给出当前已知的消息对象是否已读的答案。

附:当消息被从 messages 中移除的时候,它应该也从你的数据结构中消失。

附:我们不能直接修改消息对象。如果它们被其他代码管理,那么给他们添加额外的属性可能导致不好的后果。

明智的选择是 WeakSet

let messages = [
    {text: "Hello", from: "John"},
    {text: "How goes?", from: "John"},
    {text: "See you soon", from: "Alice"}
];

let readMessages = new WeakSet();

// 两个消息已读
readMessages.add(messages[0]);
readMessages.add(messages[1]);
// readMessages 包含两个元素

// ...让我们再读一遍第一条消息!
readMessages.add(messages[0]);
// readMessages 仍然有两个不重复的元素

// 回答:message[0] 已读?
alert("Read message 0: " + readMessages.has(messages[0])); // true

messages.shift();
// 现在 readMessages 有一个元素(技术上来说内存可能稍后被清理)

WeakSet 允许存储一系列的消息并且很容易就能检查它包含的消息是否还存在。

它会自动清理自身。但是作为交换,我们不能对它进行迭代。我们不能直接获取所有已读消息。但是我们可以通过迭代所有消息然后找出存在于 set 的那些消息来完成这个功能。

附:如果消息被其他代码管理,那么仅为了自己的功能给每个消息添加一个属性也许会很危险,但是我们可以将它改为 symbol 来规避冲突。

像这样:

// the symbolic property is only known to our code
let isRead = Symbol("isRead");
messages[0][isRead] = true;

现在即使其他人的代码使用 for..in 循环消息的属性,我们的秘密标识也不会出现。

重要程度: 5

这里有一个和前一任务相像的消息数组。情境相似。

let messages = [
    {text: "Hello", from: "John"},
    {text: "How goes?", from: "John"},
    {text: "See you soon", from: "Alice"}
];

现在的问题是:你建议采用什么数据结构来保存信息:“消息是什么时候被阅读的?”。

在前一个任务中我们只需要保存“是/否”。现在我们需要保存日期,并且它也应该在消息没有了就消失。

我们使用 WeakMap 保存日期:

let messages = [
    {text: "Hello", from: "John"},
    {text: "How goes?", from: "John"},
    {text: "See you soon", from: "Alice"}
];

let readMap = new WeakMap();

readMap.set(messages[0], new Date(2017, 1, 1));
// Date 对象我们将会稍后学习
教程路线图

评论

在评论之前先阅读本内容…
  • 如果你发现教程有错误,或者有其他需要修改和提升的地方 — 请 提交一个 GitHub issue 或 pull request,而不是在这评论。
  • 如果你对教程的内容有不理解的地方 — 请详细说明。
  • 使用 <code> 标签插入只有几个词的代码,插入多行代码可以使用 <pre> 标签,对于超过 10 行的代码,建议你使用沙箱(plnkrJSBincodepen…)