Débogage des fuites de mémoire JavaScript en pratique : un pipeline complet de Chrome DevTools à la détection automatisée

前端工程

Fuites de mémoire : le tueur silencieux des applications frontend

Les fuites de mémoire ne provoquent pas de crash immédiat — elles agissent comme un poison lent qui rend les pages progressivement plus lentes, jusqu'à finir en écran blanc (white-screen). C'est particulièrement grave dans les SPA, où les pages ne sont jamais rechargées.

Gravité de la fuite Symptômes Échelle de temps
Léger Léger ralentissement après usage prolongé Plusieurs heures
Modéré Ralentissement notable après changement de route 30-60 minutes
Grave Écran blanc/crash après interaction Quelques minutes

Six motifs classiques de fuite

1. Temporisateurs non nettoyés

// 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. Écouteurs d'événements non retirés

// 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. Références accidentelles dans les 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. Nœuds DOM détachés (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. Références zombies dans 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. Utiliser Map au lieu 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

Le panneau mémoire de Chrome DevTools en pratique

Étape 1 : établir la référence

  1. Ouvrir DevTools → panneau Memory
  2. Cliquer sur Take heap snapshot
  3. L'étiqueter "Baseline"

Étape 2 : opérer et comparer

  1. Effectuer des opérations potentiellement fuiteuses (ex. changer de route 10 fois)
  2. Cliquer sur Take heap snapshot
  3. L'étiqueter "After operation"
  4. Choisir la vue "Comparison" et comparer à la référence

Étape 3 : analyser les objets en croissance

Colonne Signification
Added Objets nouvellement créés
Deleted Objets supprimés
Delta Variation nette du nombre d'objets
Allocated Size Mémoire nouvellement allouée
Freed Size Mémoire libérée
Size Delta Variation nette de mémoire

À surveiller : les objets avec Delta > 0 et un Size Delta élevé.

Étape 4 : examiner les retainers

Cliquer sur un objet fuiteux → ouvrir le panneau Retainers → retracer la chaîne de références :

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

Cette chaîne indique qu'un élément DOM détaché est retenu par la Map componentCache, qui est une variable globale.

Timeline d'allocation pour le suivi en temps réel

  1. Sélectionner Allocation instrumentation on timeline
  2. Démarrer l'enregistrement
  3. Effectuer les opérations
  4. Arrêter l'enregistrement
  5. Barres bleues = allocations encore vivantes, barres grises = déjà collectées

Signatures DevTools des scénarios de fuite courants

Type de fuite Signature Heap Snapshot Mots-clés
Écouteurs d'événements Le nombre d'EventListener continue de croître eventListeners
Temporisateurs Les objets timer ne diminuent pas timer
DOM détaché Detached HTML*Element Detached
Références de closure closure retient de gros objets context
Map/Set La taille de la Map continue de croître Map / Set

Détection automatisée des fuites de mémoire

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);

Intégration 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

Spécifique à React : checklist de nettoyage 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]);
}

L'aide du Strict Mode de React 18

En Strict Mode de React 18, les composants sont montés deux fois puis démontés. Si le nettoyage de useEffect est incomplet, la console révélera le problème immédiatement.


Bonnes pratiques

  1. Vérifier que chaque useEffect possède une fonction de nettoyage
  2. Gérer centralement les écouteurs d'événements globaux, retirés en lot au démontage
  3. Éviter de capturer de gros objets dans les closures, ne prendre que les valeurs nécessaires
  4. Utiliser WeakMap/WeakSet au lieu de Map/Set pour les métadonnées d'objets
  5. Mettre à null les références des nœuds DOM détachés
  6. Intégrer la détection des fuites de mémoire dans la CI pour éviter les régressions
  7. Comparer régulièrement les Heap Snapshots, surtout lors des changements de route
  8. Utiliser le Strict Mode de React 18 pour détecter les doubles montages en développement
#JavaScript#内存泄漏#Chrome DevTools#性能优化