分离窗口内存泄漏

查找并修复由分离窗口导致的棘手内存泄漏问题。

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 元素或弹出式窗口)时,就会发生一种特别棘手的内存泄漏。这类对象可能会在应用不知情的情况下变为未使用,这意味着应用代码可能拥有对某个对象的唯一剩余引用,否则该对象可能会被垃圾回收。

什么是分离式窗口?

在以下示例中,幻灯片查看器应用包含用于打开和关闭演示者备注弹出式窗口的按钮。假设用户点击显示记事,然后直接关闭弹出式窗口,而不是点击隐藏记事按钮,则 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() 返回的值一样。JavaScript 代码可以保留对 iframe 的 contentWindowcontentDocument 的引用,即使 iframe 从 DOM 中移除或其网址发生变化也是如此,这会阻止系统对文档进行垃圾回收,因为其属性仍然可以访问。

演示了事件处理脚本如何保留 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 DevTools 中堆快照的屏幕截图,显示了保留大型对象的引用。
堆快照,显示保留大型对象的引用。

如需记录堆快照,请前往 Chrome DevTools 中的内存标签页,然后在可用性能分析类型列表中选择堆快照。录制完成后,摘要视图会显示内存中的当前对象,并按构造函数进行分组。

演示如何在 Chrome 开发者工具中获取堆快照。

分析堆转储可能是一项艰巨的任务,在调试过程中,找到正确的信息可能非常困难。为此,Chromium 工程师 yossik@peledni@ 开发了一款独立的堆整理工具,可帮助突出显示特定节点(例如分离的窗口)。对轨迹运行堆整理器会从保留率图表中移除其他不必要的信息,从而使轨迹更清晰、更易于阅读。

以编程方式测量内存

堆快照提供详细信息,非常适合找出发生内存泄漏的位置,但获取堆快照是一个手动过程。检查内存泄漏的另一种方法是从 performance.memory API 获取当前使用的 JavaScript 堆大小:

Chrome DevTools 界面部分的屏幕截图。
在创建、关闭和取消引用弹出式窗口时,检查 DevTools 中使用的 JS 堆大小。

performance.memory API 仅提供有关 JavaScript 堆大小的信息,这意味着它不包含弹出式窗口的文档和资源使用的内存。为了全面了解情况,我们需要使用目前在 Chrome 中试用的新 performance.measureUserAgentSpecificMemory() API

避免窗户脱落造成漏水的解决方案

分离的窗口导致内存泄漏的最常见两种情况是:父文档保留对已关闭的弹出式窗口或已移除的 iframe 的引用;以及窗口或 iframe 发生意外导航,导致事件处理脚本永远无法取消注册。

示例:关闭弹出式窗口

在以下示例中,两个按钮用于打开和关闭弹出式窗口。为了让关闭弹出式窗口按钮正常运行,系统会将对打开的弹出式窗口的引用存储在变量中:

<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 DevTools 的内存面板中检查是否有分离的窗口时,获取堆快照实际上会触发垃圾回收并处置弱引用的窗口。您还可以通过检测 deref() 何时返回 undefined 或使用新的 FinalizationRegistry API 来检查是否已从 JavaScript 中处置通过 WeakRef 引用的对象:

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