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

如果我們將 Map 替換為 WeakMap,那麼這個問題就會消失。快取結果會在物件被垃圾回收後自動從記憶體中移除。

// 📁 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"}
];

您的程式碼可以存取它,但訊息是由其他人的程式碼管理的。新的訊息會新增,舊的訊息會定期被該程式碼移除,而且您不知道確切發生時間。

現在,您可以使用哪個資料結構來儲存有關訊息「是否已讀取」的資訊?該結構必須非常適合針對給定的訊息物件提供「是否已讀取」的答案。

P.S. 當訊息從 messages 中移除時,它也應該從你的結構中消失。

P.P.S. 我們不應該修改訊息物件,將我們的屬性新增到它們上面。因為它們是由其他人的程式碼管理的,這可能會導致不良後果。

讓我們將已讀訊息儲存在 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…)