卸離視窗記憶體流失

找出並修正由分離視窗造成的記憶體流失問題。

Bartek Nowierski
Bartek Nowierski

什麼是 JavaScript 中的記憶體流失?

記憶體流失是指應用程式在一段時間內,不經意地增加所使用的記憶體量。在 JavaScript 中,當物件不再需要,但仍由函式或其他物件參照時,就會發生記憶體流失。這些參照可避免垃圾收集器回收不需要的物件。

垃圾收集器的工作是找出應用程式無法再存取的物件,並回收這些物件。即使物件會參照自身,或會循環參照彼此,這項做法也能正常運作。一旦應用程式無法透過剩餘的參照存取一組物件,這些物件就會進行垃圾收集。

let A = {};
console.log(A); // local variable reference

let B = {A}; // B.A is a second reference to A

A = null; // unset local variable reference

console.log(B.A); // A can still be referenced by B

B.A = null; // unset B's reference to A

// No references to A are left. It can be garbage collected.

當應用程式參照具有自身生命週期的物件 (例如 DOM 元素或彈出式視窗) 時,就會發生特別棘手的記憶體耗損類別。這類物件可能會在應用程式不知情的情況下未使用,也就是說,應用程式程式碼可能只會保留物件的參照,而該物件可能會遭到垃圾收集。

什麼是分離視窗?

在以下範例中,幻燈片檢視器應用程式包含用於開啟及關閉簡報者筆記彈出式視窗的按鈕。假設使用者按一下「Show Notes」,然後直接關閉彈出式視窗,而不是按一下「Hide Notes」按鈕,notesWindow 變數仍會保留可存取的彈出式視窗參照,即使彈出式視窗已不再使用。

<button id="show">Show Notes</button>
<button id="hide">Hide Notes</button>
<script type="module">
  let notesWindow;
  document.getElementById('show').onclick = () => {
    notesWindow = window.open('/presenter-notes.html');
  };
  document.getElementById('hide').onclick = () => {
    if (notesWindow) notesWindow.close();
  };
</script>

這是分離視窗的範例。彈出式視窗已關閉,但我們的程式碼含有對該視窗的參照,因此瀏覽器無法銷毀彈出式視窗並回收記憶體。

當網頁呼叫 window.open() 以建立新的瀏覽器視窗或分頁時,系統會傳回代表視窗或分頁的 Window 物件。即使這類視窗已關閉或使用者已離開,從 window.open() 傳回的 Window 物件仍可用於存取相關資訊。這是一種分離式視窗:由於 JavaScript 程式碼仍可能存取已關閉的 Window 物件屬性,因此必須保留在記憶體中。如果視窗包含許多 JavaScript 物件或 iframe,除非沒有任何 JavaScript 參照視窗的屬性,否則無法回收記憶體。

使用 Chrome 開發人員工具,說明如何在關閉視窗後保留文件。

使用 <iframe> 元素時也會發生同樣的問題。Iframe 的運作方式就像包含文件的巢狀視窗,而其 contentWindow 屬性可提供對包含的 Window 物件的存取權,就像 window.open() 所傳回的值一樣。即使 iframe 已從 DOM 中移除或其網址已變更,JavaScript 程式碼仍可保留對 iframe 的 contentWindowcontentDocument 的參照,這可防止文件遭到垃圾收集,因為其屬性仍可存取。

示範事件處理常式如何保留 iframe 的文件,即使 iframe 已導向至其他網址也一樣。

如果從 JavaScript 保留對視窗或 iframe 內 document 的參照,即使包含的視窗或 iframe 導覽至新的網址,該文件仍會保留在記憶體中。當持有該參照的 JavaScript 無法偵測到視窗/框架已導向新網址時,這可能會特別麻煩,因為它不知道何時會成為儲存在記憶體中的最後一個參照。

分離視窗如何導致記憶體流失

當您在與主要網頁相同的網域中使用視窗和 iframe 時,通常會在跨越文件邊界時監聽事件或存取屬性。舉例來說,讓我們回顧本指南開頭的簡報檢視器範例變化版本。觀眾會開啟第二個視窗,用於顯示演講者備忘稿。演講者備忘稿視窗會監聽 click 事件,做為轉移至下一張投影片的提示。如果使用者關閉這個筆記視窗,在原始父項視窗中執行的 JavaScript 仍可完全存取講者筆記文件:

<button id="notes">Show Presenter Notes</button>
<script type="module">
  let notesWindow;
  function showNotes() {
    notesWindow = window.open('/presenter-notes.html');
    notesWindow.document.addEventListener('click', nextSlide);
  }
  document.getElementById('notes').onclick = showNotes;

  let slide = 1;
  function nextSlide() {
    slide += 1;
    notesWindow.document.title = `Slide  ${slide}`;
  }
  document.body.onclick = nextSlide;
</script>

假設我們關閉由 showNotes() 建立的瀏覽器視窗。沒有事件處理常式會監聽並偵測視窗是否已關閉,因此沒有任何資訊會告知程式碼應清除任何文件參照。nextSlide() 函式仍處於「運作中」狀態,因為它在主頁面中已綁定為點擊處理常式,而 nextSlide 包含 notesWindow 的參照,表示視窗仍處於參照狀態,無法進行垃圾收集。

插圖:說明如何透過參照視窗,避免視窗在關閉後遭到垃圾收集。

還有許多其他情況會導致系統意外保留參照,進而導致分離的視窗無法進行垃圾收集:

  • 事件處理常式可在 iframe 的初始文件中註冊,在框架導向其預期的網址之前,導致意外參照文件,並在清除其他參照後持續保留 iframe。

  • 在視窗或 iframe 中載入的記憶體密集型文件,在導覽至新網址後,可能會意外保留在記憶體中很長一段時間。這通常是因為父頁面保留文件參照,以便移除事件監聽器。

  • 將 JavaScript 物件傳遞至其他視窗或 iframe 時,物件的原型鏈結會包含對其建立環境的參照,包括建立該環境的視窗。也就是說,避免保留其他視窗物件的參照,與避免保留視窗本身的參照一樣重要。

    index.html:

    <script>
      let currentFiles;
      function load(files) {
        // this retains the popup:
        currentFiles = files;
      }
      window.open('upload.html');
    </script>
    

    upload.html:

    <input type="file" id="file" />
    <script>
      file.onchange = () => {
        parent.load(file.files);
      };
    </script>
    

偵測由分離視窗造成的記憶體流失

追蹤記憶體流失問題可能很棘手。要單獨重現這些問題通常很困難,尤其是涉及多個文件或視窗時。更複雜的是,檢查潛在的遺漏參照可能會產生額外參照,導致檢查的物件無法進行垃圾收集。為此,建議您先使用專門避免引入此可能性的工具。

擷取堆積快照,是開始偵錯記憶體問題的絕佳起點。這可提供應用程式目前使用的記憶體點狀檢視畫面,也就是所有已建立但尚未進行垃圾收集的物件。堆積快照包含物件的實用資訊,包括物件大小,以及參照物件的變數和關閉項目清單。

Chrome 開發人員工具中堆積集快照的螢幕截圖,顯示保留大型物件的參照。
堆積快照,顯示保留大型物件的參照。

如要記錄堆積快照,請前往 Chrome 開發人員工具的「Memory」分頁,然後在可用的剖析類型清單中選取「Heap Snapshot」。錄製完成後,「Summary」檢視畫面會顯示記憶體中的目前物件,並依建構函式分組。

示範如何在 Chrome 開發人員工具中擷取堆積快照。

分析堆積傾印作業可能會是一項艱鉅的任務,而且在進行除錯時,要找出正確的資訊可能相當困難。為協助解決這個問題,Chromium 工程師 yossik@peledni@ 開發了獨立的 堆積清理器工具,可用來醒目顯示特定節點,例如分離視窗。在追蹤記錄上執行堆積清理工具,可從保留圖表中移除其他不必要的資訊,讓追蹤記錄更清晰,也更容易閱讀。

透過程式輔助測量記憶體

堆積快照可提供詳細資料,非常適合用來找出發生漏洞的位置,但擷取堆積快照是手動程序。另一種檢查記憶體流失的方法,是從 performance.memory API 取得目前使用的 JavaScript 堆積大小:

Chrome 開發人員工具使用者介面的螢幕截圖。
在 DevTools 中檢查彈出式視窗建立、關閉和未參照時使用的 JS 堆積大小。

performance.memory API 只提供 JavaScript 堆積大小的相關資訊,也就是說,它不包含彈出式視窗的文件和資源所使用的記憶體。為了全面掌握情況,我們需要使用目前在 Chrome 中測試的新 performance.measureUserAgentSpecificMemory() API

避免分離視窗洩漏的解決方案

最常見的兩種情況是,當父項文件保留關閉的彈出式視窗或已移除的 iFrame 參照,以及當視窗或 iFrame 的意外導覽導致事件處理常式從未取消註冊時,就會造成分離視窗的記憶體耗損。

範例:關閉彈出式視窗

在以下範例中,我們使用兩個按鈕開啟及關閉彈出式視窗。為了讓「Close Popup」按鈕正常運作,系統會將已開啟的彈出式視窗參照值儲存在變數中:

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = window.open('/login.html');
  };
  close.onclick = () => {
    popup.close();
  };
</script>

乍看之下,上述程式碼似乎避免了常見的陷阱:不會保留對彈出式視窗的參照,也不會在彈出式視窗上註冊事件處理常式。不過,一旦點選「Open Popup」按鈕,popup 變數現在會參照已開啟的視窗,且可透過「Close Popup」按鈕點按處理常式的範圍存取該變數。除非重新指派 popup 或移除點擊處理常式,否則該處理常式對 popup 的封閉參照,表示無法進行垃圾收集。

解決方法:取消設定參照

參照其他視窗或其文件的變數會導致該視窗保留在記憶體中。由於 JavaScript 中的物件一律是參照,因此將新值指派給變數會移除其對原始物件的參照。如要「取消設定」物件的參照,我們可以將這些變數重新指派給 null 值。

將這項做法套用至先前的彈出式視窗範例,我們可以修改關閉按鈕處理常式,讓它「取消設定」對彈出式視窗的參照:

let popup;
open.onclick = () => {
  popup = window.open('/login.html');
};
close.onclick = () => {
  popup.close();
  popup = null;
};

這有助於解決問題,但也揭露了使用 open() 建立的視窗所特有的另一個問題:如果使用者關閉視窗,而不是按一下自訂關閉按鈕,該怎麼辦?此外,如果使用者開始在我們開啟的視窗中瀏覽其他網站,該怎麼辦?雖然在點選關閉按鈕時取消設定 popup 參照似乎足以解決問題,但如果使用者未使用該特定按鈕關閉視窗,仍會發生記憶體耗損。解決這個問題需要偵測這些情況,以便在發生時取消設定殘留的參照。

解決方法:監控及處置

在許多情況下,負責開啟視窗或建立框架的 JavaScript 並未獨占其生命週期。使用者可以關閉彈出式視窗,或是前往新文件,這可能會導致先前由視窗或框架所包含的文件變成未連結。在兩種情況下,瀏覽器都會觸發 pagehide 事件,表示正在卸載文件。

pagehide 事件可用於偵測關閉的視窗,以及離開目前文件的導覽。不過,請注意以下重要事項:所有新建立的視窗和 iframe 都會包含空白文件,然後非同步前往指定的網址 (如果有提供的話)。因此,在建立視窗或影格後,系統會在目標文件載入之前,立即觸發初始 pagehide 事件。由於參照清理程式碼需要在 目標文件卸載時執行,因此我們必須忽略這第一個 pagehide 事件。這麼做的方法有很多種,其中最簡單的方法是忽略來自初始文件 about:blank 網址的 pagehide 事件。以下是彈出式視窗範例的樣子:

let popup;
open.onclick = () => {
  popup = window.open('/login.html');

  // listen for the popup being closed/exited:
  popup.addEventListener('pagehide', () => {
    // ignore initial event fired on "about:blank":
    if (!popup.location.host) return;

    // remove our reference to the popup window:
    popup = null;
  });
};

請務必注意,這項技巧僅適用於與執行程式碼的父頁面具有相同有效來源的視窗和框架。從其他來源載入內容時,location.hostpagehide 事件皆因安全性考量而無法使用。雖然一般來說,最好避免保留對其他來源的參照,但在少數需要保留參照的情況下,您可以監控 window.closedframe.isConnected 屬性。當這些屬性變更,表示已關閉視窗或移除 iframe 時,建議您取消對這些屬性的任何參照。

let popup = window.open('https://example.com');
let timer = setInterval(() => {
  if (popup.closed) {
    popup = null;
    clearInterval(timer);
  }
}, 1000);

解決方案:使用 WeakRef

JavaScript 最近開始支援一種新的參照物件方式,可進行垃圾收集,稱為 WeakRef。為物件建立的 WeakRef 並非直接參照,而是提供特殊 .deref() 方法的個別物件,只要物件未經垃圾收集,就會傳回物件的參照。使用 WeakRef 時,您可以存取視窗或文件的目前值,同時允許垃圾收集。系統不會保留對視窗的參照,因為在回應 pagehide 等事件或 window.closed 等屬性時,必須手動取消設定該參照。相反地,系統會視需要取得對視窗的存取權。視窗關閉後,系統會進行垃圾收集,導致 .deref() 方法開始回傳 undefined

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = new WeakRef(window.open('/login.html'));
  };
  close.onclick = () => {
    const win = popup.deref();
    if (win) win.close();
  };
</script>

使用 WeakRef 存取視窗或文件時,有一個值得注意的細節:在視窗關閉或 iframe 移除後,參照通常會在短時間內保留。這是因為 WeakRef 會持續傳回值,直到其相關聯的物件經過垃圾收集為止,而垃圾收集作業會在 JavaScript 中以非同步方式進行,通常會在閒置時間執行。值得慶幸的是,在 Chrome 開發人員工具的「Memory」面板中檢查是否有分離視窗時,擷取堆積快照實際上會觸發垃圾收集,並處置弱參照的視窗。您也可以檢查透過 WeakRef 參照的物件是否已從 JavaScript 中處置,方法是偵測 deref() 何時傳回 undefined,或是使用新的 FinalizationRegistry API

let popup = new WeakRef(window.open('/login.html'));

// Polling deref():
let timer = setInterval(() => {
  if (popup.deref() === undefined) {
    console.log('popup was garbage-collected');
    clearInterval(timer);
  }
}, 20);

// FinalizationRegistry API:
let finalizers = new FinalizationRegistry(() => {
  console.log('popup was garbage-collected');
});
finalizers.register(popup.deref());

解決方法:透過 postMessage 進行通訊

偵測視窗關閉或導覽卸載文件的時間,可讓我們移除處理程序並取消設定參照,以便收回分離的視窗。不過,這些變更是針對有時可能更為根本的疑慮所做的特定修正:網頁之間的直接連結。

您可以使用更全面的替代方法,避免視窗和文件之間出現過時的參照:將跨文件通訊限制在 postMessage() 上,藉此建立區隔。回顧原始的簡報者筆記範例,nextSlide() 這類函式會直接參照筆記視窗並操控其內容,藉此更新筆記視窗。而是讓主要頁面以非同步方式,透過 postMessage() 間接將必要資訊傳遞至筆記視窗。

let updateNotes;
function showNotes() {
  // keep the popup reference in a closure to prevent outside references:
  let win = window.open('/presenter-view.html');
  win.addEventListener('pagehide', () => {
    if (!win || !win.location.host) return; // ignore initial "about:blank"
    win = null;
  });
  // other functions must interact with the popup through this API:
  updateNotes = (data) => {
    if (!win) return;
    win.postMessage(data, location.origin);
  };
  // listen for messages from the notes window:
  addEventListener('message', (event) => {
    if (event.source !== win) return;
    if (event.data[0] === 'nextSlide') nextSlide();
  });
}
let slide = 1;
function nextSlide() {
  slide += 1;
  // if the popup is open, tell it to update without referencing it:
  if (updateNotes) {
    updateNotes(['setSlide', slide]);
  }
}
document.body.onclick = nextSlide;

雖然這仍需要視窗彼此參照,但兩者都不會保留對其他視窗中目前文件的參照。訊息傳遞方法也鼓勵在單一位置保留視窗參照的設計,也就是說,當視窗關閉或導覽離開時,只需要取消設定單一參照。在上述範例中,只有 showNotes() 會保留筆記視窗的參照,並使用 pagehide 事件確保清除參照。

解決方法:避免使用 noopener 做為參照

如果網頁開啟的彈出式視窗不需要與該視窗通訊或控制該視窗,您可以避免取得該視窗的參照。這項功能特別適合用於建立會載入其他網站內容的視窗或 iframe。在這些情況下,window.open() 會接受 "noopener" 選項,其運作方式與 HTML 連結的 rel="noopener" 屬性相同:

window.open('https://example.com/share', null, 'noopener');

"noopener" 選項會導致 window.open() 傳回 null,因此不會意外儲存彈出式視窗的參照。由於 window.opener 屬性會變成 null,因此這也會防止彈出式視窗取得其父項視窗的參照。

意見回饋

希望本文的部分建議能協助您找出並修正記憶體流失問題。如果您有其他偵錯分離視窗的技術,或是這篇文章有助於找出應用程式中的漏洞,歡迎與我們分享!歡迎在 Twitter 上追蹤我 (@_developit)。