- 开发工具
- 测试
- CLI
- MCP 服务
【免费下载链接】memlab
A framework for finding JavaScript memory leaks and analyzing heap snapshots
导读:
IScenario是 memlab 框架中定义"如何让一次 E2E 测试与浏览器交互"的核心接口。本文以 Types.ts 中的接口定义为主线,逐一拆解url、action、back、repeat、leakFilter等全部回调的语义、参数与真实代码示例,并结合 CLI 的memlab run --scenario与@memlab/api的run({scenario})两种使用方式,以及 BaseSynthesizer.ts 的源码级访问计划生成逻辑,帮助你写出可复用、可精确控制泄漏判定标准的测试场景文件。
一、什么是 IScenario:测试场景在 memlab 中的定位
IScenario(定义于 Types.ts)描述了 memlab 应该如何与一个 Web 应用交互以完成内存泄漏检测。一个测试场景既可以保存为.js文件,通过命令行传入,也可以直接作为对象字面量传给@memlab/api的runAPI:
// save as test.js and use in terminal: // $ memlab run --scenario test.js module.exports = { url: () => 'https://www.npmjs.com/', action: async () => ... , back: async () => ... , cookies: () => ... , // optional repeat: () => ... , // optional ... };const {run} = require('@memlab/api'); (async function () { const scenario = { url: () => 'https://www.facebook.com', action: async () => ... , back: async () => ... , cookies: () => ... , // optional repeat: () => ... , // optional ... }; const leaks = await run({scenario}); })();1.1 两条使用路径的底层一致性
从源码可以确认,两条路径最终殊途同归:
- CLI 路径:
memlab run --scenario <文件>在 ScenarioFileOption.ts 中通过utils.loadScenario(scenarioFile)读取文件(实现在 Utils.ts,会校验文件存在性并处理非法文件报错),把结果挂到config.scenario上; - API 路径:
run({scenario})(见 API.ts)直接把场景对象传给内部流程。从 API 实现看,run等价于"预热(warmup)→ 浏览器交互并采集快照(takeSnapshots)→ 泄漏分析(findLeaks)"三步的组合。
无论哪条路径,场景对象最终都会进入 TestPlanner.ts 的prepareSynthesizer,由 BaseSynthesizer.ts 的synthesisForScenario把回调编排成一个带步骤顺序的访问计划(visit plan)。
1.2 场景被展开成怎样的执行序列
synthesisForScenario的源码逻辑清晰展示了action/back/repeat如何决定浏览器访问顺序:
- 只有
url(无action):生成单步page-load,作为target步骤执行——即仅检测初始页面加载引入的泄漏; - 有
action时:先访问page-load(baseline),然后按repeat()的次数循环插入action-on-page与revert(回退)步骤,最后执行一次action-on-page(target)与一次revert(final)。
其中repeat在 BaseSynthesizer.ts 中的默认取值为scenario.repeat ? scenario.repeat() : 0,即不声明repeat就只做一轮action -> back。而revert步骤只有在声明了scenario.back时才使用自定义回退回调,否则回落到内置的通用回退步骤(SynthesisUtils.revertStep)。
二、必选回调:url
定义位置:Types.ts
url是唯一必选回调,返回页面初始 URL 的字符串值:
const scenario = { url: () => 'https://www.npmjs.com/', }; module.exports = scenario;关键语义:如果场景只提供url(没有action),memlab 会尝试直接从初始页面加载中检测内存泄漏——初始加载分配的所有 JS 对象都会成为泄漏过滤的候选对象。这一点与 BaseSynthesizer.ts 中if (!scenario.action)分支的处理完全对应。
三、核心交互对:action 与 back
3.1 action:触发泄漏的交互
定义位置:Types.ts,类型为
InteractionsCallback
action定义了初始页面加载之后、用于触发内存泄漏的浏览器交互。action回调触发过程中,浏览器里分配的所有 JS 对象都会被纳入泄漏过滤的候选集合。
- 参数:
page——puppeteer 的Page对象,提供与浏览器交互的 API; - 示例:
const scenario = { url: () => 'https://www.npmjs.com/', action: async (page) => { await page.click('a[href="/link"]'); }, back: async (page) => { await page.click('a[href="/back"]'); }, } module.exports = scenario;务必注意:释放 puppeteer 元素句柄。官方文档与源码都强调:从page.$x或page.$取到的元素句柄(element handle)必须显式释放,否则会在浏览器上下文里造成额外内存泄漏,干扰检测结果。使用element.dispose()或Promise.all(elements.map(e => e.dispose()))完成清理:
const scenario = { url: () => 'https://www.npmjs.com/', action: async (page) => { const elements = await page.$x("//button[contains(., 'Text in Button')]"); const [button] = elements; if (button) { await button.click(); } // dispose external references to JS objects in browser context await Promise.all(elements.map(e => e.dispose())); }, back: async (page) => ... , } module.exports = scenario;仓库自带的示例场景 detached-dom.js 完整演示了这一最佳实践:先用$x按 XPath 找到按钮,点击后立刻Promise.all(elements.map(e => e.dispose()))清理句柄。
3.2 back:action 的"撤销"操作
定义位置:Types.ts
back指定 memlab 如何回退/撤销action造成的状态变化,本质是一个 undo 操作:
const scenario = { url: () => 'https://www.npmjs.com/', action: async (page) => { await page.click('a[href="/link"]'); }, back: async (page) => { await page.click('a[href="/back"]'); }, }memlab 需要撤销action的原因是其"差分对比"式的泄漏检测原理:对比执行交互前后的堆快照,找出"被action分配、但back之后仍未释放"的对象——这正是leakFilter与leakedNodeIds的数据来源。在 detached-dom.js 中,back通过点击a[href="/"]返回首页来复位页面状态。
四、交互次数控制:repeat
定义位置:Types.ts
repeat返回一个数字,表示 memlab 额外执行多少轮action+back:
module.exports = { url: () => ... , action: async (page) => ... , back: async (page) => ... , // browser interaction: two additional [ action -> back ] // init-load -> action -> back -> action -> back -> action -> back repeat: () => 2, };注意:repeat的值是"额外轮数"。repeat: () => 2得到的序列是init-load -> action -> back -> action -> back -> action -> back,即总共 3 次action、前 2 次各跟一次back,最后一次action(target 快照点)之后跟final回退。这与 BaseSynthesizer.ts 的循环展开逻辑一致。多轮往返交互有助于放大"每次交互都残留一点内存"这类渐进式泄漏的检测信号。
五、生命周期钩子:beforeInitialPageLoad 与 setup
5.1 beforeInitialPageLoad:初始加载前的准备
定义位置:Types.ts
在初始页面加载之前仅调用一次,常用于设置 HTTP 头或准备数据:
const scenario = { url: () => 'https://www.npmjs.com/', beforeInitialPageLoad: async (page) => { // before the initial page load }, action: async (page) => { await page.click('a[href="/link"]'); }, back: async (page) => { await page.click('a[href="/back"]'); }, } module.exports = scenario;源码印证:在 E2EInteractionManager.ts 中,只有当tabInfo.idx === 1(第一个步骤)时才会调用opArgs.scenario?.beforeInitialPageLoad。
5.2 setup:初始加载后的数据准备
定义位置:Types.ts
setup在初始页面加载之后仅调用一次,可用来登录(官方推荐优先用cookies)或在action之前准备数据:
const scenario = { url: () => 'https://www.npmjs.com/', setup: async (page) => { // log in or prepare data for the interaction }, action: async (page) => { await page.click('a[href="/link"]'); }, back: async (page) => { await page.click('a[href="/back"]'); }, } module.exports = scenario;同样在 E2EInteractionManager.ts,setup只在首个步骤执行完成后被调用。
六、认证与 Cookie:cookies
定义位置:Types.ts
如果被测页面需要认证或特定 Cookie,可以以<name, value, domain>元组列表的形式传入:
const scenario = { url: () => 'https://www.facebook.com/', cookies: () => [ {name:'cm_j', value: 'none', domain: '.facebook.com'}, {name:'datr', value: 'yJvIY...', domain: '.facebook.com'}, {name:'c_user', value: '8917...', domain: '.facebook.com'}, {name:'xs', value: '95:9WQ...', domain: '.facebook.com'}, // ... ], }; module.exports = scenario;domain 自动填充规则:请务必提供正确的domain字段;若缺失,memlab 会基于url回调推断 domain。源码实现在 TestPlanner.ts:通过new URL(scenario.url())解析主机名,去掉最左侧子域后拼上.前缀(例如https://www.facebook.com/会推导出.facebook.com)。cookies的返回类型是Cookies(即Cookie数组),单个Cookie的name、value必填,其余字段可选。
七、页面加载判定:isPageLoaded
定义位置:Types.ts,类型为
CheckPageLoadCallback
该回调用于判定初始加载及后续交互中页面是否加载完成,返回Promise<boolean>:
- 返回
true:memlab 认为导航完成,进入下一步; - 返回
false:memlab 会持续调用该回调直到返回true(可await异步逻辑解析后再返回true); - 未提供时的默认行为:memlab 认为"连续至少 500ms 没有网络连接"即视为导航结束。
module.exports = { url: () => ... , action: async (page) => ... , back: async (page) => ... , isPageLoaded: async (page) => { await page.waitForNavigation({ // consider navigation to be finished when there are // no more than 2 network connections for at least 500 ms. waitUntil: 'networkidle2', // Maximum navigation time in milliseconds timeout: 5000, }); return true; }, };从 BaseSynthesizer.ts 可以看到,访问计划中的isPageLoaded字段优先取scenario.isPageLoaded,否则回落给合成器的默认检查器(getPageLoadChecker,内部通过waitFor等待delayWhenNoPageLoadCheck配置时长)。
八、泄漏判定三件套:beforeLeakFilter、leakFilter、retainerReferenceFilter
memlab 的默认泄漏过滤器把**游离的 DOM 元素(detached DOM elements)和从 React Fiber 树上脱离的未挂载 Fiber 节点(unmounted Fiber nodes)**视为内存泄漏。当默认规则不满足被测应用时,可以用下面三个回调自定义判定逻辑。它们在 Config.ts 中被统一收集为externalLeakFilter并接入分析流程(见 HeapAnalyzer.ts 中对beforeLeakFilter的调用)。
8.1 beforeLeakFilter:过滤前的初始化钩子
定义位置:Types.ts,类型为
InitLeakFilterCallback
在所有leakFilter调用之前仅执行一次,适合初始化数据存储或做一次性预处理:
module.exports = { url: () => ... , action: async (page) => ... , back: async (page) => ... , beforeLeakFilter: (snapshot, leakedNodeIds) { // initialize some data stores }, };snapshot:所有浏览器交互完成后采集的最终堆快照(IHeapSnapshot),可查询堆快照数据;leakedNodeIds:Set<number>——由action分配、但back之后未释放的所有 JS 堆对象的 id 集合。
8.2 leakFilter:自定义泄漏判定算法
定义位置:Types.ts,类型为
LeakFilterCallback
leakFilter对每个"被action分配、但back后未释放"的节点逐一调用,返回boolean决定该节点是否算泄漏。这些未释放对象可能是有意驻留内存的缓存,也可能真的是内存泄漏——回调让你针对被测 JS 程序"择优挑出"真正的泄漏:
module.exports = { url: () => ... , action: async (page) => ... , back: async (page) => ... , leakFilter(node, snapshot, leakedNodeIds) { // any unreleased node (JS heap object) with 1MB+ // retained size is considered a memory leak return node.retainedSize > 1000000; }, };node:IHeapNode——堆快照中分配但未释放的对象;snapshot:最终堆快照;leakedNodeIds:泄漏候选节点 id 集合。
仓库还提供了一个"按保留大小过滤"的参考实现:filter-by-retained-size.js,可作为自定义leakFilter的起点。
8.3 retainerReferenceFilter:控制保留追踪(retainer trace)的边
定义位置:Types.ts,类型为
ReferenceFilterCallback
该回调对堆快照中的**每一条引用(edge)**调用,决定该引用是否应纳入保留追踪(retainer trace)计算:
edge:IHeapEdge——被评估是否纳入 retainer trace 的引用边;snapshot:最终堆快照;isReferenceUsedByDefault:boolean——memlab 默认逻辑是否使用该引用(true表示默认会纳入计算)。
返回值语义需要特别注意:返回true只表示该引用成为 retainer trace 的候选,最终可能进也可能不进;返回false则必然排除。而且,如果排除的是某个对象的支配引用(dominator reference,即从 GC 根到达该堆对象必须经过的边),该对象会在堆图中被视为不可达,从而整个引用和对象都不会进入 retainer trace 检测与保留大小计算。
// save as leak-filter.js module.exports = { retainerReferenceFilter(edge, _snapshot, _leakedNodeIds) { // exclude react fiber references if (edge.name_or_index.toString().startsWith('__reactFiber$')) { return false; } return true; } };九、IScenario 属性速查表
| 属性 | 类型 | 必选 | 调用时机 | 核心作用 |
|---|---|---|---|---|
url | () => string | ✅ | 规划阶段 | 返回初始页面 URL;仅此一项时检测初始加载泄漏 |
action | InteractionsCallback | 否 | 初始加载后(每轮) | 执行触发泄漏的交互,其分配对象进入候选集 |
back | InteractionsCallback | 否 | 每次 action 之后 | 撤销 action 的状态变更(undo) |
repeat | () => number | 否 | 规划阶段 | 额外执行多少轮action -> back,默认 0 |
beforeInitialPageLoad | InteractionsCallback | 否 | 初始加载前(仅一次) | 设置 HTTP 头、准备数据 |
setup | InteractionsCallback | 否 | 初始加载后(仅一次) | 登录(推荐用 cookies)或准备数据 |
cookies | () => Cookies | 否 | 规划阶段 | 认证 Cookie 列表,domain 可自动推断 |
isPageLoaded | CheckPageLoadCallback | 否 | 每次导航后 | 自定义页面加载完成判定,默认 500ms 无网络连接 |
beforeLeakFilter | InitLeakFilterCallback | 否 | leakFilter 之前(仅一次) | 初始化数据存储等一次性预处理 |
leakFilter | LeakFilterCallback | 否 | 每个候选节点 | 自定义泄漏判定,默认检测游离 DOM 与未挂载 Fiber |
retainerReferenceFilter | ReferenceFilterCallback | 否 | 每条引用边 | 控制引用是否纳入 retainer trace 计算 |
十、最小可用场景与进阶组合示例
最小场景(仅检测初始加载泄漏):
module.exports = { url: () => 'https://example.com', };综合示例(含认证、重复交互与自定义泄漏阈值):
module.exports = { url: () => 'https://example.com/', cookies: () => [ {name: 'session', value: 'abc123', domain: '.example.com'}, ], isPageLoaded: async (page) => { await page.waitForNavigation({waitUntil: 'networkidle2', timeout: 5000}); return true; }, action: async (page) => { const elements = await page.$x("//button[contains(., 'Load more')]"); const [button] = elements; if (button) { await button.click(); } await Promise.all(elements.map(e => e.dispose())); }, back: async (page) => { await page.click('a[href="/"]'); }, repeat: () => 3, leakFilter(node) { return node.retainedSize > 1000000; // 1MB+ }, };十一、进一步阅读
- 接口完整定义:Types.ts;
- 场景如何生成访问计划:BaseSynthesizer.ts;
- CLI 的
--scenario解析:ScenarioFileOption.ts; - API 的
run({scenario})实现:API.ts; - 官方示例场景:detached-dom.js;
- 官方示例泄漏过滤器:filter-by-retained-size.js;
- 配套类型:InteractionsCallback、LeakFilterCallback、ReferenceFilterCallback、Page。
- 开发工具
- 测试
- CLI
- MCP 服务
【免费下载链接】memlab
A framework for finding JavaScript memory leaks and analyzing heap snapshots
相关推荐
MemLab 内存泄漏检测实战指南:从 Puppeteer 场景编写到堆快照分析的完整实践
MemLab 内存泄漏检测实战指南:从 Puppeteer 场景编写到堆快照分析的完整实践 MemLab 是 Meta 开源的端到端 JavaScript 内存
开发工具测试CLIMCP 服务Babylon.js 内存泄漏 CI 测试实战:基于 memlab 的场景化内存泄漏运行器
Babylon.js 内存泄漏 CI 测试实战:基于 memlab 的场景化内存泄漏运行器 本文以 Babylon.js 仓库内 packages/tools/
图形学游戏开发3D渲染memlab 内存泄漏检测实战:从浏览器 E2E 场景到堆快照分析与内存断言
memlab 内存泄漏检测实战:从浏览器 E2E 场景到堆快照分析与内存断言 memlab 是一个端到端(E2E)的 JavaScript 内存泄漏测试与分析框
开发工具测试CLIMCP 服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考