fuite
fuite /fɥit/ 法语,意为“泄漏”
fuite 是一个用于查找 Web 应用内存泄漏的 CLI 工具。
用法
npx fuite https://example.com
这将检查泄漏并将输出打印到标准输出。
默认情况下,fuite 会假设该站点是一个客户端渲染的 Web 应用,并会在给定页面上搜索内部链接。然后,对于每个链接,它会:
- 点击该链接
- 按下浏览器后退按钮
- 重复操作以查看该场景是否存在泄漏
对于其他场景,请参阅 scenarios。
工作原理
fuite 使用 Puppeteer 启动 Chrome,加载一个网页,并针对该网页运行一个场景。它会运行该场景若干次迭代(默认为 7 次),并查找泄漏了 7 次(或 14 次,或 28 次)的对象。这听起来可能是一种奇怪的方法,但对于在内存分析中 过滤噪音 非常有用。
fuite 查找以下类型的泄漏:
- 对象(通过 Chrome 堆快照 捕获)
- 事件监听器
- DOM 节点(附加到 DOM 上 – 分离的节点将显示在“对象”下)
- 集合,例如 Arrays、Maps、Sets 和纯 Objects
默认场景点击内部链接,因为这是最通用的场景,可以针对各种 SPA 运行,并且如果使用了客户端路由,它通常能捕获到泄漏。
选项
Usage: fuite [options] <url>
Arguments:
url URL to load in the browser and analyze
Options:
-o, --output <file> Write JSON output to a file
-i, --iterations <number> Number of iterations (default: 7)
-s, --scenario <scenario> Scenario file to run
-S, --setup <setup> Setup function to run
-H, --heapsnapshot Save heapsnapshot files
-d, --debug Run in debug mode
-p, --progress Show progress spinner (use --no-progress to disable)
-b, --browser-arg <arg> Arg(s) to pass when launching the browser
-V, --version output the version number
-h, --help display help for command
URL
fuite <url>
要加载的 URL。这应该是您希望开始访问的落地页。请注意,您可以使用 --setup 来自定义设置函数(例如,使用用户名/密码登录)。
Output
-o, --output <file>
fuite 会生成大量数据,但并非所有数据都会显示在 CLI 输出中。为了深入挖掘,请使用 --output 选项创建一个包含 fuite 分析结果的 JSON 文件。其中包含额外信息,例如事件监听器声明所在的代码行。
您在 CLI 中看到的内容,也可以在输出 JSON 文件中找到。
Iterations
-i, --iterations <number>
默认情况下,fuite 运行 7 次迭代。但您可以更改此数字。
为什么是 7?嗯,它是一个不错的、小的质数。如果您重复执行某个操作 7 次,而某个对象恰好泄漏了 7 次,那么这不太可能是无关的。话虽如此,在非常复杂的页面上,可能存在足够的噪声,使得 7 太小而无法穿透噪声——因此您可以尝试 13、17 或 19。或者,如果您喜欢冒险,可以设为 1。
Scenario
--scenario <scenario>
默认场景是找到页面上的所有内部链接,点击它们,然后按后退按钮。您还可以定义一个场景文件,执行您想要的任何操作:
fuite --scenario ./myScenario.mjs https://example.com
您的 myScenario.mjs 可以导出多个 async function,其中大多数是可选的。
以下是一个模板:
// myScenario.mjs
/**
* OPTIONAL: Setup code to run before each test
* @param { import("puppeteer").Page } page
*/
export async function setup(page) {
}
/**
* OPTIONAL: Code to run once on the page to determine which tests to run
* @param { import("puppeteer").Page } page
*/
export async function createTests(page) {
}
/**
* REQUIRED: Run a single iteration against a page – e.g., click a link and then go back
* @param { import("puppeteer").Page } page
* @param { any } data
*/
export async function iteration(page, data) {
}
/**
* OPTIONAL: Teardown code to run after each test
* @param { import("puppeteer").Page } page
*/
export async function teardown(page) {
}
/**
* OPTIONAL: Code to wait asynchronously for the page to become idle
* @param { import("puppeteer").Page } page
*/
export async function waitForIdle(page) {
}
您可以删除任何不需要的可选函数。
请注意,您的场景文件也可以扩展默认场景。
setup 函数(可选)
异步 setup 函数接收一个 Puppeteer Page 作为输入,并返回 undefined。它在每个 iteration 之前运行,或者在 createTests 之前运行。如果您的 webapp 需要登录,这是一个进行登录操作的好位置。
如果未定义此函数,则不会运行任何设置代码。
请注意,还有一个 --setup 标志。如果已定义,它将覆盖在场景中定义的 setup 函数。
createTests 函数(可选)
异步 createTests 函数接收一个 Puppeteer Page 作为输入,并返回一个由 测试数据对象 组成的数组,这些对象表示要运行的测试以及为每个测试传递的数据。如果您希望动态确定要对页面运行哪些测试(例如,点击哪些链接),这将非常有用。
如果未定义 createTests,则默认测试为 [{}](一个带有空数据的单个测试)。
测试数据对象的基本结构如下:
{
"description": "Some human-readable description",
"data": {
"some data": "which is passed to the test"
}
}
例如,你的 createTests 可能返回:
[
{
"description": "My test 1",
"data": { "foo": "foo" }
},
{
"description": "My test 2",
"data": { "foo": "bar" }
}
]
iteration 函数(必需)
异步 iteration 函数接收一个 Puppeteer Page 和 迭代数据 作为输入,并返回 undefined。它在内存泄漏测试的每次迭代中运行。迭代数据 是一个普通对象,来自 createTests 函数,因此默认情况下它只是一个空对象:{}。
在 iteration 内部,你需要运行想要测试泄漏的核心测试逻辑。其想法是,在迭代的开始和结束时,内存 应该 是相同的。 因此,一次迭代可能会执行以下操作:
- 点击一个链接,然后返回
- 点击以启动一个模态对话框,然后按 Esc 键
- 悬停以显示一个工具提示,然后移开悬停以关闭工具提示
- 等等。
该迭代假设无论它从哪个页面开始,最终都会回到同一个页面。如果你以这种方式测试多页面应用程序,那么由于多页面应用程序在路由之间导航时不会像 SPA 那样泄漏内存,因此你极不可能检测到任何泄漏。
teardown 函数(可选)
异步 teardown 函数接收一个 Puppeteer Page 作为输入,并返回 undefined。它在每次 iteration 之后,或在 createTests 之后运行。
如果未定义此函数,则不会运行任何拆卸代码。
waitForIdle 函数(可选)
异步 waitForIdle 函数接收一个 Puppeteer Page,并且当页面被认为“空闲”时应解析。
以下是一个空闲检查的示例:
export async function waitForIdle(page) {
await new Promise(resolve => setTimeout(resolve, 2000)) // wait 2 seconds
await page.waitForSelector('#my-element') // wait for element
}
如果未定义此函数,则使用默认的闲置检查。默认值基于启发式方法,使用网络闲置和主线程闲置。
设置
--setup <setup>
--setup 选项可以定义一个自定义的 setup 函数,该函数在页面加载后立即运行,但在任何其他场景代码之前。
例如,您可以使用 --setup 通过用户名/密码登录。为此,首先创建一个名为 mySetup.mjs 的文件:
export async function setup (page) {
await page.type('#username', 'myusername');
await page.type('#password', 'mypassword');
await page.click('#submit');
}
然后传入:
npx fuite https://example.com --setup ./mySetup.mjs
此处定义的 setup 函数 与您在自定义场景中使用 --scenario 定义的函数相同(即它接受一个 Puppeteer Page 作为输入)。
如果同时定义了 --scenario 和 --setup,则 --setup 将覆盖场景中的 setup 函数。
Heap snapshot
-H, --heapsnapshot Save heapsnapshot files
默认情况下,fuite 不会保存其捕获的任何堆快照文件(以避免用大文件占满您的磁盘)。但是,如果您使用 --heapsnapshot 标志,则文件将保存在 /tmp 目录中,并且 CLI 会输出其位置。这样,您就可以自行检查它们并将它们加载到 Chrome DevTools 内存工具 中。
Debug
-d, --debug Run in debug mode
调试模式允许您深入复杂的场景并使用 Chrome DevTools 自行进行调试。运行它的最佳方式是:
NODE_OPTIONS=--inspect-brk fuite --debug <url>
(请注意,如果你运行 npx fuite,NODE_OPTIONS 将 不 起作用。因此,你必须使用 npm i -g fuite 在本地或全局安装 fuite。)
然后在 Chrome 中导航到 chrome:inspect,点击“Open dedicated DevTools for Node”,现在你正在调试 fuite 本身。
这将以非无头模式启动 Chrome,并且它还会在运行迭代之前和之后自动暂停。这样,你可以打开 Chrome DevTools 并自行分析场景,获取自己的堆快照等。
Progress
-p, --progress Show progress spinner (use --no-progress to disable)
在测试运行时启用或禁用进度指示器。它默认为 true,因此你应该使用 --no-progress 来禁用。
Browser args
-b, --browser-arg <arg> Arg(s) to pass when launching the browser
这允许你在启动浏览器时向 Puppeteer 传递参数(也称为标志)。你可以定义多个参数,
它们会原样传递给 Puppeteer 的 launch args 选项。
例如:
fuite <url> -b --use-fake-device-for-media-stream -b --enable-experimental-web-platform-features
JavaScript API
fuite 也可以通过 JavaScript API 使用,其工作方式与 CLI 类似:
import { findLeaks } from 'fuite';
const results = findLeaks('https://example.com', {
scenario: scenarioObject,
iterations: 7,
heapsnapshot: false,
debug: false,
progress: true,
browserArgs: ['--use-fake-device-for-media-stream']
});
for await (const result of results) {
console.log(result);
}
请注意,findLeaks 返回一个 async iterable。
这返回了与在 CLI 中使用 --output <filename> 时相同的输出——一个描述泄漏的普通对象。该对象的格式尚未完全指定,但可以在 TypeScript 类型 中找到其基本结构。
选项
findLeaks 的选项基本上与 CLI 相同。JavaScript API 与 CLI 之间的唯一区别是:
scenario接受一个包含键setup、createTests和iteration的对象,而不是文件名(参见下文 Scenario object 和 Extending the default scenario)。- 不支持
setup– 请改用scenario。 - 可以使用
signal来取消测试(参见下文 Cancel the test)。
取消测试
您可以传入一个 AbortSignal 作为 signal 选项,以按需取消测试:
const controller = new AbortController();
const { signal } = controller;
findLeaks('https://example.com', { signal });
// Later
controller.abort();
场景对象
对于 JavaScript API,你可以将一个自定义场景作为普通对象传入。首先,定义它:
const myScenario = {
async setup(page) { /* ... */ },
async createTests(page) { /* ... */ },
async iteration(page, data) { /* ... */ },
async teardown(page) { /* ... */ }
};
然后传入它:
import { findLeaks } from 'fuite';
for await (const result of findLeaks('https://example.com', {
scenario: myScenario
})) {
console.log(result);
}
如果 scenario 未定义,则将使用默认场景。
扩展默认场景
如果您正在编写自己的自定义场景,也可以扩展默认场景。例如,如果您想要默认场景,但希望首先能够使用用户名和密码登录:
import { defaultScenario, findLeaks } from 'fuite';
const myScenario = {
...defaultScenario,
async setup(page) {
await page.type('#username', 'myusername')
await page.type('#password', 'mypassword')
await page.click('#submit')
}
};
for await (const result of findLeaks('https://example.com', {
scenario: myScenario
})) {
console.log(result);
}
请注意,上述方法适用于使用 JavaScript API 的情况。对于 CLI,你可能需要使用 --setup 标志。
局限性
fuite 专注于页面的主框架。如果你在跨域 iframe 或 web workers 中存在内存泄漏,该工具将无法发现它们。
同样,fuite 测量页面的 JavaScript 堆大小,对应于你在 Chrome DevTool 的 Memory 标签页中看到的内容。它忽略了原生浏览器对象的大小。
fuite 在源代码未压缩时效果最佳。否则,类名将显示为压缩后的版本,这可能难以调试。
fuite 本身可能会使用大量内存来分析大型堆快照文件。如果你发现 Node.js 内存不足,可以运行类似以下命令:
NODE_OPTIONS=--max-old-space-size=8000 fuite <url>
上述命令将为 fuite 提供 8GB 的内存。(请注意,如果你运行 npx fuite,则 NODE_OPTIONS 将不起作用;你必须直接运行 fuite,例如先运行 npm i -g fuite。)
常见问题
结果似乎不正确或不一致。
尝试使用 --iterations 13 或 --iterations 17 运行。默认的 7 次迭代是合理的,但可能会报告一些误报。
它说我泄漏了 1kB。我真的需要修复这个问题吗?
并非每个内存泄漏都是严重问题。如果你每次交互只泄漏几 kB,用户可能永远不会注意到,你肯定也不会遇到浏览器的 Out Of Memory 错误。不过,你对“可接受泄漏”的上限会因用例而异。例如,如果你正在为嵌入式设备构建,那么你可能希望将内存使用量保持得更低。
它说我的页面内存增长了,但它也说没有检测到任何泄漏。为什么?
网页内存增长有很多原因。例如,浏览器的 JavaScript 引擎可能会对某些函数进行 JIT,从而占用额外的内存。或者浏览器可能会决定使用某些内部数据结构,以优先使用 CPU 而非内存。
Web 开发者通常无法控制这些事情,因此 fuite 试图区分浏览器内部内存和页面拥有的 JavaScript 对象。只有当它实际上能够给 Web 开发者提供一些可操作的建议时,fuite 才会说“检测到泄漏”。
我如何调试泄漏的事件监听器?
使用 --output 命令输出一个 JSON 文件,其中将包含事件监听器列表及其声明所在的代码行。否则,你可以使用 Chrome DevTools 来分析事件监听器:
- 打开 DevTools
- 打开 Elements 面板
- 打开 Event Listeners
- 或者,在 DevTools 控制台中运行
getEventListeners(node)
我如何调试泄漏的集合?
fuite 将分析你泄漏的集合,并打印出导致其增长的代码的堆栈跟踪 –
例如,向 Array 执行 push 操作,或对 Map 执行 set 操作。因此,这是首要排查的地方。
如果你有 sourcemaps,它将显示原始源代码。否则,它将显示原始堆栈跟踪。
有时不止一个因素在增加大小,且并非每次增加都是问题所在(例如,它随后立即删除)。
在这些情况下,你应该使用 --output 并查看 JSON 输出以查看完整的堆栈跟踪列表。
在其他一些情况下,fuite 无法跟踪集合的增长。(例如,对象禁止修改,或者代码使用 Array.prototype.push.call() 而不是直接执行 .push()。)
在这些情况下,你可能需要进行手动分析。以下是操作方法。
首先,在调试模式下运行 fuite:
NODE_OPTIONS=--inspect-brk fuite https://example.com --debug
然后在 Chrome 中打开 chrome:inspect 并点击“Open dedicated DevTools for Node.”。然后,当断点命中时,在 Chromium(运行你网站的那个)中打开 DevTools 并点击“Play”按钮,让场景继续运行。
最终,fuite 会在 Chrome DevTools 本身中给你一个断点,在那里你可以访问泄漏的集合(Array、Map 等)并检查它。
它还会在集合增长时(例如 push、set 等)提供 debugger 断点。对于普通对象,它尝试覆盖原型并监视 setter 来实现这一点。
请注意,并非每个泄漏的集合都是严重的内存泄漏:例如,你的路由器可能会在一个不断增长的栈中保留一些关于过去路由的元数据。或者你的分析库可能会在一个持续增长的数组中存储一些计时数据。除非这些对象非常大,或者包含引用大量内存的闭包,否则通常无需担心。
为什么不支持多个浏览器?
目前 fuite 需要 Chromium 特定的工具,例如堆快照、getEventListeners、queryObjects,以及其他仅在 Chromium 和 Chrome DevTools Protocol (CDP) 中可用的功能。理论上,这些功能可能以跨浏览器的方式访问,但今天这还不可能。
话虽如此,如果某物在 Chrome 中泄漏,它很可能在 Safari 和 Firefox 中也会泄漏。