一般來說,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.
目前,WeakMap
和 WeakSet
是 JavaScript 中唯一可以弱參照物件的方法:將物件新增為 WeakMap
或 WeakSet
的金鑰不會阻止它被垃圾回收。
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);
}
// …
}
這種方法的缺點是手動記憶體管理。MovingAvgComponent
和 MovingAvg
類別的所有其他使用者都必須記得呼叫 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 中,如果 wrapper
是 MovingAvg
建構函式的區域變數,並與包裝在 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
支援 #
- Chrome: 自版本 74 起支援
- Firefox: 自版本 79 起支援
- Safari: 不支援
- Node.js: 自版本 14.6.0 起支援
- Babel: 不支援