写过前端的人都以为 SPA 内存泄漏只能靠用户投诉——今天一个 Playwright 包把这件事彻底原生化了

写过前端的人都以为 SPA 内存泄漏只能靠用户说「这页面越用越卡」才能发现。等用户投诉了再去找泄漏,浏览器 tab 早就烂透了。

这不是前端工程师的错觉。传统的 E2E 测试每次都启新浏览器 context,泄漏还没来得及累积测试就结束了。真正的问题只有等到生产环境,用户连续用几个小时才暴露。

Chrome DevTools Protocol 给了一种解法:测 DOM 节点数和事件监听器数。 DOM 节点只增不减,意味着有东西从页面移除但还在内存里。监听器只增不减,意味着 addEventListener 没有配 removeEventListener。这两个数字在同一个浏览器 session 里循环跑 200 次,如果每次都在涨,就是泄漏。

npm 上有一个包叫 playwright-soak-test,Den Odell 写的,MIT,发布 2 周。它把你现有的 Playwright 测试 flow 包装一下,自动循环跑几百次,强制触发 GC,对比基准值和最终值,超过阈值直接让测试失败。

import { test } from 'playwright-soak-test';

test('抽屉开合不泄漏内存', async ({ page, soak }) => {
  await page.goto('/dashboard');
  await soak.run(async () => {
    await page.getByRole('button', { name: 'Report' }).click();
    await page.getByRole('button', { name: 'Close' }).click();
  });
});

循环默认跑 200 次,先跑 5 次 warmup 建立基准,然后每隔 25 次取一次读数。每轮读数前都会强制 GC,保证读到的是真实内存占用而不是浏览器待释放的临时对象。

报告长这样:

[soak] drawer — 200 iterations
  base   heap 2.77MB  nodes 45  listeners 163  docs 1
  @ 50   heap 2.97MB  nodes 45  listeners 164  docs 1
  @ 100  heap 3.07MB  nodes 45  listeners 163  docs 1
  @ 200  heap 3.21MB  nodes 45  listeners 163  docs 1
  delta  heap +0.44MB  nodes +0  listeners +0  docs +0
  per-iteration  heap 2.27KB  nodes 0.00  listeners 0.000

这个包不只测节点和监听器,还测 heap 大小和 iframe 文档数。heap 是兜底的——有些泄漏是闭包持有组件树,或者无限增长的数组,节点数和监听器数都不动,只有 heap 在爬坡。

setTimeout 是最常见的泄漏源。44% 的 SPA 泄漏来自 timer 没清。Den Odell 在他的博客里做了实验:如果每个 flow 里有个每 30 秒查一次的 polling,200 次循环在真实时间下只跑 2 分钟,timer 只触发 4 次;但一小时生产环境中会触发 120 次。Playwright 的 page.clock.install() 可以伪造时间,让 2 分钟的测试覆盖一小时 timer 累积:

await page.clock.install({ time: start });
await page.goto('/dashboard');
await page.clock.pauseAt(new Date(start.getTime() + 10_000));

const { baseline, after } = await soak(page, async () => {
  await openAndCloseDrawer(page);
  await page.clock.runFor(18_000); // 快进 18 秒
});

expect(after.listeners).toBeLessThanOrEqual(baseline.listeners);

不过 polling 里还有真实网络请求,如果想精确模拟,还要配合 page.route() 把网络请求也 mock 掉,才能保证 timer 触发频率和真实环境一致。

作者扫了 500 个生产仓库,86% 都有未清理的 listener 或 timer。只有 14% 是干净的。最大的泄漏来源不是 listener,而是 setTimeout 系列(44%),其次是 detached DOM 持有闭包引用。

playwright-soak-test 只是一个 smoke alarm,不是 debugger。它告诉你哪个 flow 在泄漏,不告诉你是谁在持有内存。想找到具体对象,用 Meta 的 memlab 做 heap snapshot diff,但那太重了,没人会每次 PR 都跑。

建议是:每个高频 user flow 写一个 soak test,放进夜间 CI job。 每次 PR 不跑(数值波动会让 baseline 不稳定),但每天跑一次,第二天早上看报告。发现哪个 flow 的节点数或监听器数在爬坡,单独拎出来用 memlab 定位。

下一步:找一个你在维护的 SPA,挑一个最常用的 user flow,npm install -D playwright-soak-test,照着上面的例子写第一行测试,看看跑完是什么结果。

评论区

0 条评论

登录后可评论。