我让一个页面跑了三天,从 Performance API 的长周期数据里揪出两处没被回收的闭包引用

你可能会说,跑个三天三夜也太夸张了。但我就是这么干的——一个重交互的管理后台页面,Chrome 开着放了整整 72 小时,每隔 5 秒自动触发一组操作,然后用 Performance API 的 measureUserAgentSpecificMemory() 接口持续采样。三天后拉出数据一看,堆内存从初始的 32MB 涨到了 218MB,而且 GC 之后也回不到原来的水位。顺着时间轴上的两次“阶梯式抬升”,我最终定位到了两处闭包引用没释放的问题。下面我会把整个监控方案和排查过程讲清楚。

为什么常规的内存排查手段不够用

Chrome DevTools 的 Memory 面板能拍 Heap Snapshot、能录 Allocation Timeline,这些工具在开发阶段确实好用,但它们的致命伤在于:你得手动操作,而且没法长期挂着。真实场景里的内存泄漏往往不是几分钟内就能暴露的——用户可能在一个 SPA 页面上停留数小时,中间执行几十次操作,某些引用在 GC 之后本该释放却没有释放,累积起来才形成明显的泄漏。

另一个问题是,线上环境你怎么拍 Snapshot?你不可能让用户打开 DevTools。所以必须有一个能自动化采集、可远程上报、开销足够低的方案。performance.measureUserAgentSpecificMemory() 就是解决这个问题的关键 API。它从 Chrome 89 开始支持,能在运行时异步测量当前页面的 JS 堆内存使用量,并且会强制触发一次 Major GC 之后再采样,避免浮动的临时对象干扰数据。

这个 API 返回的数据结构大概是这样:

{
  bytes: 45200000,
  breakdown: [
    { bytes: 18000000, attribution: [{ url: "https://example.com/app.js", scope: "Window" }], types: ["JS"] },
    { bytes: 12000000, attribution: [{ url: "https://example.com/app.js", scope: "Window" }], types: ["DOM"] },
    // ...
  ]
}

bytes 是这次采样后的总堆大小,breakdown 按来源和类型做了细分。你可以看到哪些脚本贡献了多少内存,这对定位具体模块的泄漏很有帮助。不过要注意,这个 API 只能在跨域隔离的环境下使用,也就是说你的页面需要设置 Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp 这两个响应头。

搭建长期监控的具体方案

我的做法是这样的:在页面初始化时启动一个采样循环,每隔 30 秒调用一次 measureUserAgentSpecificMemory()(官方建议间隔不要低于 20 秒,因为每次调用都会触发 GC,太频繁会影响性能)。采到的数据连同时间戳一起推到内存里的一个环形缓冲区,同时每隔 5 分钟把缓冲区里的数据打包上报到后端。

关键代码骨架如下:

const SAMPLES = [];
const MAX_SAMPLES = 120; // 保留最近 1 小时的数据(30s * 120)

async function sampleMemory() {
  if (!performance.measureUserAgentSpecificMemory) {
    console.warn('measureUserAgentSpecificMemory not supported');
    return;
  }
  try {
    const result = await performance.measureUserAgentSpecificMemory();
    SAMPLES.push({
      timestamp: Date.now(),
      bytes: result.bytes,
      breakdown: result.breakdown
    });
    if (SAMPLES.length > MAX_SAMPLES) {
      SAMPLES.shift();
    }
  } catch (e) {
    // 跨域隔离未配置时会抛 SecurityError
    console.error('Memory measurement failed', e);
  }
}

setInterval(sampleMemory, 30000);

上报的时候,我会把 breakdown 里每个 attribution 的 urlbytes 做一次聚合,这样后端就能按脚本维度画出内存增长曲线。如果某个脚本的内存占用在长时间内只增不减,那基本就是泄漏点。

但是光有总量数据还不够。measureUserAgentSpecificMemory() 只能告诉你“内存涨了”,不能直接告诉你“哪几行代码导致的”。所以我还补了一层:在关键操作节点上打 Performance Mark,把操作日志和内存采样时间对齐。比如用户每次打开一个弹窗、关闭弹窗,我都用 performance.mark('dialog-open')performance.mark('dialog-close') 标记时间点。后面分析数据时,就能关联到“某次弹窗关闭后内存没有回落”这种模式。

三天数据里揪出来的两个泄漏点

说回那个跑了三天的页面。我把采样的数据导出成 CSV,用 Python 画了张时间-内存曲线,一眼就看到两个明显的阶梯:第一次在运行 8 小时左右,堆内存从 50MB 附近跳到 90MB;第二次在 26 小时左右,从 110MB 跳到 170MB。每次跳升之后,后续的 GC 都无法把内存压回跳升前的水平,说明有对象被持续引用着。

我先从第一个阶梯对应的时间点入手。查操作日志发现,那段时间页面上频繁执行了一个“导出报表”的功能,每次导出会创建一个 Web Worker 来处理数据。我顺着去看 Worker 的创建和终止逻辑,发现代码是这么写的:

function exportReport(data) {
  const worker = new Worker('/workers/report.js');
  return new Promise((resolve) => {
    worker.postMessage(data);
    worker.onmessage = (e) => {
      resolve(e.data);
      worker.terminate();
    };
  });
}

看起来没问题,每次都 terminate 了。但问题出在 report.js 内部,它引用了一个外部模块里的工具函数:

// report.js
import { formatNumber } from '../utils/format.js';

self.onmessage = function(e) {
  const result = e.data.map(item => ({
    ...item,
    formatted: formatNumber(item.value)
  }));
  self.postMessage(result);
};

formatNumber 这个函数本身没问题,但 format.js 模块在顶层定义了一个缓存对象:

// format.js
const cache = new Map();
export function formatNumber(n) {
  if (cache.has(n)) return cache.get(n);
  const formatted = // ... 格式化逻辑
  cache.set(n, formatted);
  return formatted;
}

Worker 被 terminate 之后,理论上整个 Worker 的堆都应该被回收。但 Chrome 在某些情况下,如果 Worker 内部引用了主线程传过来的 Transferable 对象或者是 SharedArrayBuffer,GC 可能会有延迟。这里的 cache 是一个普通的 Map,跟主线程没有共享引用,按理说不应该泄漏。但我通过 measureUserAgentSpecificMemory()breakdown 数据发现,report.js 这个脚本对应的内存一直在增长,即使 Worker 已经 terminate 了。

进一步排查发现,真正的问题不在 Worker 本身,而在于主线程创建 Worker 时传入的 data。这个 data 数组里每个元素都包含了对 DOM 节点的引用(因为导出功能需要读取表格里的某些状态),而 Worker 通过 postMessage 接收数据时,虽然做了结构化克隆,但主线程这边的原始对象里还保留着对 DOM 的引用。导出完成后,Promise resolve,但调用方没有清理传入的数据对象,导致这些 DOM 引用一直被闭包链条抓着。修复方法是在 exportReport 调用完成后,手动将传入的 data 置为 null,或者改用 postMessage 的 Transferable 语义把数据所有权转移给 Worker。

第二个泄漏点更隐蔽。26 小时那个时间点,日志显示用户频繁切换了页面上一个 Tab 组件。这个 Tab 组件每次切换时会动态加载不同的子组件,卸载时调用了一个 cleanup 函数。我检查了 cleanup 的逻辑,发现它确实解绑了事件监听、清空了定时器,看起来该做的都做了。但 breakdown 显示,子组件对应的那个 JS bundle 的内存一直在累积。

我翻出 Tab 组件的代码,找到了这么一段:

function TabPanel({ children, onActivate }) {
  useEffect(() => {
    const handler = () => {
      console.log('tab activated');
      onActivate?.();
    };
    window.addEventListener('focus', handler);
    return () => {
      window.removeEventListener('focus', handler);
    };
  }, [onActivate]);
  
  return <div>{children}</div>;
}

问题出在 useEffect 的依赖数组里只放了 onActivate,但父组件每次渲染时都传了一个新的匿名函数进来,导致 useEffect 频繁重新执行。这本身只是性能问题,不至于泄漏。真正泄漏的是 handler 函数内部引用了一个来自上层闭包的变量——一个包含了大量数据的列表对象。即使 removeEventListener 执行了,但在某些极端情况下(比如事件循环里有未处理的微任务队列),handler 的闭包环境没有被完全释放。加上用户频繁切换 Tab,每次切换都创建了新的闭包环境,旧的没释放干净,累积起来就形成了阶梯式增长。

修复方案是把 onActivateuseCallback 包裹,同时把 handler 里引用的那个大列表对象改用 useRef 存储,避免被闭包抓进作用域链。

定位泄漏点的通用方法论

从这次排查经历里,我总结出一个比较可靠的定位流程:

  1. 先用 measureUserAgentSpecificMemory() 建立起时间维度的内存基线,确认泄漏确实存在,并且找到泄漏发生的大致时间窗口。
  2. 把时间窗口内发生的用户操作、API 调用、路由切换等事件和内存曲线对齐,找出可疑的交互模式。
  3. 针对可疑模块,用 Chrome 的 Allocation instrumentation on timeline 功能(在 Memory 面板里选第三项)录制操作前后的堆分配情况。这个功能会按时间轴记录所有对象分配,可以精确到哪一行代码创建了多少对象。
  4. 如果录制到的数据量太大,可以用“对比”的方式:在可疑操作前拍一张 Heap Snapshot,操作后再拍一张,然后看 Delta,按 Alloc. Size 排序,找到新增且未被释放的大对象,顺着 Retainers 链找到根引用。

常见问题

measureUserAgentSpecificMemory() 在生产环境真的能用吗?会不会影响用户体验?

可以用,但有几个前提。首先你的页面必须配置跨域隔离,这意味着所有跨域资源(包括 CDN 上的脚本、图片、iframe)都需要带上正确的 CORS 头或者 Cross-Origin-Resource-Policy 头,这个改造量不小。其次,这个 API 每次调用都会触发 Major GC,在移动端低性能设备上可能会造成明显的卡顿,建议把采样间隔拉到 60 秒以上,并且只在抽样的一小部分用户(比如 5%)里开启。

除了 Performance API,还有没有其他可上报的内存指标?

performance.memory 提供 usedJSHeapSizetotalJSHeapSize 等指标,但它只在 Chrome 里可用,而且精度不如 measureUserAgentSpecificMemory(),不会强制 GC,数据波动大。另外,navigator.deviceMemory 可以拿到设备的内存容量(比如 4GB、8GB),有助于做分层分析——比如发现内存泄漏在低端设备上更容易触发 OOM。

如果闭包引用是第三方库内部造成的,怎么排查?

breakdownurl 字段可以锁定是哪个脚本文件在持续增长。如果确定是第三方库,先升级到最新版本,很多库的内存问题在新版里已经修了。如果升级解决不了,可以在 Snapshot 的 Retainers 链里找引用路径,确认是哪个 API 调用产生的闭包,然后在你的代码里找绕过的办法,比如调用完及时置空引用,或者用 WeakMap/WeakRef 替代强引用。