弱參照和完成處理常式

發布時間 · 更新時間 · 標籤為 ECMAScript ES2021

一般來說,JavaScript 中的物件參照是強保留的,表示只要您有物件參照,它就不會被垃圾回收。

const ref = { x: 42, y: 51 };
// As long as you have access to `ref` (or any other reference to the
// same object), the object won’t be garbage-collected.

目前,WeakMapWeakSet 是 JavaScript 中唯一可以弱參照物件的方法:將物件新增為 WeakMapWeakSet 的金鑰不會阻止它被垃圾回收。

const wm = new WeakMap();
{
const ref = {};
const metaData = 'foo';
wm.set(ref, metaData);
wm.get(ref);
// → metaData
}
// We no longer have a reference to `ref` in this block scope, so it
// can be garbage-collected now, even though it’s a key in `wm` to
// which we still have access.

const ws = new WeakSet();
{
const ref = {};
ws.add(ref);
ws.has(ref);
// → true
}
// We no longer have a reference to `ref` in this block scope, so it
// can be garbage-collected now, even though it’s a key in `ws` to
// which we still have access.

注意:您可以將 WeakMap.prototype.set(ref, metaData) 視為將值 metaData 新增為物件 ref 的屬性:只要您有物件參照,您就可以取得元資料。一旦您不再有物件參照,它就可以被垃圾回收,即使您仍有它被新增到的 WeakMap 參照。同樣地,您可以將 WeakSet 視為 WeakMap 的特殊情況,其中所有值都是布林值。

JavaScript WeakMap 實際上並非:只要金鑰存在,它就會參照其內容。一旦金鑰被垃圾回收,WeakMap 才會弱參照其內容。這種關係更精確的名稱是 短暫

WeakRef 是一個更進階的 API,提供實際的弱參照,讓您得以深入了解物件的生命週期。讓我們一起來看一個範例。

對於這個範例,假設我們正在開發一個聊天網路應用程式,它使用網路插座與伺服器通訊。想像一個 MovingAvg 類別,它為了效能診斷目的,會保留一組來自網路插座的事件,以便計算延遲的簡單移動平均數。

class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}

compute(n) {
// Compute the simple moving average for the last n events.
// …
}
}

它是由 MovingAvgComponent 類別使用的,讓您可以控制何時開始和停止監控延遲的簡單移動平均數。

class MovingAvgComponent {
constructor(socket) {
this.socket = socket;
}

start() {
this.movingAvg = new MovingAvg(this.socket);
}

stop() {
// Allow the garbage collector to reclaim memory.
this.movingAvg = null;
}

render() {
// Do rendering.
// …
}
}

我們知道將所有伺服器訊息保留在實例 MovingAvg 中會使用大量記憶體,因此我們在停止監控時會小心將 this.movingAvg 清除為 null,讓垃圾收集器回收記憶體。

但是,在 DevTools 中檢查記憶體面板後,我們發現記憶體根本沒有被回收!經驗豐富的網頁開發人員可能已經發現錯誤:事件監聽器是強參照,必須明確移除。

讓我們使用可及性圖表明確說明這一點。呼叫 start() 之後,我們的物件圖形如下所示,其中實線箭頭表示強參照。從 MovingAvgComponent 實例透過實線箭頭可及的一切都不可垃圾收集。

呼叫 stop() 之後,我們已移除從 MovingAvgComponent 實例到 MovingAvg 實例的強參照,但並未透過 socket 的監聽器移除。

因此,MovingAvg 實例中的監聽器透過參照 this,只要未移除事件監聽器,就會讓整個實例保持運作。

到目前為止,解決方案是透過 dispose 方法手動取消註冊事件監聽器。

class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}

dispose() {
this.socket.removeEventListener('message', this.listener);
}

// …
}

這種方法的缺點是手動記憶體管理。MovingAvgComponentMovingAvg 類別的所有其他使用者都必須記得呼叫 dispose,否則會發生記憶體外洩。更糟糕的是,手動記憶體管理會層層遞減:MovingAvgComponent 的使用者必須記得呼叫 stop,否則會發生記憶體外洩,依此類推。應用程式行為不依賴於此診斷類別的事件監聽器,而且監聽器在記憶體使用方面很昂貴,但在運算方面則不然。我們真正想要的是讓監聽器的生命週期在邏輯上與 MovingAvg 實例綁定,以便可以像使用任何其他 JavaScript 物件一樣使用 MovingAvg,其記憶體會由垃圾收集器自動回收。

WeakRef 可以透過建立實際事件監聽器的弱參照,然後將該 WeakRef 包裝在外部事件監聽器中來解決這個兩難問題。這樣一來,垃圾收集器就可以清除實際事件監聽器和它保持運作的記憶體,例如 MovingAvg 實例及其 events 陣列。

function addWeakListener(socket, listener) {
const weakRef = new WeakRef(listener);
const wrapper = (ev) => { weakRef.deref()?.(ev); };
socket.addEventListener('message', wrapper);
}

class MovingAvg {
constructor(socket) {
this.events = [];
this.listener = (ev) => { this.events.push(ev); };
addWeakListener(socket, this.listener);
}
}

注意:必須謹慎處理函式的 WeakRef。JavaScript 函式是 封閉,並強烈參照包含函式內部參照的自由變數值的外部環境。這些外部環境可能包含其他封閉參照的變數。也就是說,在處理封閉時,其記憶體通常會以微妙的方式被其他封閉強烈參照。這就是 addWeakListener 是獨立函式,而且 wrapper 不是 MovingAvg 建構函式的區域變數的原因。在 V8 中,如果 wrapperMovingAvg 建構函式的區域變數,並與包裝在 WeakRef 中的監聽器共用詞彙範圍,則 MovingAvg 實例及其所有屬性都可透過包裝監聽器的共用環境來存取,導致實例無法收集。在撰寫程式碼時請記住這一點。

我們首先建立事件監聽器並將其指定給 this.listener,以便 MovingAvg 實例強參照它。換句話說,只要 MovingAvg 實例存在,事件監聽器就會存在。

然後,在 addWeakListener 中,我們建立一個 WeakRef,其 目標 是實際事件監聽器。在 wrapper 內部,我們 deref 它。由於 WeakRef 沒有防止目標的垃圾回收(如果目標沒有其他強參照),我們必須手動取消參照它們以取得目標。如果目標在此期間已被垃圾回收,deref 會傳回 undefined。否則,會傳回原始目標,也就是我們使用 選擇性鏈接 呼叫的 listener 函式。

由於事件監聽器封裝在 WeakRef 中,對它的 唯一 強參照是 MovingAvg 實例上的 listener 屬性。也就是說,我們已成功將事件監聽器的生命週期繫結到 MovingAvg 實例的生命週期。

回到可達性圖,在使用 WeakRef 實作呼叫 start() 之後,我們的物件圖如下所示,其中虛線箭頭表示弱參照。

在呼叫 stop() 之後,我們已移除對監聽器的唯一強參照

最後,在進行垃圾回收之後,MovingAvg 實例和監聽器將被回收

但這裡仍有一個問題:我們透過將 listener 封裝在 WeakRef 中,為 listener 新增了一層間接,但 addWeakListener 中的 wrapper 仍會洩漏,原因與 listener 最初洩漏的原因相同。當然,這是一個較小的洩漏,因為只有 wrapper 會洩漏,而不是整個 MovingAvg 實例,但它仍然是一個洩漏。解決這個問題的方法是 WeakRef 的配套功能 FinalizationRegistry。有了新的 FinalizationRegistry API,我們可以註冊一個回呼,在垃圾回收器清除註冊物件時執行。這種回呼稱為 完成處理函式

注意:完成處理回呼並不會在垃圾回收事件監聽器後立即執行,因此不要將它用於重要的邏輯或指標。垃圾回收和完成處理回呼的時機未指定。事實上,從不進行垃圾回收的引擎將完全符合規範。但是,可以安全地假設引擎 進行垃圾回收,而且完成處理回呼會在稍後某個時間點呼叫,除非環境已捨棄(例如關閉分頁或終止工作執行緒)。在撰寫程式碼時,請謹記這種不確定性。

我們可以使用 FinalizationRegistry 註冊一個回呼函式,以便在內部事件監聽器被垃圾回收時從 socket 中移除 wrapper。我們的最終實作如下所示

const gListenersRegistry = new FinalizationRegistry(({ socket, wrapper }) => {
socket.removeEventListener('message', wrapper); // 6
});

function addWeakListener(socket, listener) {
const weakRef = new WeakRef(listener); // 2
const wrapper = (ev) => { weakRef.deref()?.(ev); }; // 3
gListenersRegistry.register(listener, { socket, wrapper }); // 4
socket.addEventListener('message', wrapper); // 5
}

class MovingAvg {
constructor(socket) {
this.events = [];
this.listener = (ev) => { this.events.push(ev); }; // 1
addWeakListener(socket, this.listener);
}
}

注意: gListenersRegistry 是全域變數,用於確保執行終結器。FinalizationRegistry 不會由在其中註冊的物件維持運作。如果註冊表本身被垃圾回收,其終結器可能不會執行。

我們建立一個事件監聽器並將其指定給 this.listener,以便 MovingAvg 實例 (1) 強力參照它。然後,我們將執行工作的事件監聽器封裝在 WeakRef 中,使其可被垃圾回收,並透過 this 避免其參照外洩至 MovingAvg 實例 (2)。我們建立一個 wrapper,用於 deref WeakRef 以檢查它是否仍然存在,然後在存在時呼叫它 (3)。我們在 FinalizationRegistry 上註冊內部監聽器,並將持有值 { socket, wrapper } 傳遞給註冊 (4)。然後,我們將傳回的 wrapper 新增為 socket 上的事件監聽器 (5)。在 MovingAvg 實例和內部監聽器被垃圾回收後的一段時間,終結器可能會執行,並傳遞給它的持有值。在終結器內,我們也移除 wrapper,使與 MovingAvg 實例使用相關的所有記憶體都可被垃圾回收 (6)。

透過這一切,我們 MovingAvgComponent 的原始實作既不會造成記憶體外洩,也不需要任何手動處置。

不要過度使用 #

在聽聞這些新功能後,你可能會忍不住對所有事物使用 WeakRef。然而,這可能不是個好主意。有些事物明確適合使用 WeakRef 和終結器。

一般來說,避免撰寫依賴垃圾收集器在任何可預測的時間清除 WeakRef 或呼叫終結器的程式碼,因為這無法做到!此外,物件是否可被垃圾回收可能取決於實作細節,例如封閉的表示方式,這些細節既微妙又可能因 JavaScript 引擎甚至同一引擎的不同版本而異。特別是,終結器回呼函式

  • 可能不會在垃圾回收後立即發生。
  • 可能不會按照實際垃圾回收的順序發生。
  • 可能根本不會發生,例如,如果瀏覽器視窗已關閉。

因此,不要將重要邏輯放在完成器的程式碼路徑中。它們對於在垃圾回收時執行清理很有用,但你不能可靠地使用它們來記錄有關記憶體使用量的有意義的指標。對於該用例,請參閱 performance.measureUserAgentSpecificMemory

WeakRef 和完成器可以幫助你節省記憶體,並且在作為漸進增強的手段時,使用最少效果最佳。由於它們是高級使用者功能,因此我們預期大部分使用會發生在框架或程式庫中。

WeakRef 支援 #