分离窗口内存泄漏

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

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 变量仍然包含引用,而不是点击 Hide Notes 按钮 添加到可访问的弹出式窗口中(即使该弹出式窗口已不再使用)。

<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 引用。

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
使用 Chrome DevTools 演示在窗口发生后保留文档 已关闭。

使用 <iframe> 元素时也可能出现同样的问题。iframe 的行为类似于嵌套窗口 包含文档,其 contentWindow 属性提供对所含 Window 的访问权限 该对象与 window.open() 返回的值非常相似。JavaScript 代码可以保留对 iframe 的 contentWindowcontentDocument(即使 iframe 已从 DOM 或其网址中移除) 这样可防止系统对文档进行垃圾回收,因为其属性 。

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
演示了事件处理脚本即使在导航 iframe 添加到其他网址。

如果 JavaScript 保留了窗口或 iframe 中对 document 的引用, 即使包含的窗口或 iframe 切换到新的 网址。如果保留该引用的 JavaScript 未检测到 窗口/框架已导航到新网址,因为它不知道自己何时成为最后一个网址 在内存中保存文档的引用

已分离的窗口如何导致内存泄漏

使用与主网页位于同一网域的窗口和 iframe 时,系统通常会监听 或跨文档边界访问属性。例如,让我们回顾一下 请参阅本指南开头的演示文稿查看器示例。观看者每开一秒 用于显示演讲者备注的窗口演讲者备注窗口会监听 click 事件作为其提示 即可移到下一张幻灯片。如果用户关闭此备注窗口,则在 原始父级窗口仍然拥有对演讲者备注文档的完整访问权限:

<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 表示窗口仍然被引用,无法作为垃圾进行回收。

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
说明对窗口的引用如何防止窗口关闭后进行垃圾回收。

在很多其他情况下,引用会被意外保留 已分离的窗口不符合垃圾回收条件:

  • 事件处理脚本可在 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>
    

检测因已分离的窗口而导致的内存泄漏

跟踪内存泄漏可能比较棘手。构建孤立的复制品通常很困难 尤其是在涉及多个文档或窗口时。为了更充分地 对潜在的泄露引用进行复杂的检查最终可能会创建 防止对检查的对象进行垃圾回收。为此,建议您先从 专门用于避免实现这种可能性的工具。

若要开始调试内存问题,最好先从 截取堆快照。 这提供了一个时间点视图,了解应用当前使用的内存, 已创建但尚未进行垃圾回收的对象。堆快照包含 对象的相关信息,包括其大小以及 引用它们。

<ph type="x-smartling-placeholder">
</ph> Chrome 开发者工具中堆快照的屏幕截图,显示了保留大型对象的引用。 <ph type="x-smartling-placeholder">
</ph> 显示保留大型对象的引用的堆快照。

要记录堆快照,请转至 Chrome DevTools 中的 Memory 标签页,然后选择堆 Snapshot。录制完成后, Summary 视图可以显示内存中的当前对象,按构造函数分组。

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
演示如何在 Chrome 开发者工具中截取堆快照。

分析堆转储是一项艰巨的任务,可能很难找到正确的 调试信息。为帮助解决这个问题,Chromium 工程师 yossik@peledni@ 开发了 独立的Heap Cleaner工具 比如一个单独的窗口对轨迹运行 Heap Cleaner 可以移除 保留图中的信息,从而使跟踪记录更简洁,更易于阅读。

以编程方式测量内存

堆快照可提供高度细节,非常适合用于找出泄漏发生的位置, 但拍摄堆快照是一个手动过程检查内存泄漏的另一种方法是 performance.memory API 中当前使用的 JavaScript 堆大小:

<ph type="x-smartling-placeholder">
</ph> Chrome 开发者工具界面一部分的屏幕截图。 <ph type="x-smartling-placeholder">
</ph> 在创建、关闭和取消引用弹出窗口时,检查开发者工具中所使用的 JS 堆大小。

performance.memory API 仅提供有关 JavaScript 堆大小的信息,这意味着 但不包括弹出式窗口的文档和资源所使用的内存。要全面了解情况, 需要使用新推出的 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 变量现在会引用打开的窗口,并且该操作 变量可从关闭弹出式窗口按钮点击处理程序的作用域内访问。除非 popup 点击处理程序之前被赋值,或者点击处理程序被移除时,如果处理程序包含对 popup 的引用,则表示其无法 进行垃圾回收。

解决方案:取消设置引用

引用其他窗口或其文档的变量会将其保留在内存中。开始时间 对象始终是引用,为变量指定新值会使其 对原始对象的引用。取消设置就可以重新分配这些引用 变量设置为 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, 可以访问窗口或文档的当前值,同时仍允许这些内容被视为垃圾 。不会在响应中保留对必须手动取消设置的窗口的引用 事件(例如 pagehidewindow.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 进行引用

如果打开了一个无需与网页通信或控制的弹出式窗口, 您可以避免获得对窗口的引用。这对于 。在这些情况下 window.open() 接受 "noopener" 选项,其作用类似于 rel="noopener" 属性(适用于 HTML 链接):

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

"noopener" 选项会导致 window.open() 返回 null,因此无法 意外存储了对弹出式窗口的引用。它还会阻止弹出式窗口获取 引用其父窗口,因为 window.opener 属性将为 null

反馈

希望本文中的一些建议有助于查找和解决内存泄漏问题。如果您 或者使用另一种方法来调试分离的窗口,或者本文可帮助您找出 我也很想知道!您可以在 Twitter (@_developit) 上找到我。