陌上人如玉
公子世无双

5.13 内存泄漏排查

5.13 内存泄漏排查

一句话:前端内存泄漏的本质是「本该被回收的对象,仍然被某个『活着的引用』链到 GC 根」——排查方法不是「看内存涨没涨」,而是「操作并回退后 GC,看对象是否回落」,工具是「三快照对比 + Allocation timeline + queryObjects + Detached elements」。

一、先分清:什么是泄漏,什么只是「正常增长」

⭐⭐ 一个关键判据:

   【正常】内存上涨 → 触发 GC → 回落到基线附近(有涨有落,锯齿形)
   【泄漏】内存上涨 → 触发 GC → 只回落一点点,且反复操作后越来越高(台阶形)

   ⭐ 所以「内存持续上涨」本身不是结论,必须做「**回退 + 强制 GC**」再看。

✅ 正确的判定流程(一定要「操作 → 回退 → GC → 再操作」):
   ① 记录基线(打开页面,等稳定)
   ② 执行操作(如「打开 Modal → 关闭」,来回 10 次)
   ③ 【回到初始状态】(关闭所有 Modal)
   ④ 强制 GC(DevTools Memory 面板的垃圾桶图标)
   ⑤ 记录当前值 → 与基线比较
   ⑥ 重复 ②~⑤ → 如果每次「回退后」都比上一次高 → ⭐ 泄漏确认
// ✅ 开发期用一个「指标面板」观察三个关键数字(Chrome 的 Performance monitor)
// 打开方式:DevTools → 更多工具 → Performance monitor
// 关注的三个值:
//   · JS heap size       —— JS 堆内存
//   · DOM Nodes          —— DOM 节点数(⭐ 泄漏时它不会回落)
//   · JS event listeners —— 事件监听器数量(⭐ 泄漏时它不会回落)
//
// 也可以自己采样(Chrome 专有 API):
interface ChromeMemoryInfo {
  usedJSHeapSize: number;
  totalJSHeapSize: number;
  jsHeapSizeLimit: number;
}
const mem = (performance as unknown as { memory?: ChromeMemoryInfo }).memory;

setInterval(() => {
  if (!mem) return;
  console.log(
    `heap ${(mem.usedJSHeapSize / 1048576).toFixed(1)}MB / ` +
    `DOM ${document.getElementsByTagName('*').length} 节点`
  );
}, 3000);

二、React 场景下的八类泄漏源

⭐⭐ 按「出现频率 × 隐蔽程度」排序:

① 【订阅 / 监听未清理】(最常见)
   window/document 的 addEventListener、第三方 SDK 的 on、
   EventSource、WebSocket、BroadcastChannel
   → 组件卸载后回调仍被调用(并且闭包持有整棵旧树)

② 【定时器未清理】
   setInterval / setTimeout / requestAnimationFrame
   ⚠️ setInterval 尤其危险:它「每一轮都新建闭包」,
      而闭包里的 state 快照一直无法回收

③ 【Observer 未断开】
   IntersectionObserver / ResizeObserver / MutationObserver / PerformanceObserver
   → 必须 disconnect()(且要「先 unobserve 元素」再 disconnect 才算干净)

④ 【异步请求未中止】
   fetch 未 abort、Promise 链未取消
   → 组件卸载后仍持有「组件闭包」与「响应数据」

⑤ 【全局缓存无上限】
   `const cache = new Map()` 一直 set 从不 delete
   → 页面停留越久越大(典型:路由参数做 key 的缓存、
     「请求去重」的 Map、图片预加载的 Map)

⑥ 【第三方实例未销毁】
   chart(ECharts/Chart.js)、地图(Mapbox/高德)、编辑器(Monaco/Quill/TipTap)、
   视频播放器、拖拽库(Sortable)
   → 必须调用 destroy()/dispose(),且要「先解除 DOM 绑定」

⑦ 【Detached DOM(分离 DOM)】
   DOM 节点已从文档树移除,但仍被 JS 引用
   → 最常见原因:「把 DOM 节点存进 ref/数组/Map 里」而没有在卸载时清掉
   ⚠️ 一个 detached 的 div 若引用了它的父/子,会「带出一整棵子树」

⑧ 【闭包保留大对象】(不容易被叫「泄漏」,但影响很大)
   useEffect 里 `[]` 依赖捕获了一个「每次渲染都新建的大对象」
   → 第一次的那个大对象永远活着(虽然只保留一份,但可能很大)
// ⭐ ①②③④ 的正确写法:一个「自动清理」的 Hook
import { useEffect, useRef } from 'react';

/**
 * 统一注册「需要清理的资源」,组件卸载或依赖变化时自动释放
 */
export function useEffectResource(
  setup: (register: (cleanup: () => void) => void) => void,
  deps: React.DependencyList
) {
  useEffect(() => {
    const cleanups: Array<() => void> = [];
    const register = (fn: () => void) => { cleanups.push(fn); };

    setup(register);

    return () => {
      // ⭐ 逆序清理(与注册顺序相反,符合「后进先出」的依赖关系)
      for (let i = cleanups.length - 1; i >= 0; i--) {
        try {
          cleanups[i]!();
        } catch (err) {
          console.error('[useEffectResource] 清理失败', err);
        }
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);
}

// 使用
function Chat({ roomId }: { roomId: string }) {
  useEffectResource((register) => {
    const ws = new WebSocket(`/ws/${roomId}`);
    register(() => ws.close());

    const onKey = (e: KeyboardEvent) => {/* ... */};
    window.addEventListener('keydown', onKey);
    register(() => window.removeEventListener('keydown', onKey));

    const timer = setInterval(() => {/* ... */}, 1000);
    register(() => clearInterval(timer));

    const observer = new IntersectionObserver(() => {/* ... */});
    observer.observe(document.body);
    register(() => { observer.disconnect(); observer.unobserve(document.body); });

    const controller = new AbortController();
    fetch('/api/history', { signal: controller.signal });
    register(() => controller.abort());
  }, [roomId]);

  return <div>{roomId}</div>;
}
// ⑤ 的正确写法:给「全局缓存」加上限与淘汰
class BoundedCache<K, V> {
  private map = new Map<K, { value: V; expiresAt: number }>();
  constructor(private maxSize: number, private ttlMs: number) {}

  set(key: K, value: V) {
    // ⭐ 先清理过期项
    const now = Date.now();
    for (const [k, v] of this.map) {
      if (v.expiresAt <= now) this.map.delete(k);
    }
    // ⭐ 再按 LRU 淘汰(Map 的迭代顺序就是插入顺序)
    if (this.map.size >= this.maxSize) {
      const oldest = this.map.keys().next().value;
      if (oldest !== undefined) this.map.delete(oldest);
    }
    this.map.set(key, { value, expiresAt: now + this.ttlMs });
  }

  get(key: K): V | undefined {
    const entry = this.map.get(key);
    if (!entry) return undefined;
    if (entry.expiresAt <= Date.now()) {
      this.map.delete(key);
      return undefined;
    }
    // LRU:命中后移到末尾
    this.map.delete(key);
    this.map.set(key, entry);
    return entry.value;
  }

  get size() { return this.map.size; }
}

// ⭐ 项目里所有的「请求去重缓存/图片缓存/查询缓存」都应该用这种「有上限」的实现

三、工具之一:三快照对比(Heap snapshot)

⭐⭐⭐ 三快照法(Google 官方推荐,比「两个快照对比」更可靠):

   ① 打开 DevTools → Memory → Heap snapshot → 拍【快照 1】(基线)
   ② 执行「可疑操作」,然后【回退到初始状态】(如「打开/关闭 Modal」)
   ③ 拍【快照 2】
   ④ 再执行一次同样的操作 + 回退
   ⑤ 拍【快照 3】
   ⑥ 在快照 3 的视图下拉框里选 "Comparison",与【快照 2】对比
   ⑦ 按 "# Delta"(新增数量)排序 → 找「新增且数量稳定的对象」

   ⭐ 为什么用 3 个而不是 2 个?
     因为「第一次操作」往往有「懒初始化」的一次性分配(缓存、编译结果、单例)。
     用「快照 2 与 快照 3」对比,这些一次性分配就被过滤掉了,
      剩下的「每次操作都新增且不释放」才是真正的泄漏。
⭐ 快照里四个必须理解的列/概念:

① 【Shallow Size】对象自身占用的内存(不含它引用的对象)
② 【Retained Size】⭐ 该对象被回收后,能连带释放的总内存
   → 「Retained Size 大」才是问题(说明它『压着』一大片)
③ 【Distance】到 GC 根的最短路径长度
   ⭐ 这是找「泄漏路径」的关键:Distance 越短越可能是「直接挂在全局上的」
④ 【Constructor】构造函数名
   → 找自己的类:`MyEditor`、`MyChart`;找闭包:`(closure)`;
     找分离 DOM:`Detached HTMLDivElement`

⭐⭐ 定位「是谁在引用它」——最快的方法:
   选中可疑对象 → 面板下方出现「Retainers(引用者)」树
   → 逐层往上看,直到找到「不该持有它的那个对象」
   → ⭐ 常见的「元凶」长这样:
      · `Window / window.onresize → (closure) → component state`
      · `Map (global cache) → entry → DOM node`
      · `Array (queued callbacks) → (closure) → props`
// ⭐⭐ 一个非常有用但很少人知道的技巧:queryObjects

// 在 DevTools console 里执行:
const eds = queryObjects(MyEditor);        // 返回「当前活着」的所有 MyEditor 实例
eds.length                                  // 如果「打开/关闭」多次后数量持续增长 → 泄漏

queryObjects(HTMLDivElement);                // 所有 div 元素(配合 detached 检测)
queryObjects(HTMLElement);

// ⭐ 更实用的一步:直接在 console 里「主动释放」来验证因果
eds.forEach((e) => e.destroy());
// 如果「释放后内存回落」→ 确认「这些实例的持有者是问题所在」

四、工具之二:Allocation instrumentation on timeline

⭐ 与「快照对比」的区别:

   · Heap snapshot         → 「某一时刻所有对象的快照」(适合找「谁持有」)
   · Allocation timeline   → 「一段时间内【新分配】的对象」(适合找「什么时候分配、谁分配」)

⭐⭐ 用法(找「反复分配且不回收」的对象):
   ① Memory → "Allocation instrumentation on timeline" → 开始录制
   ② 执行「操作 → 回退」若干次
   ③ 停止录制
   ④ 蓝条 = 仍存活的新分配;灰条 = 已被回收
   ⑤ ⭐ 过滤「蓝条」(仍存活的)→ 展开 → 看它是在哪个调用栈分配的
      → 直接指向「创建它的那行代码」(这是最快定位「分配源头」的方法)
⭐ 与「长任务/卡顿」的配合:
   Allocation timeline 录制会带来显著开销(会强制采样分配),
   所以它适合「定位分配点」,不适合「测真实性能」。
   真实性能用 Performance 面板(见 5.12)。

五、工具之三:Detached elements(分离 DOM)

⭐⭐ Chrome DevTools 有专门的 "Detached elements" 面板
   (也可用 Memory → 快照里搜 "Detached")

   它直接列出「已经从文档移除、但仍被 JS 引用」的 DOM 节点,
   并**给出引用它的那个 JS 对象**(这是最直观的「分离 DOM 定位」)。

⭐ 最常见的三种「分离 DOM」成因:
   ① 把 DOM 节点存进「ref / 数组 / Map / 全局变量」里,卸载时忘了清
   ② 第三方库「把节点缓存在内部」,只 remove 了节点但没调用库的 destroy
   ③ 「闭包」捕获了 DOM(如事件回调里 `const el = e.currentTarget`)

⚠️ 注意:一个 detached 节点如果「引用了它的子树」,
  会连带一整棵子树都不能回收(Retained Size 会突然很大)。
// ✅ 一个「检测分离 DOM」的开发期哨兵(可把它挂在开发工具栏上)
export function useDetachedDomWatch(intervalMs = 5000) {
  useEffect(() => {
    if (process.env.NODE_ENV === 'production') return;

    const seen = new Set<Node>();
    const timer = setInterval(() => {
      // ⭐ 注意:这里用「已挂载节点总数」的变化做粗判,
      //    精确的 detached 检测需要 DevTools 的 Heap snapshot / Detached elements 面板
      const nodes = document.getElementsByTagName('*');
      const detachedCandidates: Element[] = [];

      // 遍历「曾经记录过但现在不在文档里」的节点
      seen.forEach((node) => {
        if (node.nodeType !== Node.ELEMENT_NODE) return;
        const el = node as Element;
        if (!el.isConnected) detachedCandidates.push(el);
      });

      if (detachedCandidates.length > 0) {
        console.warn(
          `[detached-watch] 发现 ${detachedCandidates.length} 个「曾记录过且已脱离文档」的节点`,
          detachedCandidates.slice(0, 5)
        );
      }

      // 记录当前所有元素(供下一轮比较)—— 仅在开发期,且限制数量
      if (nodes.length < 5000) {
        for (const el of Array.from(nodes)) seen.add(el);
      }
    }, intervalMs);

    return () => clearInterval(timer);
  }, [intervalMs]);
}

六、完整实现:一个 230 行的「资源泄漏哨兵」

// leak-sentinel.ts —— 开发期「未释放资源」统计器
// ⚠️ 仅在 development 使用(它会 patch 全局 API,有性能开销)

interface ResourceRecord {
  kind: 'timeout' | 'interval' | 'listener' | 'observer' | 'raf';
  label: string;
  createdAt: number;
  stack: string;
  released: boolean;
}

class LeakSentinel {
  private records: ResourceRecord[] = [];
  private installed = false;

  /** 当前「未释放」的资源数 */
  get pending(): number {
    return this.records.filter((r) => !r.released).length;
  }

  /** 按种类统计未释放数量 */
  pendingByKind(): Record<string, number> {
    const out: Record<string, number> = {};
    for (const r of this.records) {
      if (r.released) continue;
      out[r.kind] = (out[r.kind] ?? 0) + 1;
    }
    return out;
  }

  /** ⭐ 抓取「分配点」堆栈(这是能定位到代码行的关键) */
  private captureStack(): string {
    const err = new Error();
    const lines = (err.stack ?? '').split('\n').slice(2, 5);
    return lines.map((l) => l.trim()).join(' ← ');
  }

  private add(kind: ResourceRecord['kind'], label: string): number {
    this.records.push({ kind, label, createdAt: Date.now(), stack: this.captureStack(), released: false });
    return this.records.length - 1;
  }

  private release(index: number) {
    const rec = this.records[index];
    if (rec) rec.released = true;
  }

  /** ============ 安装 patch ============ */
  install() {
    if (this.installed || typeof window === 'undefined') return;
    this.installed = true;

    // ---------- ① 定时器 ----------
    const rawSetTimeout = window.setTimeout;
    const rawClearTimeout = window.clearTimeout;
    const timerIndex = new Map<number, number>();

    window.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
      const id = rawSetTimeout(handler as never, timeout, ...args) as unknown as number;
      timerIndex.set(id, this.add('timeout', `setTimeout(${timeout ?? 0})`));
      return id;
    }) as typeof window.setTimeout;

    window.clearTimeout = ((id?: number) => {
      const idx = id !== undefined ? timerIndex.get(id) : undefined;
      if (idx !== undefined) {
        this.release(idx);
        timerIndex.delete(id!);
      }
      return rawClearTimeout(id);
    }) as typeof window.clearTimeout;

    const rawSetInterval = window.setInterval;
    const rawClearInterval = window.clearInterval;
    const intervalIndex = new Map<number, number>();

    window.setInterval = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
      const id = rawSetInterval(handler as never, timeout, ...args) as unknown as number;
      intervalIndex.set(id, this.add('interval', `setInterval(${timeout ?? 0})`));
      return id;
    }) as typeof window.setInterval;

    window.clearInterval = ((id?: number) => {
      const idx = id !== undefined ? intervalIndex.get(id) : undefined;
      if (idx !== undefined) {
        this.release(idx);
        intervalIndex.delete(id!);
      }
      return rawClearInterval(id);
    }) as typeof window.clearInterval;

    // ---------- ② 事件监听 ----------
    const rawAdd = EventTarget.prototype.addEventListener;
    const rawRemove = EventTarget.prototype.removeEventListener;
    const listenerIndex = new Map<string, number[]>();

    EventTarget.prototype.addEventListener = function (
      type: string,
      listener: EventListenerOrEventListenerObject | null,
      options?: boolean | AddEventListenerOptions
    ) {
      const targetName =
        this === window ? 'window' : this === document ? 'document' : describeTarget(this as EventTarget);
      const key = `${targetName}|${type}`;
      const arr = listenerIndex.get(key) ?? [];
      arr.push(sentinel.add('listener', `addEventListener(${targetName}, ${type})`));
      listenerIndex.set(key, arr);
      return rawAdd.call(this, type, listener, options);
    } as typeof EventTarget.prototype.addEventListener;

    EventTarget.prototype.removeEventListener = function (
      type: string,
      listener: EventListenerOrEventListenerObject | null,
      options?: boolean | EventListenerOptions
    ) {
      const targetName =
        this === window ? 'window' : this === document ? 'document' : describeTarget(this as EventTarget);
      const key = `${targetName}|${type}`;
      const arr = listenerIndex.get(key);
      const idx = arr?.pop();
      if (idx !== undefined) sentinel.release(idx);
      return rawRemove.call(this, type, listener, options);
    } as typeof EventTarget.prototype.removeEventListener;

    // ---------- ③ Observer ----------
    for (const Ctor of [window.IntersectionObserver, window.ResizeObserver, window.MutationObserver]) {
      if (!Ctor) continue;
      const rawObserve = Ctor.prototype.observe;
      const rawDisconnect = Ctor.prototype.disconnect;
      const idxMap = new WeakMap<object, number>();

      Ctor.prototype.observe = function (this: object, ...args: never[]) {
        if (!idxMap.has(this)) {
          idxMap.set(this, sentinel.add('observer', `${Ctor.name}.observe`));
        }
        return rawObserve.apply(this, args);
      };
      Ctor.prototype.disconnect = function (this: object) {
        const idx = idxMap.get(this);
        if (idx !== undefined) sentinel.release(idx);
        return rawDisconnect.call(this);
      };
    }

    // ---------- ④ rAF ----------
    const rawRaf = window.requestAnimationFrame;
    const rawCancelRaf = window.cancelAnimationFrame;
    const rafIndex = new Map<number, number>();

    window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
      const id = rawRaf(cb);
      rafIndex.set(id, sentinel.add('raf', 'requestAnimationFrame'));
      return id;
    }) as typeof window.requestAnimationFrame;

    window.cancelAnimationFrame = ((id: number) => {
      const idx = rafIndex.get(id);
      if (idx !== undefined) {
        sentinel.release(idx);
        rafIndex.delete(id);
      }
      return rawCancelRaf(id);
    }) as typeof window.cancelAnimationFrame;
  }

  /** ============ 报告 ============ */
  report(force = false) {
    // 已释放的记录太多时清理(避免自己成为泄漏源)
    if (this.records.length > 5000 || force) {
      this.records = this.records.filter((r) => !r.released);
    }

    const byKind = this.pendingByKind();
    const total = this.pending;
    if (total === 0) return;

    const rows = this.records
      .filter((r) => !r.released)
      .sort((a, b) => b.createdAt - a.createdAt)
      .slice(0, 20)
      .map((r) => ({ kind: r.kind, label: r.label, stack: r.stack }));

    console.group(`[leak-sentinel] 未释放资源 ${total} 个(${JSON.stringify(byKind)})`);
    console.table(rows);
    console.groupEnd();
  }

  reset() {
    this.records = [];
  }
}

function describeTarget(target: EventTarget): string {
  if (target instanceof Element) {
    const id = target.id ? `#${target.id}` : '';
    return `${target.tagName.toLowerCase()}${id}`;
  }
  return String(target.constructor?.name ?? 'unknown');
}

export const sentinel = new LeakSentinel();
// ✅ 使用:开发期在入口安装,并在「操作回退后」手动报告
if (process.env.NODE_ENV !== 'production') {
  sentinel.install();

  // 页面卸载前报告一次
  window.addEventListener('beforeunload', () => sentinel.report(true));

  // ⭐ 也可以暴露到全局,方便在 console 里手动触发
  (window as unknown as { __leak: typeof sentinel }).__leak = sentinel;
}

// 手动排查流程(在 console 里):
//   __leak.reset()
//   → 打开/关闭 Modal 5 次(每次都回到初始状态)
//   __leak.report()
//   → 看「未释放」的列表:如果有 5 个 addEventListener(window, resize)
//     → 说明 Modal 里的监听没有清理 ⭐
⭐⭐ 这个哨兵的价值与边界:

   ✅ 能抓到:「定时器 / 监听器 / Observer / rAF 未释放」
   ⚠️ 抓不到:「Detached DOM」「闭包保留」「全局缓存无穷增长」「第三方实例未销毁」
      → 这四类要靠「三快照对比 + Detached elements 面板」

⭐ 使用注意:
   · 它 patch 了全局 API(有开销,且会改变 stack)→ **只在 development 用**
   · patch 会让「同一个监听器注册两次」也被计两次(这是对的,因为确实要注销两次)
   · 对于「匿名函数注册、无法 remove」的写法,它会一直显示「未释放」
     → ⭐ 这恰恰暴露了真正的问题(那种写法本来就无法清理)

七、修复清单

泄漏源 症状 正确做法
addEventListener(window/document) 回调在卸载后仍执行 Effect 清理里 removeEventListener,且函数引用要稳定(或用 AbortController 一次性清理)
setInterval 内存台阶式上涨、回调加倍执行 clearInterval;优先用「递归 setTimeout」或「rAF」避免累积
setTimeout 卸载后回调触发 setState 清理里 clearTimeout;或用 AbortController 包装
Observer DOM 元素无法回收 unobserve + disconnect
fetch 卸载后仍处理响应 AbortController.abort()
WebSocket / EventSource 连接数持续增长 close()(并考虑 visibilitychange 时暂停)
全局 Map 缓存 页面越用越卡 有上限 + TTL + LRU 的 BoundedCache
第三方实例 Detached DOM 大量出现 destroy()/dispose() + 解除 DOM 绑定
存 DOM 进 ref/Map Detached DOM 卸载时置 null / delete
闭包捕获大对象 单份大对象长期存活 依赖写全、或用 useRef 存最新值(避免「老闭包」)
全局单例「订阅了组件」 组件卸载后仍被引用 在卸载时 unsubscribe
// ✅ 一个「一次性清理」的替代方案:AbortController(比逐个 remove 更不易漏)
useEffect(() => {
  const controller = new AbortController();
  const { signal } = controller;

  window.addEventListener('resize', onResize, { signal });        // ⭐ 支持 signal
  document.addEventListener('visibilitychange', onVisible, { signal });
  fetch('/api/x', { signal });

  return () => controller.abort();                                 // ⭐ 一行清掉全部
}, []);
// ⚠️ 注意:只有 addEventListener 的「新式 options」支持 signal(现代浏览器已普遍支持)
//    Observer / setInterval 仍需单独清理

八、本篇特有的坑

① 只看「内存涨了」就断定泄漏(不做「回退 + GC」)
   → 可能只是「缓存预热」或「懒加载」,GC 后回落就正常

② 用「两个快照」对比(把一次性初始化也算成泄漏)
   → ✅ 用「三个快照」法(对比第 2 与第 3 个)

③ 忘记「强制 GC」再做对比
   → DevTools 的垃圾桶按钮;否则「等待回收」的对象会被误判为泄漏

④ 只看 Shallow Size(忽略 Retained Size)
   → 真正要关注的是「Retained Size 大」的对象(它压着一大片内存)

⑤ 在快照里迷失(几万个对象)→ 不会用「Comparison 视图 + 按 # Delta 排序」
   → ✅ Comparison + 排序 + 搜索自己的类名(`MyChart`、`(closure)`、`Detached`)

⑥ 不知道 `queryObjects(SomeClass)`(明明一秒能验证的事查半天)
   → ✅ console 里 `queryObjects(MyEditor).length`

⑦ 忽略「Detached elements」面板
   → 它是最直观的「分离 DOM + 谁引用它」工具

⑧ 在 Profiler / 快照里做「性能结论」
   → 内存工具对**性能**的结论不可靠(采样开销大)→ 用 Performance 面板

⑨ 修了「监听器」但没修「监听器闭包里捕获的 state」
   → 监听器清掉了,但闭包仍被别的地方持有(如「存在数组里的回调」)

⑩ 忽略「开发期 StrictMode 双挂载」造成的假象
   → 开发期 Effect 会「挂载 → 卸载 → 再挂载」,
     如果你的清理不完整,**内存数据会明显失真**(生产环境不会有第二次挂载)
   → ✅ 先确认「清理是否完整」,再判断是否真有泄漏

⑪ 认为「内存泄漏只影响内存」
   → 它通常先表现为「越用越卡」(GC 频繁 + 遍历变慢),
     然后才是崩溃(OOM)

⑫ 只在「本地稳定网络」下测(长会话泄漏需要「长时间反复操作」才暴露)
   → ✅ 用自动化脚本「循环操作 100 次」再采样

⑬ 忽略「第三方 SDK 的隐藏订阅」
   → 埋点/客服/广告 SDK 常在 window 上挂监听 → 用哨兵统计后逐一核对

⑭ 「内存回来了」就以为修好了(没有验证「对象数量」)
   → ✅ 同时看「DOM Nodes / listeners / queryObjects 数量」是否回落

⑮ 在生产环境跑「内存哨兵」
   → patch 全局 API 有开销且可能被反作弊/监控误判 → ✅ 只在 development 用
⭐ ⑩ 的详细说明(这个坑非常容易被忽略):

   开发期 React StrictMode 会「模拟卸载再挂载」:
     mount → run effect → cleanup → run effect again

   如果「cleanup 缺失」:
     ❌ 你会看到「同一份资源被注册两次」(且第一次永远不会被清理)
     ⭐ 这反而是 StrictMode 的【价值】:它把「清理不完整」的 bug 提前暴露了

   但如果你用「哨兵」在开发期统计,就会看到「数量翻倍」的表象,
   误以为「生产也会泄漏」——实际上生产只挂载一次。

   ✅ 正确做法:先在开发期修「cleanup 缺失」(StrictMode 的提示是对的),
      再用「生产构建」做内存采样(避免双挂载的干扰)。

九、面试延伸

  1. 「怎么判断一个页面有没有内存泄漏?」
    核心判据是「操作并回退后再强制 GC,看是否回落」——正常内存是「锯齿形」(上涨后 GC 回落),泄漏是「台阶形」(每次操作后回退,基线都更高)。流程是:① 记录基线;② 执行可疑操作(如「打开/关闭 Modal」)并回到初始状态;③ 强制 GC;④ 对比基线;⑤ 重复多次,如果每次回退后都更高 → 泄漏确认。同时观察三个数字:JS heap size、DOM Nodes、JS event listeners(后两者在泄漏时不会回落)。

  2. 「用什么工具排查?各自的强项是什么?」
    四件套:① Heap snapshot(三快照法)——找「谁持有」泄漏对象(看 Retainers 链、Comparison 视图、Retained Size);② Allocation instrumentation on timeline——找「什么时候分配、在哪个调用栈分配」(过滤蓝条=仍存活的分配);③ queryObjects(SomeClass)——在 console 里直接数「某类实例有多少」(如 queryObjects(MyEditor).length),一秒验证「实例是否累积」;④ Detached elements 面板——直接列出「已从文档移除但仍被 JS 引用」的 DOM 节点及引用它的对象。

  3. 「为什么要用『三个快照』而不是两个?」
    因为第一次操作往往有「一次性分配」(懒初始化、缓存预热、单例创建、代码编译),用两个快照对比会把这些算作泄漏。三快照法是「执行操作+回退 → 快照 2 → 再执行+回退 → 快照 3」,然后对比快照 3 与快照 2——一次性分配在两次之间不再增长,被自然过滤掉,剩下的「每次操作都新增且不释放」才是真正的泄漏。

  4. 「React 里最常见的内存泄漏有哪些?」
    按频率:① 订阅/监听未清理(window.addEventListener、第三方 SDK);② 定时器未清理(setInterval 尤其危险,因为每轮都新建闭包);③ Observer 未 disconnect(Intersection/Resize/Mutation);④ fetch 未 abort;⑤ 全局缓存无上限(Map 只增不减);⑥ 第三方实例未 destroy(图表、地图、编辑器 → 直接导致 Detached DOM);⑦ 把 DOM 存进 ref/数组/Map 而不清理;⑧ 闭包保留大对象(Effect 依赖写错,[] 捕获了每次新建的大对象)。

  5. 「Detached DOM 是什么?怎么定位?」
    Detached DOM 指「已经从文档树移除、但仍被 JS 引用」的节点——它们不会被 GC 回收,且如果引用了自己的子树,会连带一整棵子树无法回收(Retained Size 会突然很大)。定位用 DevTools 的 “Detached elements” 面板(它直接列出分离节点 + 引用它的对象),或 Heap snapshot 里搜 Detached。常见成因:把 DOM 存进 ref/Map 没清、第三方库只 remove 节点未 destroy、闭包捕获了 e.currentTarget。

  6. 「怎么快速验证『某类实例有没有累积』?」
    在 DevTools console 里用 queryObjects(Constructor):queryObjects(MyEditor).length ——如果「打开/关闭 5 次后长度变成 5」就确认了累积。进阶:直接调用 queryObjects(MyEditor).forEach((e) => e.destroy()),然后观察内存是否回落——如果回落,就确认了这些实例的持有者是问题所在(这是最快建立「因果」的方法)。

  7. 「开发环境看到内存翻倍,是泄漏吗?」
    不一定是——因为 StrictMode 会「挂载 → 卸载 → 再挂载」(模拟 Effect 的完整生命周期),如果你清理不完整,开发期就会看到「同一份资源注册了两次」且第一次永不释放。这恰恰是 StrictMode 的价值(提前暴露清理缺失),但生产环境只挂载一次,所以不能直接把开发期数字当结论。正确做法:先把 StrictMode 暴露出的「cleanup 缺失」都修掉,再用生产构建做内存采样。

  8. 「内存泄漏的『业务表现』是什么?」
    它通常先表现为「越用越卡」(GC 频率变高 + 存活对象多导致遍历变慢 + 内存压力下浏览器降频),然后才是「标签页崩溃(OOM)」。特别是 SPA 长时间驻留(后台系统、监控大屏、客服工作台)——每次路由切换都泄漏一点,几小时后明显卡顿。所以「内存问题应当作为『长会话体验』的一部分被监控」(可参考 5.11 的思路,上报 usedJSHeapSize 与 DOM 节点数)。

一句话速记

判据不是「内存涨了」,而是「操作 → 回退 → 强制 GC → 是否回落」(锯齿形正常、台阶形泄漏),同时看 JS heap / DOM Nodes / JS event listeners 三项;工具四件套:三快照对比(对比第 2、3 个快照以过滤一次性初始化,看 Comparison 的 # Delta 与 Retainers 链)、Allocation timeline(找分配源头)、queryObjects(SomeClass)(直接数实例,最快验证累积)、Detached elements 面板(找分离 DOM 与它的引用者);Shallow Size 不重要,Retained Size 大才是问题;React 的重点泄漏源是「订阅/定时器/Observer/fetch 未清理、全局缓存无上限、第三方实例未 destroy、DOM 存进 ref/Map、闭包保留大对象」,统一解法是「在 Effect 清理里逆序释放(AbortController 能一行清掉监听与请求)+ 给缓存加 TTL/LRU 上限 + 实例必须 destroy」;开发期 StrictMode 的双挂载会让内存数字翻倍——那是清理缺失的提示,但结论要在生产构建里下。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.13 内存泄漏排查

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫