Depuración de fugas de memoria en JavaScript en la práctica: una pipeline completa desde Chrome DevTools hasta la detección automatizada

前端工程

Fugas de memoria: el asesino silencioso de las apps frontend

Las fugas de memoria no causan un fallo inmediato: son como un veneno lento que hace que las páginas se vuelvan progresivamente más lentas hasta que al final quedan en pantalla blanca (white-screen). Esto es especialmente grave en las SPA, donde las páginas nunca se recargan.

Severidad de la fuga Síntomas Escala de tiempo
Leve Pequeña lentitud tras uso prolongado Varias horas
Moderada Lentitud notable tras cambiar de ruta 30-60 minutos
Grave Pantalla blanca/crash tras interacción Varios minutos

Seis patrones clásicos de fuga

1. Temporizadores no limpiados

// Leak: timer keeps running after component unmounts
function PollingComponent() {
  useEffect(() => {
    const timer = setInterval(fetchData, 5000);
    // Forgot to return cleanup
  }, []);
}

// Fix
function PollingComponent() {
  useEffect(() => {
    const timer = setInterval(fetchData, 5000);
    return () => clearInterval(timer);
  }, []);
}

2. Escuchadores de eventos no eliminados

// Leak: global event listener holds component reference
function ScrollComponent() {
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    // Forgot to remove
  }, []);
}

// Fix
function ScrollComponent() {
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
}

3. Referencias accidentales en closures

// Leak: closure holds reference to entire largeData
function processChunk(largeData) {
  const result = largeData.items[0].value;
  // Even though only result is used, closure still holds largeData
  return function getResult() {
    return result;
  };
}

// Fix: only capture the needed value
function processChunk(largeData) {
  const result = largeData.items[0].value;
  const captured = result; // primitive type, no reference
  return function getResult() {
    return captured;
  };
}

4. Nodos DOM desadjuntados (Detached)

// Leak: DOM removed but JS still references it
const element = document.getElementById('card');
element.remove();
// element variable still holds reference, DOM node cannot be garbage collected

// Fix: null out the reference after removal
element.remove();
element = null;

5. Referencias zombie en Map/Set

// Leak: Map holds references to destroyed components
const componentCache = new Map();

function register(id, component) {
  componentCache.set(id, component);
}

// Component destroyed but not removed from Map
// Fix: clean up on destroy
function unregister(id) {
  componentCache.delete(id);
}

6. Usar Map en lugar de WeakMap

// When keys are objects and auto-collection is desired, use WeakMap
const metadata = new WeakMap();

function attachMeta(obj, meta) {
  metadata.set(obj, meta); // when obj is GC'd, entry auto-disappears
}

// Using Map, the entry persists even after obj is GC'd

Panel de memoria de Chrome DevTools en la práctica

Paso 1: establecer la línea base

  1. Abrir DevTools → panel de Memory
  2. Hacer clic en Take heap snapshot
  3. Etiquetarlo como "Baseline"

Paso 2: operar y comparar

  1. Realizar operaciones potencialmente con fugas (p. ej., cambiar de ruta 10 veces)
  2. Hacer clic en Take heap snapshot
  3. Etiquetarlo como "After operation"
  4. Seleccionar la vista "Comparison" y comparar con Baseline

Paso 3: analizar objetos en crecimiento

Columna Significado
Added Objetos recién creados
Deleted Objetos eliminados
Delta Cambio neto en la cantidad de objetos
Allocated Size Memoria recién asignada
Freed Size Memoria liberada
Size Delta Cambio neto de memoria

Enfocarse en: objetos con Delta > 0 y Size Delta grande.

Paso 4: examinar los retainers

Clic en un objeto con fuga → ver el panel Retainers → rastrear la cadena de referencias:

Detached HTMLDivElement
  ↳ retained by Object (componentCache)
    ↳ retained by Map @12345
      ↳ retained by Window (global)

Esta cadena indica: un elemento DOM desadjuntado es retenido por el Map componentCache, que es una variable global.

Línea de tiempo de asignación para seguimiento en tiempo real

  1. Seleccionar Allocation instrumentation on timeline
  2. Iniciar la grabación
  3. Realizar operaciones
  4. Detener la grabación
  5. Barras azules = asignaciones aún vivas, barras grises = ya recolectadas

Firmas en DevTools de escenarios comunes de fuga

Tipo de fuga Firma en Heap Snapshot Palabras clave
Escuchadores de eventos El conteo de EventListener sigue creciendo eventListeners
Temporizadores Los objetos timer no disminuyen timer
DOM desadjuntado Detached HTML*Element Detached
Referencias en closures closure retiene objetos grandes context
Map/Set El tamaño del Map sigue creciendo Map / Set

Detección automatizada de fugas de memoria

Puppeteer + DevTools Protocol

const puppeteer = require('puppeteer');

async function detectMemoryLeak(url, action, iterations = 10) {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto(url);

  const results = [];

  for (let i = 0; i < iterations; i++) {
    // Perform operation
    await action(page);

    // Force GC
    await page.evaluate(() => {
      if (window.gc) window.gc();
    });

    // Get memory metrics
    const metrics = await page.metrics();
    results.push({
      iteration: i + 1,
      jsHeapUsedSize: metrics.JSHeapUsedSize,
    });
  }

  await browser.close();

  // Analyze trend: if heap keeps growing, there may be a leak
  const first = results[0].jsHeapUsedSize;
  const last = results[results.length - 1].jsHeapUsedSize;
  const growth = (last - first) / first;

  return {
    leaked: growth > 0.1, // >10% growth considered a leak
    growthRate: `${(growth * 100).toFixed(1)}%`,
    details: results,
  };
}

// Usage
const result = await detectMemoryLeak(
  'http://localhost:3000',
  async (page) => {
    await page.click('#navigate-btn');
    await page.waitForSelector('#content');
    await page.click('#back-btn');
  },
  20
);

console.log(result);

Integración con CI

# .github/workflows/memory-check.yml
name: Memory Leak Check
on: [push]
jobs:
  memory-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - run: node scripts/memory-leak-test.js

Específico de React: lista de verificación de limpieza de useEffect

function DataComponent({ id }) {
  useEffect(() => {
    const controller = new AbortController();
    const timer = setInterval(() => {}, 5000);
    const handler = () => {};
    window.addEventListener('resize', handler);
    const subscription = eventBus.subscribe('event', handler);

    return () => {
      controller.abort();        // Cancel request
      clearInterval(timer);      // Clear timer
      window.removeEventListener('resize', handler); // Remove listener
      subscription.unsubscribe(); // Unsubscribe
    };
  }, [id]);
}

La ayuda del Strict Mode de React 18

En el Strict Mode de React 18, los componentes se montan dos veces y luego se desmontan. Si la limpieza de useEffect es incompleta, la consola revelará el problema de inmediato.


Buenas prácticas

  1. Revisar cada useEffect en busca de una función de limpieza
  2. Gestionar centralmente los escuchadores de eventos globales y eliminarlos en lote al desmontar
  3. Evitar capturar objetos grandes en closures, tomar solo los valores necesarios
  4. Usar WeakMap/WeakSet en lugar de Map/Set para metadatos de objetos
  5. Poner a null las referencias de nodos DOM desadjuntados
  6. Integrar la detección de fugas de memoria en CI para evitar regresiones
  7. Comparar los Heap Snapshots regularmente, especialmente en escenarios de cambio de ruta
  8. Usar el Strict Mode de React 18 para detectar dobles montajes en desarrollo
#JavaScript#内存泄漏#Chrome DevTools#性能优化