語言的「隱藏」特徵
本文涵蓋的主題非常狹窄,大多數開發人員在實踐中很少遇到(甚至可能不知道它的存在)。
如果您剛開始學習 JavaScript,我們建議您跳過本章。
回顧垃圾收集章節中可達性原則的基本概念,我們可以注意到 JavaScript 引擎保證將可存取或正在使用的值保留在記憶體中。
例如:
// 使用者變數持有對該物件的強引用 讓使用者= { 名稱:「約翰」 }; // 讓我們覆蓋使用者變數的值 用戶=空; // 引用遺失,物件將從記憶體中刪除
或類似但稍微複雜的程式碼,有兩個強引用:
// 使用者變數持有對該物件的強引用 讓使用者= { 名稱:「約翰」 }; // 將對物件的強引用複製到 admin 變數中 讓管理員=使用者; // 讓我們覆蓋使用者變數的值 用戶=空; // 該物件仍然可以透過 admin 變數存取
只有當沒有對物件的強烈引用時(如果我們也覆寫了admin
變數的值),物件{ name: "John" }
才會從記憶體中刪除。
在 JavaScript 中,有一個稱為WeakRef
的概念,在這種情況下它的行為略有不同。
術語:“強引用”、“弱引用”
強引用– 是對物件或值的引用,可防止垃圾收集器刪除它們。因此,將物件或值保存在它所指向的記憶體中。
這意味著,物件或值保留在記憶體中,並且不會被垃圾收集器收集,因為存在對其的活動強引用。
在 JavaScript 中,物件的普通引用是強引用。例如:
// 使用者變數持有對此物件的強引用 讓使用者= { 名稱:「約翰」 };
弱引用– 是對物件或值的引用,這不會阻止它們被垃圾收集器刪除。如果物件或值的唯一剩餘引用是弱引用,則垃圾收集器可以刪除它們。
注意事項
在我們深入探討之前,值得注意的是,正確使用本文中討論的結構需要非常仔細的思考,並且如果可能的話最好避免使用它們。
WeakRef
– 是一個對象,包含對另一個物件的弱引用,稱為target
或referent
。
WeakRef
的特點是它不會阻止垃圾收集器刪除其引用物件。換句話說, WeakRef
物件不會使referent
物件保持活動狀態。
現在讓我們將user
變數作為“引用物件”,並建立一個從它到admin
變數的弱引用。要建立弱引用,您需要使用WeakRef
建構函數,傳入目標物件(您想要弱引用的物件)。
在我們的例子中 - 這是user
變數:
// 使用者變數持有對該物件的強引用 讓使用者= { 名稱:「約翰」 }; // admin 變數持有物件的弱引用 讓 admin = new WeakRef(用戶);
下圖描述了兩種類型的引用:使用user
變數的強引用和使用admin
變數的弱引用:
然後,在某個時刻,我們停止使用user
變數-它被覆蓋、超出範圍等,同時將WeakRef
實例保留在admin
變數中:
// 讓我們覆蓋使用者變數的值 用戶=空;
對物件的弱引用不足以使其保持「活動」。當對引用物件的唯一剩餘引用是弱引用時,垃圾收集器可以自由地銷毀該物件並將其記憶體用於其他用途。
然而,在物件實際被銷毀之前,弱引用可能會傳回它,即使不再有對此物件的強引用。也就是說,我們的對象變成了一種「薛丁格的貓」——我們無法確定它是「活著」還是「死了」:
此時,要從WeakRef
實例取得對象,我們將使用其deref()
方法。
deref()
方法傳回WeakRef
指向的參考物件(如果該物件仍在記憶體中)。如果該物件已被垃圾收集器刪除,則deref()
方法將傳回undefined
:
讓 ref = admin.deref(); 如果(參考){ // 該物件仍然可以存取:我們可以用它執行任何操作 } 別的 { // 該物件已被垃圾收集器收集 }
WeakRef
通常用於建立儲存資源密集型物件的快取或關聯數組。這允許人們避免僅根據這些物件在快取或關聯數組中的存在來阻止垃圾收集器收集這些物件。
主要範例之一是當我們有大量二進位影像物件(例如,表示為ArrayBuffer
或Blob
)時的情況,並且我們希望將名稱或路徑與每個影像相關聯。現有的資料結構不太適合這些目的:
使用Map
在名稱和影像之間建立關聯(反之亦然)會將影像物件保留在記憶體中,因為它們作為鍵或值出現在Map
中。
WeakMap
也不符合此目標:因為表示為WeakMap
鍵的物件使用弱引用,且不受垃圾收集器的刪除保護。
但是,在這種情況下,我們需要一個在其值中使用弱引用的資料結構。
為此,我們可以使用Map
集合,其值是引用我們需要的大物件的WeakRef
實例。因此,我們不會將這些大的和不必要的物件在記憶體中保留的時間超過應有的時間。
否則,這是一種從快取中獲取圖像物件(如果圖像物件仍然可訪問)的方法。如果它已被垃圾收集,我們將重新生成或重新下載它。
這樣,在某些情況下可以使用更少的記憶體。
下面的程式碼片段示範了使用WeakRef
的技術。
簡而言之,我們使用帶有字串鍵和WeakRef
物件作為值的Map
。如果WeakRef
物件還沒有被垃圾收集器收集,我們就從快取中取得它。否則,我們再次重新下載它並將其放入快取中以供進一步重複使用:
函數 fetchImg() { // 下載圖片的抽象函數... } 函數weakRefCache(fetchImg) { // (1) const imgCache = new Map(); // (2) return (imgName) => { // (3) const cachedImg = imgCache.get(imgName); // (4) if (cachedImg?.deref()) { // (5) 返回cachedImg?.deref(); } const newImg = fetchImg(imgName); // (6) imgCache.set(imgName, new WeakRef(newImg)); // (7) 返回新圖像; }; } const getCachedImg =weakRefCache(fetchImg);
讓我們深入研究一下這裡發生的事情的細節:
weakRefCache
– 是一個高階函數,它接受另一個函數fetchImg
作為參數。在這個例子中,我們可以忽略fetchImg
函數的詳細描述,因為它可以是任何用於下載圖像的邏輯。
imgCache
– 是映像緩存,以字串鍵(圖像名稱)和WeakRef
物件作為其值的形式儲存fetchImg
函數的快取結果。
傳回以影像名稱作為參數的匿名函數。此參數將用作快取影像的鍵。
嘗試使用提供的鍵(圖像名稱)從快取中獲取快取結果。
如果快取包含指定鍵的值,且WeakRef
物件尚未被垃圾收集器刪除,則傳回快取結果。
如果快取中沒有包含所請求鍵的條目,或者deref()
方法傳回undefined
(表示WeakRef
物件已被垃圾收集),則fetchImg
函數將再次下載映像。
將下載的圖像作為WeakRef
物件放入快取中。
現在我們有一個Map
集合,其中鍵是字串形式的圖像名稱,值是包含圖像本身的WeakRef
物件。
此技術有助於避免為沒有人再使用的資源密集型物件分配大量記憶體。在重複使用快取物件的情況下,它還可以節省記憶體和時間。
以下是此程式碼的直觀表示:
但是,這種實現有其缺點:隨著時間的推移, Map
將填充字串作為鍵,這些字串指向WeakRef
,其所指物件已被垃圾收集:
處理這個問題的一種方法是定期清理快取並清除「死」條目。另一種方法是使用終結器,我們接下來將探討它。
WeakRef
的另一個用例是追蹤 DOM 物件。
讓我們想像一個場景,其中一些第三方程式碼或函式庫與我們頁面上的元素交互,只要它們存在於 DOM 中。例如,它可以是用於監視和通知系統狀態的外部實用程式(通常稱為「記錄器」——發送稱為「日誌」的資訊訊息的程式)。
互動範例:
結果
索引.js
索引.css
索引.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('點選', () => { // (4) startMessages(windowElementRef); startMessagesBtn.disabled = true; }); closeWindowBtn.addEventListener('click', () => document.querySelector(".window__body").remove()); // (5) const startMessages = (元素) => { const timeId = setInterval(() => { // (6) if (element.deref()) { // (7) const 負載 = document.createElement("p"); Payload.textContent = `訊息:系統狀態正常:${new Date().toLocaleTimeString()}`; element.deref().append(有效負載); } 其他 { // (8) alert("該元素已刪除。"); // (9) 清除間隔(timerId); } }, 1000); };
.應用程式 { 顯示:柔性; 彎曲方向:列; 間隙:16px; } .start-messages { 寬度:適合內容; } 。 寬度:100%; 邊框:2px實線#464154; 溢出:隱藏; } .window__header { 位置:黏性; 內邊距:8px; 顯示:柔性; 對齊內容:空間之間; 對齊項目:居中; 背景顏色:#736e7e; } .window__title { 保證金:0; 字體大小:24px; 字體粗細:700; 顏色: 白色; 字母間距:1px; } .window__button { 內邊距:4px; 背景:#4f495c; 概要:無; 邊框:2px實線#464154; 顏色: 白色; 字體大小:16px; 遊標:指針; } .window__body { 高度:250 像素; 內邊距:16px; 溢出:滾動; 背景顏色:#736e7e33; }
<!DOCTYPE HTML> <html lang="en"> <頭> <元字元集=“utf-8”> <link rel="stylesheet" href="index.css"> <title>WeakRef DOM 記錄器</title> </頭> <正文> <div類別=“應用程式”> <button class="start-messages">開始傳送訊息</button> <div 類別=“視窗”> <div class="window__header"> <p class="window__title">訊息:</p> <button class="window__button">關閉</button> </div> <div class="window__body"> 沒有消息。 </div> </div> </div> <腳本類型=“模組”src=“index.js”></腳本> </正文> </html>
當按一下「開始傳送訊息」按鈕時,在所謂的「日誌顯示視窗」(具有.window__body
類別的元素)中,訊息(日誌)開始出現。
但是,一旦從 DOM 中刪除該元素,記錄器就應該停止發送訊息。要重現刪除此元素,只需點擊右上角的“關閉”按鈕即可。
為了不使我們的工作複雜化,並且不在每次 DOM 元素可用時通知第三方程式碼,或者當 DOM 元素不可用時,使用WeakRef
創建對其的弱引用就足夠了。
一旦元素從 DOM 中刪除,記錄器就會注意到它並停止發送訊息。
現在讓我們仔細看看原始碼(選項卡index.js
):
取得「開始傳送訊息」按鈕的 DOM 元素。
取得“關閉”按鈕的 DOM 元素。
使用new WeakRef()
建構函式取得日誌顯示視窗的 DOM 元素。這樣, windowElementRef
變數就保存了 DOM 元素的弱引用。
在「開始傳送訊息」按鈕上新增事件監聽器,負責在按一下時啟動記錄器。
在「關閉」按鈕上新增事件監聽器,負責在點擊時關閉日誌顯示視窗。
使用setInterval
開始每秒顯示一則新訊息。
如果日誌顯示視窗的 DOM 元素仍然可存取並保留在記憶體中,則建立並傳送新訊息。
如果deref()
方法傳回undefined
,則表示 DOM 元素已從記憶體中刪除。在這種情況下,記錄器停止顯示訊息並清除計時器。
alert
,在日誌顯示視窗的 DOM 元素從記憶體中刪除後(即按一下「關閉」按鈕後),將呼叫函數。請注意,從記憶體中刪除可能不會立即發生,因為它僅取決於垃圾收集器的內部機制。
我們無法直接從程式碼中控制這個過程。然而,儘管如此,我們仍然可以選擇強制瀏覽器進行垃圾回收。
例如,在 Google Chrome 中,要執行此操作,您需要開啟開發人員工具(Windows/Linux 上為Ctrl+Shift+J或 macOS 上為Option+⌘+J),前往「效能」選項卡,然後單擊中垃圾箱圖示按鈕 - “收集垃圾”:
大多數現代瀏覽器都支援此功能。採取行動後, alert
將立即觸發。
現在是時候討論終結器了。在繼續之前,讓我們先澄清一下術語:
清理回呼(終結器) ——是當垃圾收集器從記憶體中刪除在FinalizationRegistry
中註冊的物件時執行的函數。
它的目的是在物件最終從記憶體中刪除後,提供執行與物件相關的附加操作的能力。
Registry (或FinalizationRegistry
)-是 JavaScript 中的一個特殊對象,用於管理對象的註冊和取消註冊及其清理回呼。
此機制允許註冊一個物件來追蹤清理回調並將其與其關聯。本質上,它是一個結構,用於存儲有關已註冊對象及其清理回調的信息,然後在從內存中刪除對象時自動調用這些回調。
要建立FinalizationRegistry
的實例,它需要呼叫其建構函數,該構造函數採用單一參數 - 清理回呼(終結器)。
句法:
函數 cleanupCallback(heldValue) { // 清理回呼程式碼 } const 註冊表 = new FinalizationRegistry(cleanupCallback);
這裡:
cleanupCallback
– 當從記憶體中刪除註冊物件時將自動呼叫的清理回調。
heldValue
– 作為參數傳遞給清理回呼的值。如果heldValue
是一個對象,則註冊表會保留對其的強參考。
registry
– FinalizationRegistry
的一個實例。
FinalizationRegistry
方法:
register(target, heldValue [, unregisterToken])
– 用於在登錄中註冊物件。
target
– 被註冊用於追蹤的物件。如果target
被垃圾收集,則將呼叫清理回調,並以heldValue
作為其參數。
可選的unregisterToken
– 取消註冊令牌。可以傳遞它來在垃圾收集器刪除物件之前註銷該物件。通常, target
物件用作unregisterToken
,這是標準做法。
unregister(unregisterToken)
– unregister
方法用於從註冊表中取消註冊物件。它需要一個參數 - unregisterToken
(註冊物件時獲得的註銷令牌)。
現在讓我們繼續看一個簡單的例子。讓我們使用已知的user
物件並建立FinalizationRegistry
的實例:
讓使用者= { 名稱:「約翰」 }; const 註冊表 = 新 FinalizationRegistry((heldValue) => { console.log(`${heldValue} 已被垃圾收集器收集。`); });
然後,我們將透過呼叫register
方法來註冊需要清理回調的物件:
registry.register(用戶, 用戶名);
註冊表不會保留對正在註冊的物件的強烈引用,因為這會違背其目的。如果註冊表保留強引用,則該物件永遠不會被垃圾收集。
如果該物件被垃圾收集器刪除,我們的清理回調可能會在將來的某個時刻被調用,並將heldValue
傳遞給它:
// 當使用者物件被垃圾收集器刪除時,控制台中將列印以下訊息: “約翰已被垃圾收集器收集。”
還有一些情況,即使在使用清理回調的實作中,也有可能不會呼叫它。
例如:
當程式完全終止其操作時(例如,關閉瀏覽器中的選項卡時)。
當 JavaScript 程式碼無法再存取FinalizationRegistry
實例本身時。如果建立FinalizationRegistry
實例的物件超出範圍或被刪除,則也可能不會呼叫在該登錄中註冊的清理回呼。
回到我們的弱快取範例,我們可以注意到以下內容:
即使WeakRef
中包裝的值已被垃圾收集器收集,但剩餘鍵(其值已被垃圾收集器收集)的形式仍然存在「記憶體洩漏」問題。
以下是使用FinalizationRegistry
改進的快取範例:
函數 fetchImg() { // 下載圖片的抽象函數... } 函數weakRefCache(fetchImg) { const imgCache = new Map(); constregistry = new FinalizationRegistry((imgName) => { // (1) const cachedImg = imgCache.get(imgName); if (cachedImg && !cachedImg.deref()) imgCache.delete(imgName); }); 返回 (imgName) => { const cachedImg = imgCache.get(imgName); if (cachedImg?.deref()) { 返回cachedImg?.deref(); } const newImg = fetchImg(imgName); imgCache.set(imgName, new WeakRef(newImg)); 註冊表.register(newImg, imgName); // (2) 返回新圖像; }; } const getCachedImg =weakRefCache(fetchImg);
為了管理「死」快取條目的清理,當垃圾收集器收集關聯的WeakRef
物件時,我們建立一個FinalizationRegistry
清理註冊表。
這裡重要的一點是,在清理回呼中,應該檢查該條目是否已被垃圾收集器刪除並且沒有重新添加,以免刪除「活動」條目。
下載新值(圖像)並將其放入快取後,我們將其註冊到終結器註冊表中以追蹤WeakRef
物件。
此實作僅包含實際或“實時”鍵/值對。在這種情況下,每個WeakRef
物件都在FinalizationRegistry
中註冊。在垃圾收集器清理物件後,清理回呼將刪除所有undefined
值。
以下是更新後的程式碼的直覺表示:
更新實現的一個關鍵方面是終結器允許在「主」程式和清理回調之間創建並行進程。在 JavaScript 的上下文中,「主」程式是我們的 JavaScript 程式碼,它在我們的應用程式或網頁中運行和執行。
因此,從物件被垃圾收集器標記為刪除的那一刻起,到清理回呼的實際執行,可能存在一定的時間間隔。重要的是要理解,在這段時間內,主程式可以對物件進行任何更改,甚至將其帶回記憶體。
這就是為什麼在清理回調中,我們必須檢查主程式是否已將條目新增回緩存,以避免刪除「活動」條目。同樣,當在快取中搜尋某個鍵時,有可能該值已被垃圾收集器刪除,但清理回呼尚未執行。
如果您使用FinalizationRegistry
則需要特別注意這種情況。
從理論轉向實踐,想像一個現實生活場景,使用者將行動裝置上的照片與某些雲端服務(例如 iCloud 或 Google Photos)同步,並希望從其他裝置查看它們。除了查看照片的基本功能外,此類服務還提供許多附加功能,例如:
照片編輯和影片效果。
創建“回憶”和專輯。
一系列照片的影片蒙太奇。
……還有更多。
在這裡,作為範例,我們將使用此類服務的相當原始的實作。重點是展示在現實生活中一起使用WeakRef
和FinalizationRegistry
的可能場景。
它看起來是這樣的:
左側有一個雲端照片庫(它們顯示為縮圖)。我們可以選擇我們需要的圖像並透過點擊頁面右側的「建立拼貼」按鈕來建立拼貼。然後,生成的拼貼畫可以作為圖像下載。
為了提高頁面載入速度,以壓縮品質下載和顯示照片縮圖是合理的。但是,要從選定的照片建立拼貼畫,請以全尺寸品質下載並使用它們。
從下面我們可以看到,縮圖的固有大小是 240x240 像素。選擇尺寸的目的是為了提高加載速度。此外,我們不需要預覽模式的全尺寸照片。
假設我們需要建立 4 張照片的拼貼畫:選擇它們,然後點擊「建立拼貼畫」按鈕。在這個階段,我們已經知道weakRefCache
函數會檢查所需的映像是否在快取中。如果沒有,它會從雲端下載並將其放入快取中以供進一步使用。對於每個選定的影像都會發生這種情況:
注意控制台中的輸出,您可以看到哪些照片是從雲端下載的 - 這由FETCHED_IMAGE指示。由於這是第一次嘗試創建拼貼畫,這意味著,在這個階段「弱快取」仍然是空的,所有照片都是從雲端下載並放入其中的。
但是,除了下載圖像的過程之外,還有垃圾收集器清理記憶體的過程。這意味著,我們使用弱引用引用的儲存在快取中的物件將被垃圾收集器刪除。我們的終結器成功執行,從而刪除了將影像儲存在快取中的金鑰。 CLEANED_IMAGE通知我們:
接下來,我們意識到我們不喜歡最終的拼貼畫,並決定更改其中一個圖像並創建一個新圖像。為此,只需取消選擇不需要的圖像,選擇另一張圖像,然後再次按一下「建立拼貼」按鈕:
但這次並非所有圖像都是從網路下載的,其中一張圖像是從弱快取中獲取的: CACHED_IMAGE訊息告訴我們這一點。這意味著在拼貼創建時,垃圾收集器還沒有刪除我們的圖像,我們大膽地從快取中取出它,從而減少了網路請求數量並加快了拼貼創建過程的整體時間:
讓我們再“玩一玩”,再次替換其中一張圖像並創建一個新的拼貼畫:
這次的結果更加令人印象深刻。所選的 4 張圖片中,有 3 張是從弱快取中取得的,只有一張需要從網路下載。網路負載減少約75%。令人印象深刻,不是嗎?
當然,重要的是要記住,這種行為是不能保證的,並且取決於垃圾收集器的具體實現和操作。
基於此,一個完全合乎邏輯的問題立即出現:為什麼我們不使用普通的緩存,我們可以自己管理其實體,而不是依賴垃圾收集器?沒錯,絕大多數情況下不需要使用WeakRef
和FinalizationRegistry
。
在這裡,我們簡單地演示了類似功能的替代實現,使用具有有趣語言功能的非平凡方法。儘管如此,如果我們需要一個恆定且可預測的結果,我們就不能依賴這個例子。
您可以在沙箱中開啟此範例。
WeakRef
– 旨在創建對物件的弱引用,如果不再有對它們的強引用,則允許垃圾收集器從記憶體中刪除它們。這有利於解決記憶體使用過多的問題並優化應用程式中系統資源的利用率。
FinalizationRegistry
– 是一個用來註冊回呼的工具,當不再強引用的物件被銷毀時執行該回呼。這允許在從記憶體中刪除物件之前釋放與物件關聯的資源或執行其他必要的操作。