2023 年 11 月 4 日

WeakRef 和 FinalizationRegistry

語言的「隱藏」功能

這篇文章探討一個非常狹隘的主題,大多數開發人員在實務上極少遇到(甚至可能不知道它的存在)。

如果你才剛開始學習 JavaScript,我們建議跳過這章。

回想一下 垃圾回收 章節中可及性原則的基本概念,我們可以注意到 JavaScript 引擎保證保留記憶體中可存取或正在使用的值。

例如

//  the user variable holds a strong reference to the object
let user = { name: "John" };

// let's overwrite the value of the user variable
user = null;

// the reference is lost and the object will be deleted from memory

或類似但稍微複雜一點的程式碼,具有兩個強參照

//  the user variable holds a strong reference to the object
let user = { name: "John" };

// copied the strong reference to the object into the admin variable
let admin = user;

// let's overwrite the value of the user variable
user = null;

// the object is still reachable through the admin variable

物件 { name: "John" } 僅在沒有對它進行強參照時才會從記憶體中刪除(如果我們也覆寫了 admin 變數的值)。

在 JavaScript 中,有一個稱為 WeakRef 的概念,在這種情況下它的行為略有不同。

術語:「強參照」、「弱參照」

強參照 – 是對物件或值的參照,可防止垃圾回收器將它們刪除。從而保留它所指向的物件或值在記憶體中。

這表示,只要對物件或值有作用中的強參照,該物件或值就會保留在記憶體中,不會被垃圾回收器回收。

在 JavaScript 中,對物件的普通參照是強參照。例如

// the user variable holds a strong reference to this object
let user = { name: "John" };

弱參照 – 是對物件或值的參照,不會防止垃圾回收器將它們刪除。如果對物件或值的唯一剩餘參照是弱參照,則垃圾回收器可以刪除該物件或值。

WeakRef

注意事項

在我們深入探討之前,值得注意的是,正確使用本文中討論的結構需要非常仔細的思考,如果可能的話最好避免使用它們。

WeakRef – 是包含對另一個物件(稱為 targetreferent)的弱參照的物件。

WeakRef 的特殊性在於它不會阻止垃圾回收器刪除其參照物件。換句話說,WeakRef 物件不會讓 referent 物件保持存在。

現在讓我們將 user 變數作為「參照」,並從它建立一個弱參照到 admin 變數。要建立弱參照,您需要使用 WeakRef 建構函式,傳入目標物件(您想要弱參照的物件)。

在我們的案例中 — 這是 user 變數

//  the user variable holds a strong reference to the object
let user = { name: "John" };

//  the admin variable holds a weak reference to the object
let admin = new WeakRef(user);

下方的圖表描繪了兩種參照類型:使用 user 變數的強參照和使用 admin 變數的弱參照

然後,在某個時間點,我們停止使用 user 變數 – 它被覆寫、超出範圍等,同時將 WeakRef 執行個體保留在 admin 變數中

// let's overwrite the value of the user variable
user = null;

對物件的弱參照不足以讓它「存活」。當對參照物件的唯一剩餘參照是弱參照時,垃圾收集器可以自由地銷毀此物件,並將其記憶體用於其他用途。

然而,在物件實際上被銷毀之前,弱參照可能會傳回它,即使沒有更多對此物件的強參照。也就是說,我們的物件變成一種「薛丁格的貓」——我們無法確定它是否「存活」或「死亡」

在這個時候,若要從 WeakRef 執行個體取得物件,我們將使用其 deref() 方法。

如果物件仍在記憶體中,deref() 方法會傳回 WeakRef 指向的參照物件。如果物件已被垃圾收集器刪除,則 deref() 方法會傳回 undefined

let ref = admin.deref();

if (ref) {
  // the object is still accessible: we can perform any manipulations with it
} else {
  // the object has been collected by the garbage collector
}

WeakRef 使用案例

WeakRef 通常用於建立快取或儲存資源密集型物件的 關聯陣列。這可以避免僅根據快取或關聯陣列中的存在,而防止垃圾收集器收集這些物件。

其中一個主要的範例——就是當我們有許多二進位影像物件(例如,表示為 ArrayBufferBlob)時,我們想要將名稱或路徑與每個影像關聯起來。現有的資料結構並不適合這些目的

  • 使用 Map 來建立名稱與影像之間的關聯,或反之亦然,會讓影像物件保留在記憶體中,因為它們存在於 Map 中作為鍵或值。
  • WeakMap 也不符合這個目標:因為表示為 WeakMap 鍵的物件使用弱參照,且不會受到垃圾收集器的刪除保護。

但是,在這種情況下,我們需要一個資料結構,在其值中使用弱參照。

為此,我們可以使用 Map 集合,其值是 WeakRef 執行個體,用來參照我們需要的龐大物件。因此,我們不會將這些龐大且不必要的物件保留在記憶體中超過它們應有的時間。

否則,這是一種在某些情況下取得快取中影像物件的方法。如果它已被垃圾收集,我們將重新產生或重新下載它。

這樣,在某些情況下使用的記憶體會更少。

範例 1:使用 WeakRef 進行快取

以下是一個程式碼片段,展示了使用 WeakRef 技術的方法。

簡而言之,我們使用一個字串鍵值和 WeakRef 物件作為其值的 Map。如果 WeakRef 物件尚未被垃圾收集器收集,我們會從快取中取得它。否則,我們會再次重新下載它並將它放入快取中,以供進一步可能的重複使用

function fetchImg() {
    // abstract function for downloading images...
}

function weakRefCache(fetchImg) { // (1)
    const imgCache = new Map(); // (2)

    return (imgName) => { // (3)
        const cachedImg = imgCache.get(imgName); // (4)

        if (cachedImg?.deref()) { // (5)
            return cachedImg?.deref();
        }

        const newImg = fetchImg(imgName); // (6)
        imgCache.set(imgName, new WeakRef(newImg)); // (7)

        return newImg;
    };
}

const getCachedImg = weakRefCache(fetchImg);

讓我們深入探討這裡發生的事情

  1. weakRefCache – 是一個高階函式,它將另一個函式 fetchImg 作為引數。在此範例中,我們可以忽略 fetchImg 函式的詳細說明,因為它可以是任何用於下載影像的邏輯。
  2. imgCache – 是影像快取,它以字串鍵值(影像名稱)和 WeakRef 物件作為其值的型式,儲存 fetchImg 函式的快取結果。
  3. 傳回一個匿名函式,它將影像名稱作為引數。此引數將用作快取影像的鍵值。
  4. 嘗試使用提供的鍵值(影像名稱)從快取中取得快取結果。
  5. 如果快取包含指定鍵值的值,且 WeakRef 物件尚未被垃圾收集器刪除,則傳回快取結果。
  6. 如果快取中沒有具有請求鍵值的項目,或 deref() 方法傳回 undefined(表示 WeakRef 物件已遭垃圾收集),則 fetchImg 函式會再次下載影像。
  7. 將下載的影像作為 WeakRef 物件放入快取中。

現在我們有一個 Map 集合,其中鍵值是字串形式的影像名稱,而值是包含影像本身的 WeakRef 物件。

此技術有助於避免為不再使用的資源密集型物件配置大量記憶體。它還可以在重複使用快取物件的情況下節省記憶體和時間。

以下是此程式碼的視覺化表示

但是,此實作有其缺點:隨著時間的推移,Map 將會填滿字串作為鍵值,這些字串指向 WeakRef,而其參照物件已遭垃圾收集

處理此問題的一種方法是定期清除快取並清除「失效」的項目。另一種方法是使用終結器,我們將在稍後探討。

範例 №2:使用 WeakRef 追蹤 DOM 物件

WeakRef 的另一個使用案例是追蹤 DOM 物件。

讓我們想像一個場景,其中一些第三方程式碼或函式庫與我們頁面上的元素互動,只要它們存在於 DOM 中。例如,它可以是監控和通知系統狀態的外部公用程式(通常稱為「記錄器」-一個傳送稱為「記錄」的資訊訊息的程式)。

互動範例

結果
index.js
index.css
index.html
const startMessagesBtn = document.querySelector('.start-messages'); // (1)
const closeWindowBtn = document.querySelector('.window__button'); // (2)
const windowElementRef = new WeakRef(document.querySelector(".window__body")); // (3)

startMessagesBtn.addEventListener('click', () => { // (4)
    startMessages(windowElementRef);
    startMessagesBtn.disabled = true;
});

closeWindowBtn.addEventListener('click', () =>  document.querySelector(".window__body").remove()); // (5)


const startMessages = (element) => {
    const timerId = setInterval(() => { // (6)
        if (element.deref()) { // (7)
            const payload = document.createElement("p");
            payload.textContent = `Message: System status OK: ${new Date().toLocaleTimeString()}`;
            element.deref().append(payload);
        } else { // (8)
            alert("The element has been deleted."); // (9)
            clearInterval(timerId);
        }
    }, 1000);
};
.app {
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.start-messages {
    width: fit-content;
}

.window {
    width: 100%;
    border: 2px solid #464154;
    overflow: hidden;
}

.window__header {
    position: sticky;
    padding: 8px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    background-color: #736e7e;
}

.window__title {
    margin: 0;
    font-size: 24px;
    font-weight: 700;
    color: white;
    letter-spacing: 1px;
}

.window__button {
    padding: 4px;
    background: #4f495c;
    outline: none;
    border: 2px solid #464154;
    color: white;
    font-size: 16px;
    cursor: pointer;
}

.window__body {
    height: 250px;
    padding: 16px;
    overflow: scroll;
    background-color: #736e7e33;
}
<!DOCTYPE HTML>
<html lang="en">

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="index.css">
  <title>WeakRef DOM Logger</title>
</head>

<body>

<div class="app">
  <button class="start-messages">Start sending messages</button>
  <div class="window">
    <div class="window__header">
      <p class="window__title">Messages:</p>
      <button class="window__button">Close</button>
    </div>
    <div class="window__body">
      No messages.
    </div>
  </div>
</div>


<script type="module" src="index.js"></script>
</body>
</html>

當按下「開始傳送訊息」按鈕時,在所謂的「日誌顯示視窗」(一個具有 .window__body 類別的元素)中,訊息(日誌)會開始出現。

但是,一旦這個元素從 DOM 中刪除,記錄器就應該停止傳送訊息。要重現這個元素的移除,只要按一下右上角的「關閉」按鈕即可。

為了不讓我們的程式變得複雜,也不必在我們的 DOM 元素可用時或不可用時通知第三方程式碼,使用 WeakRef 建立一個弱參考就足夠了。

一旦元素從 DOM 中移除,記錄器就會注意到並停止傳送訊息。

現在讓我們仔細看看原始碼(索引標籤 index.js

  1. 取得「開始傳送訊息」按鈕的 DOM 元素。

  2. 取得「關閉」按鈕的 DOM 元素。

  3. 使用 new WeakRef() 建構函式取得日誌顯示視窗的 DOM 元素。這樣,windowElementRef 變數就會持有 DOM 元素的弱參考。

  4. 在「開始傳送訊息」按鈕上新增一個事件監聽器,負責在按一下時啟動記錄器。

  5. 在「關閉」按鈕上新增一個事件監聽器,負責在按一下時關閉日誌顯示視窗。

  6. 使用 setInterval 每秒開始顯示一個新訊息。

  7. 如果日誌顯示視窗的 DOM 元素仍然可以存取並保留在記憶體中,就建立並傳送一個新訊息。

  8. 如果 deref() 方法傳回 undefined,表示 DOM 元素已從記憶體中刪除。在這種情況下,記錄器會停止顯示訊息並清除計時器。

  9. alert,將在日誌顯示視窗的 DOM 元素從記憶體中刪除後(即按一下「關閉」按鈕後)被呼叫。請注意,從記憶體中刪除可能不會立即發生,因為這僅取決於垃圾收集器的內部機制。

    我們無法直接從程式碼控制這個程序。然而,儘管如此,我們仍然可以強制瀏覽器進行垃圾收集。

    例如,在 Google Chrome 中,要執行此操作,您需要開啟開發人員工具(在 Windows/Linux 上按 Ctrl + Shift + J,或在 macOS 上按 Option + + J),前往「效能」標籤,然後按一下垃圾桶圖示按鈕 - 「收集垃圾」


    大多數現代瀏覽器都支援此功能。在執行這些動作後,alert 將會立即觸發。

FinalizationRegistry

現在是討論終結器的時間了。在我們繼續之前,讓我們先釐清一下術語

清除回呼(終結器) – 是一個函式,當註冊在 FinalizationRegistry 中的物件,被垃圾收集器從記憶體中刪除時,就會執行此函式。

它的目的 – 是提供在物件從記憶體中被最終刪除後,執行與物件相關的額外操作的能力。

註冊表(或 FinalizationRegistry) – 是 JavaScript 中的一個特殊物件,用於管理物件及其清除回呼的註冊和取消註冊。

此機制允許註冊一個物件以追蹤並關聯一個清除回呼。基本上,它是一個儲存註冊物件及其清除回呼的資訊的結構,然後在物件從記憶體中刪除時自動呼叫這些回呼。

若要建立 FinalizationRegistry 的執行個體,需要呼叫其建構函式,它會接收一個單一引數 – 清除回呼(終結器)。

語法

function cleanupCallback(heldValue) {
  // cleanup callback code
}

const registry = new FinalizationRegistry(cleanupCallback);

在這裡

  • cleanupCallback – 一個清除回呼,當註冊的物件從記憶體中刪除時,會自動呼叫此回呼。
  • heldValue – 傳遞給清除回呼作為引數的值。如果 heldValue 是物件,則註冊表會保留對它的強參照。
  • registryFinalizationRegistry 的執行個體。

FinalizationRegistry 方法

  • register(target, heldValue [, unregisterToken]) – 用於在註冊表中註冊物件。

    target – 正在註冊以進行追蹤的物件。如果 target 是垃圾收集的,則會使用 heldValue 作為其引數呼叫清除回呼。

    選擇性的 unregisterToken – 一個取消註冊令牌。它可以在垃圾收集器刪除物件之前傳遞,以取消註冊物件。通常,target 物件會用作 unregisterToken,這是標準做法。

  • unregister(unregisterToken)unregister 方法用於從註冊表中取消註冊物件。它接收一個引數 – unregisterToken(在註冊物件時取得的取消註冊令牌)。

現在讓我們繼續一個簡單的範例。我們使用已知的 user 物件並建立 FinalizationRegistry 的執行個體

let user = { name: "John" };

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`${heldValue} has been collected by the garbage collector.`);
});

然後,我們將註冊物件,這需要透過呼叫 register 方法來清理回呼

registry.register(user, user.name);

登錄檔不會保留對正在註冊物件的強參照,因為這會違背其目的。如果登錄檔保留強參照,則物件將永遠不會被垃圾回收。

如果物件被垃圾回收器刪除,我們的清理回呼可能會在將來的某個時間點被呼叫,並將 heldValue 傳遞給它

// When the user object is deleted by the garbage collector, the following message will be printed in the console:
"John has been collected by the garbage collector."

即使在使用清理回呼的實作中,也有一些情況可能會不會呼叫它。

例如

  • 當程式完全終止其運作時(例如,在瀏覽器中關閉分頁時)。
  • FinalizationRegistry 執行個體本身不再可供 JavaScript 程式碼存取時。如果建立 FinalizationRegistry 執行個體的物件超出範圍或被刪除,則在該登錄檔中註冊的清理回呼也可能不會被呼叫。

使用 FinalizationRegistry 進行快取

回到我們的快取範例,我們可以注意到以下事項

  • 即使包裝在 WeakRef 中的值已被垃圾回收器收集,仍然有「記憶體外洩」的問題,其形式為剩餘的鍵,其值已被垃圾回收器收集。

以下是用 FinalizationRegistry 改進的快取範例

function fetchImg() {
  // abstract function for downloading images...
}

function weakRefCache(fetchImg) {
  const imgCache = new Map();

  const registry = new FinalizationRegistry((imgName) => { // (1)
    const cachedImg = imgCache.get(imgName);
    if (cachedImg && !cachedImg.deref()) imgCache.delete(imgName);
  });

  return (imgName) => {
    const cachedImg = imgCache.get(imgName);

    if (cachedImg?.deref()) {
      return cachedImg?.deref();
    }

    const newImg = fetchImg(imgName);
    imgCache.set(imgName, new WeakRef(newImg));
    registry.register(newImg, imgName); // (2)

    return newImg;
  };
}

const getCachedImg = weakRefCache(fetchImg);
  1. 若要管理「失效」快取條目的清理,當相關的 WeakRef 物件被垃圾回收器收集時,我們會建立 FinalizationRegistry 清理登錄檔。

    這裡的重要一點是,在清理回呼中,應檢查條目是否已被垃圾回收器刪除,且未重新加入,以免刪除「有效」的條目。

  2. 一旦新的值(影像)下載並放入快取中,我們會在完成器登錄檔中註冊它,以追蹤 WeakRef 物件。

此實作僅包含實際或「有效」的鍵/值對。在此情況下,每個 WeakRef 物件都會註冊到 FinalizationRegistry 中。而在物件被垃圾回收器清理後,清理回呼會刪除所有 undefined 值。

以下是更新程式碼的視覺化表示

更新實作的一個關鍵面向是,完成處理程式允許在「主程式」和清理回呼之間建立平行處理。在 JavaScript 的脈絡中,「主程式」是我們的 JavaScript 程式碼,在我們的應用程式或網頁中執行和運作。

因此,從垃圾收集器標記物件要刪除的那一刻,到實際執行清理回呼,可能會有一段時間差。重要的是,要了解在此時間差期間,主程式可以對物件進行任何變更,甚至將其帶回記憶體。

這就是為什麼在清理回呼中,我們必須檢查主程式是否已將某個項目新增回快取,以避免刪除「活的」項目。類似地,在快取中搜尋某個金鑰時,垃圾收集器可能會刪除該值,但尚未執行清理回呼。

如果您使用 `FinalizationRegistry`,此類情況需要特別注意。

實際使用 WeakRef 和 FinalizationRegistry

從理論轉到實際,想像一個真實生活中的場景,使用者在行動裝置上將其照片與某個雲端服務(例如 iCloudGoogle Photos)同步,並希望從其他裝置檢視它們。除了檢視照片的基本功能外,此類服務還提供許多額外功能,例如

  • 照片編輯和影片效果。
  • 建立「回憶」和相簿。
  • 從一系列照片製作影片蒙太奇。
  • …等等。

在此,我們將使用此類服務相當原始的實作作為範例。重點是展示在實際生活中同時使用 `WeakRef` 和 `FinalizationRegistry` 的可能場景。

它看起來像這樣


在左側,有一個雲端相片庫(它們以縮圖顯示)。我們可以選擇需要的圖片,並透過按一下頁面右側的「建立拼貼」按鈕來建立拼貼。然後,可以將結果拼貼下載為圖片。

為了增加頁面載入速度,合理的做法是下載並顯示壓縮品質的相片縮圖。但是,要從選定的相片建立拼貼,請下載並使用全尺寸品質的相片。

在下方,我們可以看到縮圖的內在尺寸為 240x240 像素。此尺寸是故意選擇的,以增加載入速度。此外,我們不需要在預覽模式中使用全尺寸相片。


假設我們需要製作 4 張照片的拼貼:我們選取它們,然後按一下「建立拼貼」按鈕。在此階段,我們已知的 weakRefCache 函數會檢查快取中是否有所需的影像。如果沒有,它會從雲端下載並將其放入快取中以供進一步使用。這會發生在每個選取的影像上


注意主控台中輸出的內容,您可以看到哪些照片從雲端下載的,這由 FETCHED_IMAGE 指示。由於這是第一次嘗試建立拼貼,這表示在此階段「弱快取」仍然是空的,而且所有照片都從雲端下載並放入其中。

但是,隨著影像下載的過程,垃圾收集器也會進行記憶體清理的過程。這表示我們使用弱參考所參照的,儲存在快取中的物件會被垃圾收集器刪除。而且我們的完成器會成功執行,從而刪除儲存在快取中的影像的鍵。 CLEANED_IMAGE 會通知我們這件事


接著,我們發現我們不喜歡產生的拼貼,並決定變更其中一張影像並建立新的拼貼。為此,只要取消選取不需要的影像,選取另一張影像,然後再按一下「建立拼貼」按鈕


但是這次並非所有影像都從網路下載,其中一張影像取自弱快取: CACHED_IMAGE 訊息會告訴我們這件事。這表示在建立拼貼時,垃圾收集器尚未刪除我們的影像,而且我們大膽地從快取中取得它,從而減少網路要求的數量並加快拼貼建立流程的整體時間


讓我們再「玩一玩」,再替換其中一張影像並建立新的拼貼


這次的結果更令人印象深刻。在選取的 4 張影像中,有 3 張取自弱快取,而且只有一張必須從網路下載。網路負載減少了約 75%。令人印象深刻,不是嗎?


當然,重要的是要記住,這種行為並非有保證的,而且取決於垃圾收集器的具體實作和操作。

基於此,立即產生一個完全合乎邏輯的問題:為什麼我們不使用一般的快取,在其中我們可以自行管理其實體,而不是依賴垃圾收集器?沒錯,在絕大多數情況下,不需要使用 WeakRefFinalizationRegistry

在這裡,我們只是示範了類似功能的替代實作,使用非平凡的方法搭配有趣的語言功能。不過,如果我們需要恆定且可預測的結果,我們無法依賴這個範例。

你可以在沙箱中開啟此範例

摘要

WeakRef – 設計用來建立物件的弱參照,允許它們在沒有強參照的情況下被垃圾回收器從記憶體中刪除。這對於解決過度使用記憶體和最佳化應用程式中系統資源的使用有益。

FinalizationRegistry – 是一個註冊回呼的工具,當不再強參照的物件被銷毀時執行。這允許在從記憶體中刪除物件之前釋放與物件相關的資源或執行其他必要的作業。

教學課程地圖

評論

在評論之前先閱讀這段…
  • 如果你有建議要改進 - 請提交 GitHub 問題或提交拉取請求,而不是評論。
  • 如果你無法理解文章中的某些內容 - 請說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,對於多行 - 將它們包覆在 <pre> 標籤中,對於超過 10 行 - 使用沙箱 (plnkrjsbincodepen…)