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
- Ouvrir DevTools → panneau Memory
- Cliquer sur Take heap snapshot
- L'étiqueter "Baseline"
Étape 2 : opérer et comparer
- Effectuer des opérations potentiellement fuiteuses (ex. changer de route 10 fois)
- Cliquer sur Take heap snapshot
- L'étiqueter "After operation"
- 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
- Sélectionner Allocation instrumentation on timeline
- Démarrer l'enregistrement
- Effectuer les opérations
- Arrêter l'enregistrement
- 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
- Vérifier que chaque useEffect possède une fonction de nettoyage
- Gérer centralement les écouteurs d'événements globaux, retirés en lot au démontage
- Éviter de capturer de gros objets dans les closures, ne prendre que les valeurs nécessaires
- Utiliser WeakMap/WeakSet au lieu de Map/Set pour les métadonnées d'objets
- Mettre à null les références des nœuds DOM détachés
- Intégrer la détection des fuites de mémoire dans la CI pour éviter les régressions
- Comparer régulièrement les Heap Snapshots, surtout lors des changements de route
- Utiliser le Strict Mode de React 18 pour détecter les doubles montages en développement
#JavaScript#内存泄漏#Chrome DevTools#性能优化