你的 SPA 正在漏内存,用 Soak Test 把它揪出来
单页应用最大的特点是「永不重载」,而这恰好也是它最大的隐患:任何一个被遗忘的事件监听器、定时器或 DOM 引用,都会随着用户停留时间不断累积,直到标签页变卡甚至崩溃。
这类问题几乎不会在开发阶段暴露出来。我们本地调试时页面开着几分钟就刷新了,而真实用户可能开着同一个后台页面工作一整天。Soak Test(浸泡测试) 的思路很朴素:把同一段「回到起点」的操作重复运行上百次,观测内存指标是否只增不减 —— 泄漏会自己现形。
两个值得记住的数字
对 500 个热门 React / Vue / Angular 项目的分析发现,86% 的项目存在「设置了监听器、定时器或订阅,却从未移除」的代码;其中占比最大的单一来源是未清理的 setTimeout(),占 44%。
为什么传统网页不会这样
区别不在代码质量,而在生命周期长度。
传统多页应用每次跳转都会整页重载,浏览器直接丢弃旧页面占用的全部内存。就算代码里真有泄漏,也撑不到被察觉的那一刻 —— 导航本身就是一次「自愈」。
单页应用没有这个机会。页面永不重载,JS 运行时持续存活,每一次未清理的订阅都会叠加到上一次之上。小泄漏之所以变成大问题,只是因为它有了足够长的时间去积累。
每次跳转都会整页重载,浏览器直接丢弃旧页面占用的全部内存,水位被一次次清零。泄漏永远撑不到被察觉的那一刻。
页面永不重载,JS 运行时持续存活。每一次未清理的订阅都会叠加到上一次之上,直到标签页变卡甚至崩溃。
Soak Test:让泄漏自己现形
做法是在同一个浏览器上下文中,把同一段「回到起点」的用户操作重复运行上百次:先预热几轮记录基线,再看循环结束时的堆内存、DOM 节点数与监听器数量是否只增不减。这不是什么新发明,Gmail 十年前就已经在预发布测试里这样做了。
需要注意的是,堆内存大小不是一个可靠的信号:它会因为垃圾回收的时机而剧烈波动,一次采样高一点未必意味着泄漏。更稳定的指标是 DOM 节点数和事件监听器数,并且要在强制 GC 之后再采样。
四类最常见的泄漏源头
DOM 元素被移除后,绑定其上的 addEventListener 依然存活 —— 组件卸载时忘记调用 removeEventListener()。
未清理的 setTimeout() / setInterval(),是本次分析中占比最大的单一泄漏来源。
已从页面移除的节点仍被 JS 变量、闭包或全局对象持有 —— 在 DevTools 中表现为 Detached 节点。
没有上限的内存缓存,以及路由懒加载后未释放的残留模块代码。
这四类里,前两类靠 code review 就能发现一部分,后两类几乎只能靠观测。
Soak Test 是怎么跑起来的
- 预热 —— 先执行 5 次完整流程,避开首次加载、字体加载、懒加载 chunk 带来的噪音,然后记录基线指标。
- 循环 200 次 —— 在单一浏览器上下文中反复执行同一段往返操作,例如开关抽屉。
- 两次 GC 后采样 —— 通过 CDP 的
HeapProfiler.collectGarbage强制回收两次,再读取 Performance 指标。两次是为了让第一次回收释放出的对象所持有的引用也能被回收。 - 断言 —— 监听器数应 ≤ 基线;节点数增长应小于一个固定阈值(如 +100),而不是按百分比放宽。
为什么阈值要用固定值而不是百分比
百分比阈值会随基线一起变大:一个初始节点数很多的页面,允许的增长空间也更大,反而更容易漏掉真实泄漏。固定阈值对所有页面一视同仁。
断言长什么样
// dashboard.spec.ts
test('the dashboard drawer does not leak', async ({ page }) => {
await page.goto('/dashboard');
// 200 次循环,其中前 5 次为预热
const { baseline, after } = await soak(page, () =>
openAndCloseDrawer(page)
);
// 监听器不应比基线更多
expect(after.listeners).toBeLessThanOrEqual(baseline.listeners);
// 节点增长用固定阈值,而不是百分比
expect(after.nodes).toBeLessThan(baseline.nodes + 100);
});采样部分需要走 CDP,大致是这个形状:
const cdp = await page.context().newCDPSession(page);
async function sample() {
// 连续两次强制 GC,再读取指标
await cdp.send('HeapProfiler.collectGarbage');
await cdp.send('HeapProfiler.collectGarbage');
const { metrics } = await cdp.send('Performance.getMetrics');
const get = (name: string) => metrics.find((m) => m.name === name)?.value ?? 0;
return { nodes: get('Nodes'), listeners: get('JSEventListeners') };
}让 200 次循环等于真实的 100 分钟
这里有个容易被忽略的陷阱:200 次循环两分钟内就能跑完,但一个 30 秒轮询一次的定时器,在这两分钟里只会触发 4 次 —— 远低于用户真实使用 1 小时里的 120 次。也就是说,跑得快本身会掩盖泄漏。
解决办法是把时间「拨快」:
- 虚拟时钟:
page.clock.install()接管时间,每次循环后用runFor(18_000)手动推进 18 秒,让轮询定时器如期触发。200 次 × 18 秒 ≈ 覆盖真实使用 约 100 分钟。 - 网络模拟:用
page.route()+route.fulfill()返回与真实接口体量相当的响应,避免真实网络延迟拖慢循环,同时保留泄漏真正的触发条件(响应体大小、字段结构都要接近真实,否则测不出与数据量相关的泄漏)。
什么值得测,什么不用测
判断标准很简单:这段操作结束后,状态是否应当回到原点。应当回到原点却没回去,就是泄漏;本来就该留下东西,那测了也只是白报警。
- 打开又关闭一个抽屉 / 弹窗
- 设置又清除表格筛选条件
- 进入某个路由再退回上一级
- 任何应当「回到初始状态」的往返操作
- 无限滚动列表 —— 内存本就该增长
- 聊天界面 —— 消息本应被保留
- AI 流式响应 ——
route.fulfill无法模拟
落地建议
Soak Test 单次耗时以分钟计,放进每个 PR 的流水线并不划算,更合适的位置是夜间定时任务:跑核心页面的几条往返流程,发现指标越线再回溯到具体提交。
工具方面可以参考 playwright-soak-test。
本文整理自 Den Odell 的 Your SPA Is Leaking Memory. Soak Test It(2026-07-29),并补充了部分个人理解。