卸離視窗記憶體流失

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

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 瀏覽至其他網址後也一樣。

如果視窗或 iframe 中的 document 參照由 JavaScript 保留,即使所屬的視窗或 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」分頁,然後在可用剖析類型清單中選取「HeapSnapshot」。記錄完成後,Summary 檢視畫面會顯示記憶體中的目前物件,並依建構函式分組。

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

分析記憶體快照資料是一項艱鉅的工作,而且在偵錯過程中,可能很難找出正確的資訊。為協助解決這個問題,Chromium 工程師 yossik@peledni@ 開發了獨立的 Heap Cleaner 工具,以便醒目顯示特定節點,例如卸離視窗。對追蹤記錄執行堆積清理工具,就能從保留圖中移除其他不必要的資訊,讓追蹤記錄更加簡潔且更容易閱讀。

透過程式輔助方式測量記憶體

堆積快照可以提供詳盡的細節,因此很適合用來找出流失情況。不過,堆積快照必須手動執行。另一個檢查記憶體流失的方法是從 performance.memory API 取得目前使用的 JavaScript 堆積大小:

Chrome 開發人員工具使用者介面部分的螢幕截圖。
在建立、關閉及未參照的彈出式視窗時,查看開發人員工具中已使用的 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 事件。由於在卸載 target 文件時,我們的參考清除程式碼需要執行,因此我們必須忽略這個第一個 pagehide 事件。有很多方法可以這麼做,最簡單的方法就是忽略來自初始文件 about:blank 網址的頁面隱藏事件。它在彈出式範例中看起來會像這樣:

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 開發人員工具的「記憶體」面板中檢查卸離的視窗時,擷取堆積快照實際上會觸發垃圾收集,並處置強度不足的視窗。您也可以透過偵測 deref() 傳回 undefined 的時間,或使用新的 FinalizationRegistry API 來檢查透過 WeakRef 參照的物件是否已從 JavaScript 處理:

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 找我。