分离窗口内存泄漏

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

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() 返回的值非常相似。即使从 DOM 中移除 iframe 或网址发生更改,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 DevTools 中的堆快照的屏幕截图,其中显示了保留大型对象的引用。
显示保留大型对象的引用的堆快照。

如需记录堆快照,请前往 Chrome 开发者工具中的内存标签页,然后在可用分析类型列表中选择堆快照。记录完成后,Summary 视图会显示内存中的当前对象,按构造函数分组。

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

分析堆转储是一项艰巨的任务,并且在调试过程中很难找到正确的信息。为了帮助解决此问题,Chromium 工程师 yossik@peledni@ 开发了一款独立的堆清理器工具,该工具可帮助突出显示特定节点(例如分离窗口)。对跟踪记录运行堆清理器可从保留图中移除其他不必要的信息,从而使跟踪记录更清晰且更易于阅读。

以编程方式测量内存

堆快照提供了详细的信息,非常适合找出发生泄漏的位置,但拍摄堆快照是一个手动过程。检查内存泄漏的另一种方法是通过 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 事件。由于引用清理代码需要在目标文档卸载时运行,因此我们需要忽略这第一个 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 面板中检查已分离的窗口时,截取堆快照实际上会触发垃圾回收并处理弱引用的窗口。此外,您还可以通过检测 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() 来建立分离。回想一下最初的 Presenter Note 示例,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) 上找到我。