把前端内存泄漏排查从2小时压到5分钟,我们把它集成进了CI

18 阅读1分钟

凌晨三点,手机又震了——「页面白屏,JS heap 超过 2GB,已经自动重启三次」。线上前端 OOM 永远在用户量最大的时候精准暴雷。一边重启 Pod 续命,一边打开 Chrome DevTools,Memory 面板,Record Allocation、对比 Snapshot、筛 Detached DOM、找 Retaining Path……一套流程下来,天亮之前修好算运气好。问题根因通常就是某个组件卸载时没清定时器、闭包持有了大对象、事件监听打满了引用链——一句话,典型的内存泄漏

但真正让人崩溃的不是修不好,而是每次发版都可能带入新泄漏,而每次验证都要靠人肉点开几十个页面、手工操作、盯半天快照。这事不能再这么干下去了。

问题拆解:为什么“手工查”挡不住线上事故

前端内存泄漏的锅,通常扣在 SPA 里:全局事件没解绑、setInterval 没停、闭包里闭了 DOM、WebSocket 重连连出无穷分身。这些泄漏平时 QA 功能测试根本发现不了,因为只要不连续操作足够长时间,堆内存的增长根本不会触发 GC 临界点。只有真实用户长时间停留、疯狂交互,才会把 JS heap 推过容器上限,然后被 OOM Killer 教做人。

常规方案有几种:

  • 发版前用 Chrome DevTools 手动走一遍核心链路? 覆盖范围有限,依赖人的耐力,没人愿意每天点 40 分钟页面。
  • --max-old-space-size 把容器内存顶大? 治标不治本,只是延缓死刑。
  • Lighthouse / PageSpeed ? 它关心的是性能和最佳实践,不关心你的堆内存是不是在缓慢涨潮。

结论很明确:需要一个能模拟用户交互、自动采集堆内存变化、可接入 CI 流水线的检测方案,每次 PR 都跑一遍,判定是否引入泄漏。

方案设计:为什么不选其他工具,而用 Playwright + CDP

摆在桌面的选项:

  1. Puppeteer + 手撸 CDP —— 能力够,但和 Playwright 比多浏览器支持弱,且 API 设计略原始。
  2. cypress —— 测试框架很好,但背后还是 CDP,并且对多页面、多上下文的内存快照控制不够精细,插件生态离内存分析较远。
  3. 直接用 Node.js 调 Chrome DevTools Protocol —— 自由度高,但要处理启动、关闭、生命周期,徒增脚手架代码。

最终选 Playwright,原因是它自带多浏览器调度,且提供 page.context().newCDPSession(),可以像用手术刀一样直接和 V8 Heap Profiler 对话。而其他方案要么太远,要么多余。

架构思路很简单:用 Playwright 启动目标页面 → 触发关键用户流程 → 强制 GC → 采基线内存快照 → 重复操作 → GC → 采最终快照 → 对比差异 → 产出报告 → 在 CI 中以非零退出码拦住 PR

这个链路拆成三个核心模块:

  • 采集层:通过 CDP 获得堆内存数据(JS heap size、节点计数等)。
  • 判定层:一套规则引擎,对比操作前后指标的增长率。
  • CI 适配层:生成结构化输出,适配 GitHub Actions / GitLab CI 的返回码和 Artifacts。

核心实现:从零搭建一个可落地的 Playwright 内存检测脚本

下面给出这段开箱即用的 Node.js 脚本。它的职责:打开页面,模拟两次业务操作,对比堆内存增长。

1. 启动浏览器并建立 CDP 会话,获取页面

这段代码搞定 Playwright 启动与 CDP 连接,同时设置一个关键配置:使用 headless: true 但带上 --enable-precise-memory-info,否则拿到的内存数据是粗粒度的。

const { chromium } = require('playwright');

async function main() {
  const browser = await chromium.launch({
    headless: true,                // CI 环境必须无头
    args: ['--enable-precise-memory-info'] // 解锁精确堆内存数据
  });
  const context = await browser.newContext({
    viewport: { width: 1280, height: 720 }
  });
  const page = await context.newPage();

  // 创建 CDP 会话,直通 V8 Heap Profiler
  const cdp = await context.newCDPSession(page);
  await cdp.send('HeapProfiler.enable');
  // 先确认页面可访问
  await page.goto('http://localhost:3000/heavy-page', { waitUntil: 'networkidle' });
  // ... 后续操作
}

main().catch(console.error);

2. 模拟用户操作,强制 GC,采集堆内存基线

接下来模拟你的典型业务路径:比如反复打开弹窗、切换 Tab、提交表单。这里用一个通用函数,每次操作后都尝试触发垃圾回收(只对 --expose-gc 的 start 有用,但 CDP 提供了更稳的方法)。

// 封装:执行 GC 并返回当前 JS heap 大小(字节)
async function getHeapSize(page) {
  // 通过 CDP 触发精确 GC(比 page.evaluate 里调 gc() 更可靠)
  const cdp = await page.context().newCDPSession(page);
  await cdp.send('HeapProfiler.collectGarbage');
  // 从 performance.memory 读取(需要 --enable-precise-memory-info)
  const memoryInfo = await page.evaluate(() => ({
    usedJSHeapSize: performance.memory?.usedJSHeapSize || 0,
    totalJSHeapSize: performance.memory?.totalJSHeapSize || 0
  }));
  return memoryInfo.usedJSHeapSize;
}

// 操作模拟:例如连续打开关闭10次侧边栏
async function simulateUserWorkflow(page) {
  for (let i = 0; i < 10; i++) {
    await page.click('#toggle-sidebar');
    await page.waitForTimeout(300);
    await page.click('#close-sidebar');
    await page.waitForTimeout(200);
  }
}

// 基线前先预热
await simulateUserWorkflow(page);
const baseHeap = await getHeapSize(page);
console.log(`基线堆内存: ${(baseHeap / 1024 / 1024).toFixed(2)} MB`);

3. 操作后再次采集,对比差异并制定通过标准

再执行一轮相同的操作,然后采集最终堆大小。判定逻辑不能一刀切,得允许一定波动——GC 不一定完美回收,单次测量容易误杀。所以我们取多次操作后的最小值做对比,或采用线性回归检测增长趋势,这里先用简单阈值。

// 再走一遍负载
await simulateUserWorkflow(page);
const finalHeap = await getHeapSize(page);
console.log(`操作后堆内存: ${(finalHeap / 1024 / 1024).toFixed(2)} MB`);

const growthMB = (finalHeap - baseHeap) / 1024 / 1024;
const thresholdMB = 10; // 增长超过10MB视为泄漏

if (growthMB > thresholdMB) {
  console.error(`❌ 检测到潜在内存泄漏,增长 ${growthMB.toFixed(2)} MB`);
  process.exit(1);        // CI 红线
} else {
  console.log(`✅ 内存检查通过,增长 ${growthMB.toFixed(2)} MB`);
}

await browser.close();

注意: 现实场景单靠 JS heap size 会漏掉 Detached DOM 这种妙妙屋。更精确的方案是对比两次 Heap Snapshot 的节点数和特定类型对象(比如 Detached HTMLDivElement)的数量,但那需要解析 V8 Snapshot 的 heapsnapshot 文件,代码量会飙升。上文这套基于堆大小的方案,已经能在 CI 里拦截 80% 以上的显式泄漏,剩下的可以通过定期跑全量 Snapshot 对比脚本补充。

踩坑记录:官方文档没告诉你的三个暗坑

坑 1:performance.memory 在默认 Chromium 里返回 0

  • 现象:本地 macOS Chrome 能拿数据,丢进 CI 的 Linux 容器里全是 0。
  • 原因:Chromium 的 --enable-precise-memory-info 仅在非无头模式生效?不,必须显式传该 flag 并且页面不能处于 prerendering 状态。
  • 解决:启动参数加上 flag,并且在 page.gotowaitUntil: 'load' 后手动延迟 500ms,确保 frame 已完全激活。

坑 2:HeapProfiler.collectGarbage 后堆大小不降反升

  • 现象:GC 后记忆体居然涨了 5MB,脚本直接误报泄漏。
  • 原因:V8 的 GC 在某些触发时机下会执行增量标记和惰性清理,反而分配了临时结构。而且 Playwright 自身的自动化代码也会产生内存开销。
  • 解决:基线采集前空跑一轮用户流程,丢弃第一次 GC 后的读数;并且增加多次 GC → 取最小值的逻辑,过滤掉 GC 噪声。
async function stableHeap(page, cdp) {
  let minHeap = Infinity;
  for (let i = 0; i < 3; i++) {
    await cdp.send('HeapProfiler.collectGarbage');
    await page.waitForTimeout(500);
    const size = await getHeapSize(page);
    if (size < minHeap) minHeap = size;
  }
  return minHeap;
}

坑 3:CI 容器里 Chromium 缺少共享库导致启动失败

  • 现象executablePath 找不到或报 libnss3.so 缺失。
  • 解决:在 Dockerfile 里安装 Playwright 依赖(npx playwright install-deps chromium),或用官方mcr.microsoft.com/playwright 镜像。

效果验证:从 2 小时到 5 分钟,线上故障归零

集成前,前端团队靠“发版后暗自观察半天,顺便祈祷”。平均每 3 周发生一次生产环境 OOM 重启。每次排查耗时 1~2 小时,还经常靠远程共享屏幕抓现场。

集成进 CI 后,每次 PR 自动触发检测脚本,5 分钟内出结果。我们把增长阈值设为 10MB,成功拦截了 4 次潜在泄漏(两次闭包持有了大 JSON、一个没有卸载的 ResizeObserver、一个忘记取消的 setInterval)。过去一个季度,由内存泄漏引起的线上事故为 0

对比数据:

指标手工排查自动化 CI 检测
单页面检测耗时30~120 min3~5 min
覆盖率只测怀疑页面所有 PR 核心页面
误报率(手工不误)<5%(已去噪)
线上泄漏事故/季4 次0 次

直接拿去用的脚本模版

# 安装
npm install playwright
npx playwright install chromium
# 运行检测
node detect-memory-leak.js   # 如果泄漏,exit code 为 1
# GitHub Actions 示例片段:
# - name: Run memory leak check
#   run: node detect-memory-leak.js
#   continue-on-error: false

把上面的 detect-memory-leak.js 替换成你自己的目标 URL 和交互逻辑,第一次运行就可能帮你揪出一个潜伏已久的泄漏


#前端 #Playwright #自动化测试 #CI/CD #内存泄漏


关于作者
一个专啃硬骨头的全栈开发者,日常在 Node.js 和 Python 之间反复横跳,痴迷性能优化与工程化。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你省下一个通宵,可以请我喝杯咖啡
提供服务:Python/Node 后端性能优化、自动化工具定制、技术咨询,联系 Telegram @baofugege