2022 年 11 月 13 日

WeakMap 和 WeakSet

正如我们在 垃圾回收 一章中所了解的,JavaScript 引擎在值“可达”并且可能被使用时会将其保存在内存中。

例如

let john = { name: "John" };

// the object can be accessed, john is the reference to it

// overwrite the reference
john = null;

// the object will be removed from memory

通常,对象属性或数组或其他数据结构的元素被认为是可达的,并且在该数据结构存在于内存中时会保存在内存中。

例如,如果我们将一个对象放入数组中,那么在数组存在时,该对象也将存在,即使没有其他引用指向它。

就像这样

let john = { name: "John" };

let array = [ john ];

john = null; // overwrite the reference

// the object previously referenced by john is stored inside the array
// therefore it won't be garbage-collected
// we can get it as array[0]

与此类似,如果我们在常规 Map 中将对象用作键,那么在 Map 存在时,该对象也存在。它占用内存,并且可能不会被垃圾回收。

例如

let john = { name: "John" };

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

john = null; // overwrite the reference

// john is stored inside the map,
// we can get it by using map.keys()

WeakMap 在这方面有根本的不同。它不会阻止垃圾回收键对象。

让我们看看示例中的含义。

WeakMap

MapWeakMap 之间的第一个区别是键必须是对象,而不是原始值

let weakMap = new WeakMap();

let obj = {};

weakMap.set(obj, "ok"); // works fine (object key)

// can't use a string as the key
weakMap.set("test", "Whoops"); // Error, because "test" is not an object

现在,如果我们使用一个对象作为其中的键,并且没有其他对该对象的引用,它将自动从内存(和映射)中删除。

let john = { name: "John" };

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

john = null; // overwrite the reference

// john is removed from memory!

将其与上面常规 Map 示例进行比较。现在,如果 john 仅作为 WeakMap 的键存在,它将自动从映射(和内存)中删除。

WeakMap 不支持迭代和方法 keys()values()entries(),因此无法从中获取所有键或值。

WeakMap 仅具有以下方法

为什么会有这样的限制?这是出于技术原因。如果一个对象丢失了所有其他引用(如上面代码中的 john),那么它将被自动垃圾回收。但在技术上,它并没有确切地指定何时进行清理

JavaScript 引擎决定这一点。它可以选择立即执行内存清理,或等待并在以后发生更多删除时进行清理。因此,在技术上,WeakMap 的当前元素计数是未知的。引擎可能已经清理了它,也可能没有,或者只是部分清理了它。因此,不支持访问所有键/值的那些方法。

现在,我们哪里需要这样的数据结构?

用例:附加数据

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

如果我们使用“属于”其他代码(甚至可能是第三方库)的对象,并且希望存储一些与之关联的数据,这些数据只应在对象存活时存在,那么 WeakMap 正是所需。

我们使用对象作为键将数据放入 WeakMap 中,当对象被垃圾回收时,该数据也会自动消失。

weakMap.set(john, "secret documents");
// if john dies, secret documents will be destroyed automatically

我们来看一个示例。

例如,我们有用于记录用户访问次数的代码。信息存储在地图中:用户对象是键,访问次数是值。当用户离开(其对象被垃圾回收)时,我们不再希望存储其访问次数。

以下是使用 Map 的计数函数示例

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

// increase the 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

// later john leaves us
john = null;

现在,john 对象应被垃圾回收,但仍保留在内存中,因为它是 visitsCountMap 中的键。

当我们删除用户时,我们需要清理 visitsCountMap,否则它将在内存中无限增长。在复杂架构中,这种清理可能成为一项繁琐的任务。

我们可以通过改为使用 WeakMap 来避免这种情况

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

// increase the visits count
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

现在我们不必清理 visitsCountMap。在 john 对象变得不可达(除了作为 WeakMap 的键之外)之后,它将从内存中删除,同时也会从 WeakMap 中删除该键的信息。

用例:缓存

另一个常见的示例是缓存。我们可以存储(“缓存”)函数的结果,以便对同一对象的未来调用可以重用它。

为了实现这一点,我们可以使用 Map(不是最佳方案)

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

// calculate and remember the result
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* calculations of the result for */ obj;

    cache.set(obj, result);
    return result;
  }

  return cache.get(obj);
}

// Now we use process() in another file:

// 📁 main.js
let obj = {/* let's say we have an object */};

let result1 = process(obj); // calculated

// ...later, from another place of the code...
let result2 = process(obj); // remembered result taken from cache

// ...later, when the object is not needed any more:
obj = null;

alert(cache.size); // 1 (Ouch! The object is still in cache, taking memory!)

对于使用同一对象的 process(obj) 的多次调用,它只在第一次计算结果,然后从 cache 中获取结果。缺点是我们需要在不再需要对象时清理 cache

如果我们用 WeakMap 替换 Map,那么这个问题就会消失。在对象被垃圾回收后,缓存的结果将自动从内存中删除。

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

// calculate and remember the result
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* calculate the result for */ obj;

    cache.set(obj, result);
    return result;
  }

  return cache.get(obj);
}

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

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

// ...later, when the object is not needed any more:
obj = null;

// Can't get cache.size, as it's a WeakMap,
// but it's 0 or soon be 0
// When obj gets garbage collected, cached data will be removed as well

WeakSet

WeakSet 的行为类似

  • 它类似于 Set,但我们只能向 WeakSet 添加对象(而不是基元)。
  • 对象在从其他地方可达时存在于集合中。
  • Set 一样,它支持 addhasdelete,但不支持 sizekeys() 和任何迭代。

由于“弱”,它还可用作附加存储。但不是用于任意数据,而是用于“是/否”事实。WeakSet 中的成员资格可能表示有关对象的信息。

例如,我们可以将用户添加到 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 has 2 users now

// check if John visited?
alert(visitedSet.has(john)); // true

// check if Mary visited?
alert(visitedSet.has(mary)); // false

john = null;

// visitedSet will be cleaned automatically

WeakMapWeakSet 最明显的限制是缺少迭代,并且无法获取所有当前内容。这可能看起来不方便,但并不会妨碍 WeakMap/WeakSet 执行其主要工作——成为存储在其他位置的对象的“附加”数据存储。

摘要

WeakMap 是类似于 Map 的集合,它只允许对象作为键,并且在它们通过其他方式变得不可访问时将它们与关联的值一起删除。

WeakSet 是类似于 Set 的集合,它只存储对象,并且在它们通过其他方式变得不可访问时将它们删除。

它们的主要优点是它们对对象具有弱引用,因此垃圾回收器可以轻松地删除它们。

代价是不支持 clearsizekeysvalues...

WeakMapWeakSet 除了“主”对象存储之外,还用作“辅助”数据结构。一旦对象从主存储中删除,如果它仅作为 WeakMap 的键或在 WeakSet 中找到,它将自动清除。

任务

重要性:5

有一个消息数组

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

// two messages have been read
readMessages.add(messages[0]);
readMessages.add(messages[1]);
// readMessages has 2 elements

// ...let's read the first message again!
readMessages.add(messages[0]);
// readMessages still has 2 unique elements

// answer: was the message[0] read?
alert("Read message 0: " + readMessages.has(messages[0])); // true

messages.shift();
// now readMessages has 1 element (technically memory may be cleaned later)

WeakSet 允许存储一组消息,并轻松检查其中是否存在某条消息。

它会自动清理自身。权衡之处在于我们无法对其进行迭代,也无法直接从中获取“所有已读消息”。但我们可以通过迭代所有消息并过滤掉集合中的那些消息来实现此目的。

另一种不同的解决方案是在阅读消息后向其添加一个类似 message.isRead=true 的属性。由于消息对象由其他代码管理,因此通常不建议这样做,但我们可以使用一个符号属性来避免冲突。

就像这样

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

现在,第三方代码可能看不到我们的额外属性。

尽管符号可以降低出现问题的可能性,但从架构的角度来看,使用 WeakSet 更好。

重要性:5

这里有一个消息数组,如 前一个任务 中所示。情况类似。

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

现在的问题是:你建议使用哪个数据结构来存储以下信息:“消息何时被阅读?”。

在前一个任务中,我们只需要存储“是/否”的事实。现在我们需要存储日期,并且它应该只保留在内存中,直到消息被垃圾回收。

P.S. 日期可以存储为内置 Date 类的对象,我们将在后面介绍。

要存储日期,我们可以使用 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 object we'll study later
教程地图

评论

在评论之前先阅读此内容…
  • 如果你有改进建议,请 提交 GitHub 问题 或提交拉取请求,而不是进行评论。
  • 如果你无法理解文章中的某些内容,请详细说明。
  • 要插入几行代码,请使用 <code> 标签,对于多行代码,请将其包装在 <pre> 标签中,对于 10 行以上的代码,请使用沙箱(plnkrjsbincodepen…)