大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。

Chrome Dev Console 附带的 queryObjects API 是观察 JavaScript 中垃圾收集如何工作的一种非常简单的方法。
内存泄漏在 JavaScript 社区中一直是一个未被充分讨论的话题,主要是因为与服务器端的 JavaScript 相比,浏览器端 JavaScript 通常是短暂的。 即,大多数时候,网页不会长时间保持打开,一旦用户刷新页面或关闭选项卡,JavaScript 保留的内存都会自动释放和回收。
近年来随着 SPA 的兴起,内存泄漏问题变得更加突出。识别和调试 JavaScript 内存泄漏涉及:拍摄堆快照、手动比较堆快照、消除噪音并找出导致泄漏的原因,而且整个过程非常耗时且无法轻松自动化。
注意:服务器渲染的网站也可能在服务器端泄漏内存,但在客户端泄漏内存的可能性极小,因为每次在页面之间导航时浏览器都会清除内存。
本篇文章展示如何利用 queryObjects 方法从 DevTools 控制台检测 Chrome 中内存泄漏。
1. 什么是 queryObjectsqueryObjects 接受一个构造函数参数并返回使用输入构造函数创建的对象数组。
const array = new Uint8Array(1_000_000_000);// 分配 1GB 内存queryObjects(Uint8Array);// 打印所有存在的 Uint8Array 示例(页面不刷新每次执行都会重新分配)// Array(1) -> Array(2) -> Array(3) 除非刷新queryObjects 看起来像是一个 API,用于查询到目前为止已创建给定类的实例数量。但是其有一个黑魔法,即在调用时强制进行一次垃圾回收。
queryObjects 在触发垃圾收集后再查询内存中活动对象的能力是非常有用的,可以作为观察和理解内存泄漏如何发生的工具,而无需频繁查看和比较堆快照。除此之外,开发者甚至可以用它编写自动化测试。
以下代码来自 standardized-audio-context 库使用 puppeteer 的自动化示例:
// 具体代码链接查看末尾参考资料const countObjects = async (page) => { const prototypeHandle = await page.evaluateHandle(() => Object.prototype); const objectsHandle = await page.queryObjects(prototypeHandle); const numberOfObjects = await page.evaluate((instances) => instances.length, objectsHandle); await Promise.all([prototypeHandle.dispose(), objectsHandle.dispose()]); return numberOfObjects;};2. 最小可重现内存泄漏示例以下示例是一些最常见的内存泄漏模式。
2.1 全局对象引入内存泄漏的最简单方法可能是向全局对象添加属性,例如 window。
function fn() { foo = new Uint8Array(1_000_000_000);}fn();queryObjects(Uint8Array);// 打印 Uint8Array 数组当然也包括以下情况:
addEventListener:这是最常见的一种,需要调用 removeEventListener 来清理。比如下面是 React, Vue, Svelte 等常见的组件模型:window.addEventListener('message', this.onMessage.bind(this));此时,开发者在全局对象(window、、 等)上调用 addEventListener,然后在 unload 组件时未使用 removeEventListener 清理从而产生内存泄漏。
更糟糕的是,以上代码泄露了整个组件。 因为 this.onMessage 绑定到了 this,所以不仅组件本身已经泄漏,也包括所有子组件。因此,可以使用下面方法解决泄漏问题:
// Mount phasethis.onMessage = this.onMessage.bind(this);window.addEventListener('message', this.onMessage);// Unmount phasewindow.removeEventListener('message', this.onMessage);IntersectionObserver、ResizeObserver、MutationObserver :新的 API 非常方便,但也容易泄漏。 如果在组件内部创建一个组件,并将其附加到全局可用的元素,那么需要调用 disconnect() 来清理。 请注意,被垃圾收集的 DOM 节点也会对其侦听器和观察者进行垃圾收集。因此,通常情况下,开发者只需要担心全局元素,例如 <body>、document、无所不在 header/footer 元素等// 浏览器本身的泄漏,每次点击都会泄漏一个元素,本例中为 100mb// https://issues.chromium.org/issues/40772627const weakMap = new WeakMap();button.onclick = () => { const el = document.createElement('div'); weakMap.set(el, new Uint8Array(1000 * 1000 * 100)); new ResizeObserver( () => console.log('resize', el.getBoundingClientRect()) ).observe(el); document.body.append(el); setTimeout(() => el.remove(), 0);}Observables、EventEmitters :如果忘记停止监听,任何设置监听器的编程模型都可能会泄漏内存全局对象存储:对于 Redux ,状态是全局的,所以如果不小心可以不断地追加内存且永远不会被清理无限 DOM 增长: 如果在没有虚拟化的情况下实现无限滚动列表,那么 DOM 节点的数量将会无限制地增长2.2 闭包JavaScript 闭包使得函数可以轻松访问在函数外部声明的变量。
但是,在定义这些变量的函数执行完成之后,JavaScript 引擎也必须将变量保留在内存中,从而可能导致潜在的内存泄漏。
在以下示例中,返回的函数 bar 通过闭包继续引用 test,从而保留为其分配的内存。
class Test {}function foo() { const test = new Test(); test.a = new Uint8Array(1_000_000_000); test.b = 'foo'; return () => { console.log(test.b); };}const bar = foo();queryObjects(Uint8Array);// ❌ [uint8Array]queryObjects(Test);// ❌ [{Test}]当然,开发者可以通过添加对 test.b 的单独引用来修复以上问题。
function foo() { const test = new Test(); test.a = new Uint8Array(1_000_000_000); test.b = 'foo'; const {b} = test; return () => { console.log(b); };}queryObjects(Uint8Array); // ✅ empty arrayqueryObjects(Test); // ✅ empty array以上问题还比较简单,更复杂的例子是当开发者在闭包中捕获了一个只写变量时:
function fn() { let a; return () => { a = new Uint8Array(1_000_000_000); };}const foo = fn();foo();在上面示例中, foo 不会从闭包中读取变量 a 而是写入。 最重要的是,无法从外部访问闭包的变量 a,看起来似乎可以安全地进行垃圾收集。 然而,queryObjects 的测试表明,为 a 分配的内存仍然保留。
function fn() { let a; return () => { a = new Uint8Array(1_000_000_000); };}const foo = fn();foo();queryObjects(Uint8Array); // ❌ [uint8Array]理论上,高度优化的 JavaScript 引擎可以释放为这种情况保留的内存,因为其是只写的。然而,这并不是一件容易的事,因为其面临着暂停问题(Halting Problem)。此外,通过 linter 可以轻松防止这种错误。
{ "rules": { "no-unused-vars": ["error", { "vars": "all", "args": "after-used", "ignoreRestSiblings": false}] }}2.3 setIntervalsetInterval 的重复执行往往使问题变得更加严重,引起的内存泄漏会迅速升级。
但值得注意的是,setInterval 本身不会导致内存泄漏,即不会阻止传递给它的回调被垃圾收集:
const myQueryObject = queryObjects;// had to call the original queryObjects from a different referencefunction foo() { const array = new Uint8Array(1_000_000_000); myQueryObject(Uint8Array); // ✅ empty array}setInterval(foo, 1000);以上示例即使每秒调用 foo,一旦 foo 执行完毕,数组的内存就会被释放,即并未发生内存泄漏。
但是,嵌套的 setIntervals 会导致内存泄漏。 在下面的示例中,可以观察到 queryObjects 打印出一个不断增长的数组,该数组每秒都会添加一个新的 Uint8Array。
const myQueryObject = queryObjects;function foo() { const array = new Uint8Array(1_000); setInterval(() => { array; // referencing `array` }, 1000); myQueryObject(Uint8Array); // ever-growing array of uint8Arrays}setInterval(foo, 1000);修复方法很简单:只需通过 clearInterval 清除嵌套的 setInterval 即可。
const myQueryObject = queryObjects;function foo() { const array = new Uint8Array(1_000); const intervalId = setInterval(() => { array; // referencing `array` }, 1000); myQueryObject(Uint8Array); // ✅ empty array clearInterval(intervalId);}setInterval(foo, 1000);2.4 PromisesNolan 在他关于与 Promise 相关的内存泄漏的博客文章中指出,“如果 Promise 从未被 resolve 或 reject,则可能会泄漏,在这种情况下,附加到 Promise 任何 .then() 回调都会泄漏。” 借助 queryObjects,可以轻松地从 DevTools 控制台验证这是否(仍然)成立。
下面的代码片段创建了一个 Promise ,并且永远不会 resolve 或 reject。 然后附加一个回调,将一个巨大的 Uint8Array 分配给 then。
const p = new Promise((resolve, reject) => {});const giantArrayPromise = p.then(() => new Uint8Array(1_000_000_000));queryObjects(Uint8Array); // ✅ empty array因为 Promise 永远不会 resolve,因此永远不会调用回调。因此, Uint8Array 一开始就不应该被分配。 接下来稍微调整一下前面的例子:
let giantArray = new Uint8Array(1_000_000_000);function foo() { const a = giantArray; return () => a;}const p = new Promise((resolve, reject) => {});p.then(foo());// 构造函数没有抛出错误则执行 then// 此时 p 仍然是 Promise {<pending>} 状态giantArray = null;queryObjects(Uint8Array); // ❌ [uint8Array]此时 Uint8Array 确实泄漏了。
这个示例看起来与第一个闭包示例非常相似,区别在于没有像闭包示例中那样将返回的函数存储到变量中,而是将该函数附加到 Promise 的 then 中从而导致了内存泄漏。
最后,当在写这篇文章时使用 Promise 时,当 Promise 的 resolve 函数在其他地方引用时,发生了令人惊讶的内存泄漏:
let i = 0;while (i++ < 100) { new Promise((resolve) => { document.body.addEventListener('click', resolve, { once: true}); });}queryObjects(Promise); // ❌ Array(100)事件目标(在本例中为 document.body)保存对 Promise 的 resolve 函数的引用,并防止 Promise 在解析后被收集。 这可能是因为每个 resolve 都有一个返回其来源的 Promise 的内部引用。
可以使用 removeEventListener 来修复:
let i = 0;while (i++ < 100) { const handler = () => resolve(); new Promise((resolve) => { document.body.addEventListener('click', handler, { once: true}); removeEventListener('click', handler); });}queryObjects(Promise); // ✅ empty array3.最后的想法尽管 queryObjects 对于调试生产环境中的内存泄漏可能没有多大帮助,但其是一个很好的工具,可以通过快速反馈循环以编程方式检测内存泄漏,从而在创建最小的可重现内存泄漏示例并快速验证假设时非常有价值。
顺便说一句,Safari 的 DevTools Console 有一个名为 queryHolders 的 API,可以让开发者准确地知道哪些变量保存着对泄漏对象的引用。 遗憾的是,在撰写本文时 Chrome 尚未实现此 API。
参考资料https://www.zhenghao.io/posts/queryObjects
http://www.crockford.com/javascript/memory/leak.html
https://nolanlawson.com/2020/02/19/fixing-memory-leaks-in-web-applications/
https://en.wikipedia.org/wiki/Halting_problem
https://eslint.org/docs/latest/rules/no-unused-vars
https://github.com/chrisguttandin/standardized-audio-context/blob/master/test/memory/module.js#L35
https://nolanlawson.com/2022/01/05/memory-leaks-the-forgotten-side-of-web-performance/