Qu’est-ce qu’une closure en JavaScript ?
| |
makeCounter s’exécute une fois, renvoie une fonction, et c’est tout : son stack frame n’existe plus. Pourtant, chaque appel à counter() lit et met à jour count, une variable déclarée à l’intérieur d’une fonction déjà terminée. count ne revient pas à 0, et n’est pas copiée. C’est la même variable, toujours vivante, trois appels plus tard.
C’est une closure : une fonction associée à une référence vers les variables qui étaient dans son scope au moment de sa définition. Pas une photo de leurs valeurs : une référence vivante. Si autre chose modifie count, le counter voit la nouvelle valeur.
Pourquoi la variable survit-elle après la fin de la fonction ?
JavaScript résout les noms de variables de façon lexicale, selon l’endroit où ils sont écrits dans le code, pas selon qui appelle la fonction. Pour count += 1 à l’intérieur de la fonction interne, le moteur cherche vers l’extérieur à travers les scopes englobants jusqu’à trouver une déclaration, et trouve count dans makeCounter. Ce lien est fixé à l’endroit où la fonction interne est définie, pas à l’endroit où elle est appelée.
Normalement, une fois qu’une fonction se termine, rien ne peut plus atteindre ses variables locales et le garbage collector les récupère. Une closure est l’exception. La fonction renvoyée garde une référence vers le scope dans lequel elle a été créée, donc ce scope reste vivant aussi longtemps que la fonction. Appelez makeCounter() à nouveau et vous obtenez un second counter avec son propre count dans son propre scope :
| |
Chaque fonction que vous créez en JavaScript se referme sur son scope environnant, que vous l’utilisiez ou non. En général, rien ne survit à l’appel qui l’a créée, donc vous ne le remarquez jamais. Cela compte quand vous renvoyez la fonction interne, la passez ailleurs, ou la stockez : elle continue de fonctionner même après que le scope du parent aurait sinon disparu.
Comment créer des variables privées avec une closure ?
Le counter montre déjà l’usage principal : des données que seule la fonction renvoyée peut toucher. Rien en dehors de makeCounter ne peut lire ou modifier count. Il n’y a aucune propriété pour cela, rien à inspecter depuis la console. Vous obtenez l’encapsulation d’un champ privé de classe, construite à partir d’un simple appel de fonction :
| |
Les trois méthodes se referment sur la même balance, donc elles la partagent. Le code extérieur ne peut l’atteindre qu’à travers les méthodes que vous avez choisi d’exposer. C’est le pattern module : envelopper un état privé et une interface publique dans un seul appel de fonction, en ne renvoyant que l’interface.
Comment mémoïser une fonction avec une closure ?
Refermez-vous sur une Map et vous obtenez un wrapper de mémoïsation, avec un cache partagé par chaque appel à la fonction renvoyée :
| |
Les deux exemples suivent la même forme : vous appelez une fonction qui prépare un état, et vous récupérez en retour une ou plusieurs fonctions qui continuent de l’utiliser. Le cache ici n’évince jamais rien, donc n’enveloppez pas ainsi une fonction qui reçoit des entrées distinctes sans limite. Les décorateurs Python reposent sur le même mécanisme : un décorateur se referme sur la fonction qu’il enveloppe et renvoie un remplaçant qui peut encore l’appeler.
Pourquoi var dans une boucle casse-t-il les closures ?
C’est l’erreur de closure que presque tout le monde rencontre un jour :
| |
Vous vouliez 0 1 2. Vous avez obtenu 3 3 3. var a un scope de fonction, pas de bloc, donc il existe exactement un i pour toute la boucle, et les trois callbacks se referment sur celui-ci. Au moment où l’un d’eux s’exécute, la boucle est déjà terminée et i vaut 3.
Déclarez-la avec let et le bug disparaît :
| |
let a un scope de bloc, et une boucle for avec let dans son en-tête crée un binding séparé pour chaque itération. Trois i distincts. Chaque callback se referme sur celui de sa propre itération.
Avant let, la solution était une IIFE qui forçait un nouveau scope par itération en passant i en paramètre :
| |
Vous la trouverez encore dans du code ancien. Ne l’écrivez pas aujourd’hui : mettez let dans l’en-tête de la boucle.
Les closures peuvent-elles causer des fuites mémoire ?
Oui. Une closure garde le véritable environnement lexical où vivent ses variables, aussi longtemps que la closure elle-même reste accessible. Attachez-en une à quelque chose de longue durée, comme un event listener du DOM, un tableau au niveau module, ou un cache sans éviction, et tout ce que cet environnement transporte reste aussi en mémoire.
Le cas courant : un listener sur un nœud du DOM se referme sur un gros objet que le handler utilise à peine. Le nœud est retiré de la page, le listener n’est jamais détaché, et le nœud comme l’objet restent vivants parce que la closure les référence toujours. Appelez removeEventListener quand l’élément disparaît. Et gardez vos closures étroites : si un callback a besoin d’un seul champ, refermez-vous sur ce champ, pas sur l’objet entier.
Le second coût est plus discret. Une méthode définie à l’intérieur d’un constructeur, plutôt que sur le prototype, est recréée avec sa propre closure pour chaque instance, au lieu d’être partagée entre toutes. Si la fonction interne ne capture rien de l’externe, ne l’imbriquez pas.
Quand utiliser une closure ?
Vous en avez probablement déjà utilisé. Un debounce ou throttle en JavaScript renvoie un wrapper qui se referme sur un timeoutId qui persiste entre les appels, et c’est ainsi qu’il annule l’appel précédent encore en attente. Tout callback que vous passez à setTimeout ou à un handler de promise qui lit une variable depuis la fonction qui l’a planifié fonctionne de la même façon : l’event loop l’exécute bien après que cette fonction s’est terminée.
Utilisez une closure délibérément quand vous avez besoin d’un état qui persiste entre les appels mais ne doit pas être exposé comme propriété : un counter, un cache, un timer de debounce, une fonction configurée une fois et appelée plus tard. Pour un utilitaire sans état qui se contente de transformer ses arguments, passez le wrapper. Il n’y a rien sur quoi se refermer, et l’imbrication ajoute un scope dont vous devez tenir compte sans aucun gain.