06 - Closures et mémoire
Ce que tu vas apprendre
- Comment les closures capturent le scope (le slot interne [[Environment]])
- Ce que V8 retient vraiment (tout le scope ou juste les variables utilisees ?)
- Le coût mémoire des closures
- Les pièges classiques : closures dans les boucles, dans les event handlers
- Comment corriger les fuites de closures (nullifier, WeakRef)
Prerequisites
- Avoir lu Les 6 fuites classiques
- Connaitre les bases des closures en JavaScript
La closure, cette boîte noire
Je me souviens de la première fois ou j'ai vraiment compris les closures. C'etait pas en lisant la doc MDN. C'etait en debuggant une appli qui consommait 800 Mo en prod. Le profiler montrait des milliers de copies d'un meme objet, retenues par des closures anonymes dans des callbacks Express.
Une closure, c'est une fonction qui "se souvient" de l'environnement ou elle a ete créée. En termes techniques, quand tu créés une fonction, V8 attache un slot interne appelle [[Environment]] qui pointe vers le scope (l'environnement lexical) de la fonction parente.
typescriptfunction createCounter() {
let count = 0; // variable du scope parent
return function increment() {
count++; // la closure "se souvient" de count
return count;
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
Apres que createCounter a retourne, la variable count devrait normalement etre libérée (elle est locale). Mais increment la référencé via son [[Environment]]. Tant que increment existe, count vit.
HEAP
+------------------+
| Closure: increment|
| |
| [[Environment]] -|------+
+------------------+ |
v
+------------------+
| Scope de |
| createCounter |
| count = 2 |
+------------------+
Ce que V8 retient vraiment
Question que tout le monde se pose : si le scope parent contient 10 variables et que la closure n'en utilise qu'une seule, V8 garde-t-il les 10 ?
La bonne nouvelle : V8 fait une optimisation appelee scope analysis. Au moment de la compilation, V8 analyse quelles variables sont réellement referencees par la closure et ne capture que celles-la.
typescriptfunction example() {
const small = 42;
const big = new Array(1_000_000).fill("x");
// V8 analyse : cette closure utilise seulement "small"
return () => small * 2;
// "big" n'est PAS capture, il sera libere par le GC
}
La mauvaise nouvelle : cette optimisation a des limites. Si V8 ne peut pas prouver statiquement qu'une variable n'est pas utilisee, il la capture. Les cas problématiques :
typescriptfunction danger() {
const big = new Array(1_000_000).fill("x");
const small = 42;
// eval() empeche V8 d'optimiser : il capture TOUT le scope
return () => eval("small");
// "big" est capture aussi, par precaution
}
typescript// Autre piege : deux closures partagent le meme scope
function shared() {
const big = new Array(1_000_000).fill("x");
const small = 42;
const fn1 = () => small; // utilise "small"
const fn2 = () => big[0]; // utilise "big"
// fn1 et fn2 partagent le MEME [[Environment]]
// Tant que fn1 ou fn2 vit, "big" ET "small" vivent
return fn1; // on retourne fn1, mais big est retenu
// parce que fn2 (dans le meme scope) l'utilise
}
Ce dernier point est subtil et source de fuites reelles. Deux closures créées dans la meme fonction partagent le meme scope. Si l'une référencé un gros objet, et que l'autre vit longtemps, le gros objet est retenu.
Closures dans les boucles : le piège classique
typescript// Le piege historique (var)
for (var i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 100);
}
// Affiche : 5 5 5 5 5 (toutes les closures partagent le meme i)
// Le fix moderne (let)
for (let i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 100);
}
// Affiche : 0 1 2 3 4 (chaque iteration a son propre scope)
Le problème mémoire arrive quand la boucle est grande et les closures vivent longtemps :
typescript// PROBLEME : 10 000 closures, chacune retient son scope
const handlers: (() => void)[] = [];
for (let i = 0; i < 10_000; i++) {
const data = loadItem(i); // objet potentiellement gros
handlers.push(() => {
process(data);
});
}
// 10 000 scopes en memoire, chacun avec son "data"
typescript// MIEUX : stocker les donnees dans un tableau, une seule closure
const items = Array.from({ length: 10_000 }, (_, i) => loadItem(i));
const handler = (index: number) => {
process(items[index]);
};
// Une seule closure, un seul tableau
Closures dans les event handlers
C'est le scénario le plus frequent en frontend :
typescript// FUITE : chaque montage de composant cree une nouvelle closure
function mountWidget(data: WidgetData) {
const bigState = computeExpensiveState(data);
const onClick = () => {
// La closure retient bigState
updateUI(bigState);
};
button.addEventListener("click", onClick);
// Si on remonte le widget sans retirer le listener,
// bigState reste en memoire pour chaque instance
}
typescript// CORRIGE : nettoyage explicite
function mountWidget(data: WidgetData) {
const bigState = computeExpensiveState(data);
const onClick = () => {
updateUI(bigState);
};
button.addEventListener("click", onClick);
return function unmount() {
button.removeEventListener("click", onClick);
// Apres ca, onClick n'est plus reference
// Le scope (avec bigState) peut etre libere
};
}
const unmount = mountWidget(data);
// Plus tard :
unmount();
Factory functions et mémoire
Les factory functions (fonctions qui retournent des objets avec des méthodes) utilisent des closures pour l'encapsulation. Chaque appel créé un nouveau scope :
typescript// Chaque appel a createUser cree un scope avec name et age
function createUser(name: string, age: number) {
return {
getName: () => name,
getAge: () => age,
greet: () => `Je suis ${name}, ${age} ans`,
};
}
// 10 000 users = 10 000 scopes = 10 000 copies de name et age
const users = Array.from({ length: 10_000 }, (_, i) =>
createUser(`User${i}`, 20 + i)
);
Pour 10 000 objets, c'est généralement acceptable. Pour 1 000 000, il faut considérer une classe (les méthodes sont partagees via le prototype) :
typescriptclass User {
constructor(
private name: string,
private age: number,
) {}
getName() { return this.name; }
getAge() { return this.age; }
greet() { return `Je suis ${this.name}, ${this.age} ans`; }
}
// 1 000 000 instances, mais une seule copie des methodes (prototype)
Nullifier pour libérer
Si tu sais qu'une closure va vivre longtemps mais que tu n'as plus besoin de certaines donnees, tu peux nullifier les références :
typescriptfunction createProcessor(rawData: Buffer) {
const processed = transform(rawData);
// On n'a plus besoin de rawData
// Mais la closure le retient si on ne fait rien
// Solution : variable locale reassignable
let data: Buffer | null = rawData;
const result = transform(data);
data = null; // libere la reference
return () => result;
}
C'est un pattern un peu verbeux, mais parfois nécessaire quand tu as des closures qui retiennent de gros buffers intermediaires.
Un vrai cas de profiling
Sur paltemps.fr, j'avais un middleware Express qui loggait les requêtes. Chaque requête creait une closure pour le res.on("finish", ...). Cette closure capturait req et res (le scope du middleware). Avec 1000 requêtes concurrentes, ca faisait 1000 copies de req/res en mémoire, incluant les headers, le body parse, les cookies. J'ai corrige en ne capturant que les champs nécessaires (req.method, req.url, Date.now()) dans un objet simple, au lieu de laisser la closure retenir l'objet req entier.
Résumé
- Les closures capturent leur scope parent via le slot
[[Environment]]. - V8 optimise et ne capture que les variables utilisees, sauf avec
eval()ou quand plusieurs closures partagent un scope. - Les closures dans les boucles creent N scopes. Prefere une structure de donnees + une seule closure.
- Les event handlers avec closures sont la source de fuites la plus frequente en frontend.
- Nullifie les références inutiles dans les closures longue duree.
- Pour des millions d'instances, préféré les classes (méthodes partagees via prototype) aux factory functions (closures individuelles).
Precedent : Les 6 fuites classiques Suivant : WeakRef et WeakMap