查找并修复因分离的窗口而导致的棘手内存泄漏问题。
什么是 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 引用。
使用 <iframe>
元素时也可能出现同样的问题。iframe 的行为类似于嵌套窗口
包含文档,其 contentWindow
属性提供对所含 Window
的访问权限
该对象与 window.open()
返回的值非常相似。JavaScript 代码可以保留对
iframe 的 contentWindow
或 contentDocument
(即使 iframe 已从 DOM 或其网址中移除)
这样可防止系统对文档进行垃圾回收,因为其属性
。
如果 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
表示窗口仍然被引用,无法作为垃圾进行回收。
在很多其他情况下,引用会被意外保留 已分离的窗口不符合垃圾回收条件:
事件处理脚本可在 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">要记录堆快照,请转至 Chrome DevTools 中的 Memory 标签页,然后选择堆 Snapshot。录制完成后, Summary 视图可以显示内存中的当前对象,按构造函数分组。
<ph type="x-smartling-placeholder">分析堆转储是一项艰巨的任务,可能很难找到正确的 调试信息。为帮助解决这个问题,Chromium 工程师 yossik@ 和 peledni@ 开发了 独立的Heap Cleaner工具 比如一个单独的窗口对轨迹运行 Heap Cleaner 可以移除 保留图中的信息,从而使跟踪记录更简洁,更易于阅读。
以编程方式测量内存
堆快照可提供高度细节,非常适合用于找出泄漏发生的位置,
但拍摄堆快照是一个手动过程检查内存泄漏的另一种方法是
performance.memory
API 中当前使用的 JavaScript 堆大小:
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.host
和 pagehide
事件都不可用。虽然
一般来说,最好避免保留对其他源的引用
要求可以监控 window.closed
或 frame.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
进行引用
如果打开了一个无需与网页通信或控制的弹出式窗口,
您可以避免获得对窗口的引用。这对于
。在这些情况下
window.open()
接受 "noopener"
选项,其作用类似于
rel="noopener"
属性(适用于 HTML 链接):
window.open('https://example.com/share', null, 'noopener');
"noopener"
选项会导致 window.open()
返回 null
,因此无法
意外存储了对弹出式窗口的引用。它还会阻止弹出式窗口获取
引用其父窗口,因为 window.opener
属性将为 null
。
反馈
希望本文中的一些建议有助于查找和解决内存泄漏问题。如果您 或者使用另一种方法来调试分离的窗口,或者本文可帮助您找出 我也很想知道!您可以在 Twitter (@_developit) 上找到我。