Babylon.js 内存泄漏测试工具指南:用 Playwright 检测事件泄漏与对象泄漏
Babylon.js 内存泄漏测试工具指南:用 Playwright 检测事件泄漏与对象泄漏
导读
@babylonjs/test-tools 是 Babylon.js 仓库内部维护的一组建构中的测试工具,专门用于在浏览器端自动化测试中发现两类内存问题:事件泄漏(事件被注册后,在对象 dispose 时未被移除)与对象泄漏(对象被 dispose 后仍残留在内存中)。本文以 packages/public/@babylonjs/test-tools/readme.md 为骨架,结合仓库内 @tools/test-tools 与 memlab 内存泄漏测试包的实际实现,讲解如何把这些工具接入 Playwright 测试流程、如何对特定类做对象计数校验、如何借助 sourcemap 还原 TypeScript 源码级的泄漏堆栈,并给出完整的可复制代码示例。
工具定位:面向 Babylon.js 内部的一组建构中测试套件
该包在 package.json 中声明为 @babylonjs/test-tools,版本随 Babylon.js 主版本推进(当前仓库为 9.28.0),通过 tsc -b tsconfig.build.json 编译输出 dist/index.js 与 dist/index.d.ts,属于仓库 packages/public 下随发布流程一起构建的工具包。
它的定位非常明确:
- 事件泄漏:检测“注册了但未随 dispose 移除”的事件。Babylon.js 大量使用 Observable 机制(如场景、引擎上的
onNewSceneAddedObservable、onNewCameraAddedObservable等),事件回调若在对象释放时仍挂在 Observable 上,就会导致对象被外部引用而无法被垃圾回收。 - 对象泄漏:检测“dispose 之后仍驻留在内存中”的对象。这类泄漏通常意味着
dispose()没有清理干净内部引用,长期累积会造成内存不断膨胀。
官方文档同时给出明确提醒:该包主要供 Babylon.js 内部测试使用,使用时务必谨慎,且不会在任何时候保证向后兼容。因此在依赖它的版本升级时,需要留意函数签名与行为的变化。
使用前提:面向 Playwright 的浏览器端测试
整套库是为 Playwright 浏览器端测试设计的。所有泄漏检测函数都需要通过 page.evaluate(...) 注入到被测页面上下文中执行,因为:
- 事件注册/移除发生在页面内的真实引擎与场景对象上;
- 对象计数需要访问页面内
window上的类与实例; - 堆栈采集需要在创建点同步记录调用栈。
最小测试环境
测试页面通常是一个加载了 Babylon.js 构建产物的 HTML(如仓库中 testsMemoryLeaks 使用的 empty.html),页面内需存在 #babylon-canvas 画布,并将需要检测的类挂到 window 对象上。工具包内 window.d.ts 展示了测试页面扩展 window 接口的约定,例如 window.engine、window.scene、window.eventsRegistered、window.classesConstructed 等字段,供浏览器侧函数读写。
检测事件泄漏:注入监听增强与结果断言
事件泄漏检测由两个函数配合完成:evaluateEventListenerAugmentation 与 assertEventLeaks。
第一步:在每个测试前注入事件监听增强
为了让“注册/移除”的统计对每个测试都生效,需要在 beforeEach 中通过页面求值运行 evaluateEventListenerAugmentation:
// 在 Playwright 测试文件中
import { evaluateEventListenerAugmentation, assertEventLeaks } from "@babylonjs/test-tools";
beforeEach(async () => {
await page.goto(`${baseURL}/test.html`, {
waitUntil: "load",
timeout: 0,
});
// 增强所有事件注册/移除路径,开始记录
await page.evaluate(evaluateEventListenerAugmentation);
});
这段代码做了两件事:跳转到测试页面(timeout: 0 表示不受默认超时限制,适合加载较重的引擎资源),然后注入监听增强器。增强后,页面内所有事件监听(addEventListener/removeEventListener,以及 Babylon.js 的 Observable add/remove)都会被统计到 window.eventsRegistered 中——该结构体按事件名记录 numberAdded(注册次数)、numberRemoved(移除次数)、registeredFunctions 以及每次注册时的 stackTraces(见 window.d.ts)。
第二步:在测试结束时断言无泄漏
测试完成后,调用 assertEventLeaks(page) 校验注册与移除是否平衡:
// 测试内部:
test("Should do the thing it should do!", async () => {
// 在页面上执行你的业务逻辑
// 运行你的其他断言
// ...
// 校验是否有事件泄漏,传入 Playwright 的 page
await assertEventLeaks(page);
});
关键注意事项(原文档特别强调):assertEventLeaks 内部会运行 expect 断言,因此不要把它放在 afterEach 中——如果把它放在 afterEach,异常会被吞掉或与其它钩子逻辑混淆,导致断言失效或难以定位失败来源。正确做法是在每个测试用例体内、业务断言之后显式调用。
检测对象计数:快照对比与自动断言
对象计数提供两种粒度:对 Object.prototype 级别的全部对象计数,或对特定类计数。之所以要区分,是因为浏览器端无法直接把 ES6 风格的类传入(Playwright 的 evaluate 参数需可序列化),因此需要把待检测的类先暴露到页面的 window 上(原文档注明:这一限制后续会扩展改进)。
全量对象计数(Object.prototype 粒度)
在测试开始和结束时各拍一次快照,然后对差值做断言:
// 测试内部:
test("Should do the thing it should do!", async () => {
const init = await countObjects(page);
// 在页面上执行你的业务逻辑
// 运行你的其他断言
// ...
// 再次计数,得到测试结束时的状态
const valuesAtTheEnd = await countObjects(page);
// 手动断言:例如对象净增长必须小于 200
expect(valuesAtTheEnd.numberOfObjects - init.numberOfObjects).toBeLessThan(200);
});
这里 init 与 valuesAtTheEnd 都包含 numberOfObjects 字段。通过对比两次快照的对象数量差值,可以捕获“业务逻辑结束后仍有大量对象残留”的泄漏信号。差值阈值(如 200)由开发者根据被测场景的合理对象量级自行设定。
自动断言:countCurrentObjects
如果不想手写差值断言,可以直接使用 countCurrentObjects 在测试末尾自动完成对比与断言:
// 测试内部:
test("Should do the thing it should do!", async () => {
const init = await countObjects(page);
// 在页面上执行你的业务逻辑
// 运行你的其他断言
// ...
// 自动运行 expect 规则,对比 init 与当前状态
await countCurrentObjects(page, init);
});
针对特定类的对象计数
当只关心某一组组件是否被正确释放时,向 countObjects 传入类清单。每项包含:
| 字段 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
globalClassName |
string | 必填 | 类在页面 window 上的全局名称,例如 "FirstComponent"(若挂在命名空间下需写全路径,如 "BABYLON.Scene",这与 utils.ts 中 ClassesToCheck 的写法一致) |
disposeFunctionName |
string | 可选 | 类的 dispose 方法名。提供后才会检查该对象是否真的被 dispose 过;不提供则跳过 dispose 检查 |
// 测试内部:
test("Should do the thing it should do!", async () => {
const classes = [
{
globalClassName: "FirstComponent",
disposeFunctionName: "dispose",
},
{
globalClassName: "SecondComponent",
disposeFunctionName: "dispose",
},
];
const init = await countObjects(page, classes);
// 在页面上执行你的业务逻辑
// 运行你的其他断言
// ...
await countCurrentObjects(page, init, classes);
});
提供 disposeFunctionName 后,工具会检查每个实例的 dispose 标记(参考 utils.ts 中 prepareLeakDetection 的做法:把类的原型 dispose 包装一层,置 __disposeCalled = true),从而判断泄漏对象是否“根本没被调用过 dispose”。
获取泄漏对象的创建堆栈:onComponentCreated
默认情况下,工具知道“有对象未被释放”,但不知道它是谁、在哪里被创建。要拿到创建位置的堆栈,需要在这些对象被创建时主动通知工具:
由于无法扩展类的构造函数(ES6 类构造器不可被静态拦截),你必须自行在创建处调用
onComponentCreated函数,传入刚创建的对象。
// 在业务组件工厂里:
function createComponent() {
const component = new MyComponent(/* ... */);
onComponentCreated(component); // 标记组件并记录当前堆栈
return component;
}
onComponentCreated 会为该实例生成唯一标识、记录创建时的调用栈,并写入 window.classesConstructed 映射表(见 window.d.ts 中 StacktracedObject 结构:包含 id、stackTrace、className、可选的 disposeCalled)。这样当检测到未被移除的对象时,就能通过该表反查出它的创建位置,定位“是谁在什么时候把它 new 出来的”。
Sourcemaps 与堆栈追踪:还原 TypeScript 源码位置
事件泄漏与特定类检查都能产出“事件/对象创建时”的堆栈,这在测试较大组件集合时尤为关键——它可以快速把泄漏责任锁定到具体模块。
- 纯 JavaScript 场景:堆栈直接给出源码中的正确行列位置,无需额外处理。
- TypeScript 或压缩场景:运行时堆栈对应的是编译产物,必须依赖 sourcemap 还原为 TS 源码位置。
使用 sourcemap 有两个硬性要求(原文档明确说明):
- sourcemap 必须是独立的
.map文件,不能内嵌在 JS 文件里; - 测试 HTML 必须引入
sourcemapped-stacktrace库:
<script src="https://cdn.jsdelivr.net/npm/sourcemapped-stacktrace@1.1.11/dist/sourcemapped-stacktrace.min.js"></script>
引入后,浏览器侧会存在 window.sourceMappedStackTrace,工具在采集堆栈时优先调用其 mapStackTrace(可配置 { cacheGlobally: true, sync: true } 加速重复映射,见 utils.ts),映射失败时回退到原始 err.stack,保证检测流程不会因 sourcemap 缺失而中断。
仓库配套:从 test-tools 到 memlab 的内存泄漏防线
@babylonjs/test-tools 主要解决“浏览器内事件/对象计数”问题;而仓库内还有两个紧密相关的配套体系,可以在写测试时互为补充:
1. @tools/test-tools:通用浏览器测试工具箱
packages/tools/testTools 提供面向 Babylon.js 全仓测试的公共函数,包括:
getGlobalConfig({ usesDevHost }):解析测试的baseUrl、root、snippetUrl、pgRoot、assetsUrl,默认指向http://localhost:1337(Babylon Server),可用CDN_BASE_URL或--enable-https覆盖;evaluateInitEngine/evaluateCreateScene/evaluateRenderScene/evaluateDisposeScene/evaluateDisposeEngine:在浏览器内完成引擎与场景的创建、渲染、释放,并在释放后主动调用window.gc && window.gc()加速回收,便于泄漏检测在稳定状态下进行;prepareLeakDetection(classes):为指定类注入 dispose 标记,并挂接引擎/场景的新增对象 Observable 采集堆栈。
其中默认的 ClassesToCheck 清单(utils.ts)为 BABYLON.Camera、BABYLON.TransformNode、BABYLON.Scene、BABYLON.Vector3、BABYLON.BaseTexture、BABYLON.Material——这正是“特定类计数”思路的仓库内实践。
2. @tools/memory-leak-tests:基于 memlab 的整包级泄漏门禁
在 packages/tools/testsMemoryLeaks 中,Babylon.js 用 Meta 的 memlab 构建了面向 CI 的内存泄漏运行器,职责与 test-tools 互补:
- 四类套件:
ci(PR 门禁,核心场景)、extended(本地补充场景)、packages(各子包确定性场景)、all(全部); - 通过 scenarios.ts 中的
DefaultScenarioDefinitions声明场景,覆盖@babylonjs/core(音频/物理/粒子/导航/渲染/材质/纹理/后期处理)、@babylonjs/gui、@babylonjs/loaders、@babylonjs/materials、@babylonjs/post-processes、@babylonjs/procedural-textures、@babylonjs/serializers等包; - 运行命令(根入口):
npm run test:memory-leaks # PR 门禁:ci 套件
npm run test:memory-leaks:unit # 运行运行器自身的单元测试
npm run test:all -w @tools/memory-leak-tests # 全部场景
npm run list -w @tools/memory-leak-tests # 列出场景
npm run test -w @tools/memory-leak-tests -- --scenario core-playground-2FDQT5-1508 # 运行单个场景
- 运行器在 runner.ts 中通过
RunScenario组装 memlab 的run(options),检测到泄漏即抛出MemoryLeakRunnerError并附上 memlab 产物目录;默认failFast: true、skipWarmup: true(场景自身已做确定性 set 与 settle,跳过 memlab 通用预热可避免挂起); - 浏览器侧动作(browserActions.ts)在页面内完成引擎创建、场景加载、渲染若干帧、可选地启动/停止动画组、模拟相机移动、开关 inspector,再等待
settleAfterReadyMs/settleAfterDisposeMs让异步清理稳定,最后才让 memlab 拍照对比快照。
这套体系证明了泄漏测试的完整闭环:test-tools 负责细粒度的浏览器内事件/对象审计,memlab 负责粗粒度但可自动化的堆快照对比,两者都遵循“创建 → 使用 → dispose → 等待稳定 → 对比”的同一节奏。
最佳实践与注意事项
综合原文档与仓库实现,接入 @babylonjs/test-tools 时建议遵循以下规则:
- beforeEach 注入增强,用例体内断言:
evaluateEventListenerAugmentation放beforeEach;assertEventLeaks放测试体内,绝不放进afterEach。 - 首尾快照成对出现:
countObjects必须在业务动作之前调用以取得基线,业务结束后再计数或直接countCurrentObjects自动断言。 - 特定类检查必须暴露到 window:ES6 类无法直接序列化进浏览器,务必在测试页面把类挂到
window上(如window.FirstComponent = FirstComponent),再以globalClassName引用。 - 用
disposeFunctionName区分“没释放”与“释放不干净”:不传该字段,工具只报对象残留;传了之后,还能从堆栈记录中确认实例是否真正执行过 dispose。 - 想要堆栈必做 sourcemap:TS/压缩产物下,独立
.map文件 +sourcemapped-stacktrace缺一不可,否则拿到的堆栈指向编译产物,难以直接阅读。 - 给业务组件加
onComponentCreated埋点:这是唯一能在创建瞬间记录堆栈的手段,建议在框架层或工厂函数中统一调用,避免逐个手写。 - 关注版本兼容性:该包不承诺向后兼容,升级 Babylon.js 版本后应回归运行全部泄漏测试;在写新测试时优先复用仓库内的既有辅助函数(
getGlobalConfig、evaluateInitEngine等),保持与 CI 门禁一致的环境。
总结
@babylonjs/test-tools 用“事件注册/移除统计 + 对象计数快照 + 创建堆栈回放”三件套,把内存泄漏从“肉眼观察任务管理器”变成“可断言、可定位、可回归”的自动化测试:evaluateEventListenerAugmentation/assertEventLeaks 负责事件泄漏,countObjects/countCurrentObjects/onComponentCreated 负责对象泄漏,sourcemap 支持则让堆栈直达 TypeScript 源码。结合仓库内的 @tools/test-tools 与 memlab 内存泄漏运行器,开发者可以在 Playwright 之外获得一套从浏览器内审计到堆快照对比的完整防线,为 Babylon.js 这类长生命周期渲染引擎的长期内存健康提供保障。