Comment la chaîne de portée de JavaScript résout réellement les variables
Explique le mécanisme de chaîne d’horizon lexical qui sous-tend la recherche de variables, pourquoi var provoque des erreurs dans les boucles, et comment les closures en découlent naturellement.
La recherche de variables n’a rien à voir avec les accolades
La manière typique dont les gens apprennent d’abord ce qu’est le champ d’application est de dire « le champ d’application, c’est tout ce qui se trouve à l’intérieur des accolades ». Cette description permet de réussir un quiz, mais elle ne vous aide pas à prédire ce que fait réellement un morceau de code. Le mécanisme réel est plus mécanique et plus prévisible que ne le suggère cette abréviation, et une fois que vous l’avez compris, les closures cessent de ressembler à de la magie.
Le champ d’application est déterminé par l’endroit où vous écrivez du code, et non par la manière dont il est exécuté
JavaScript résout les variables de manière lexicale. Cela signifie que le champ d’application d’une variable est fixé en fonction de sa position physique dans le fichier source au moment où vous l’écrivez, et non en fonction de la fonction qui appelle une autre fonction pendant l’exécution du programme.
const value = "outer";
function readValue() {
console.log(value);
}
function runWithDifferentValue() {
const value = "inner";
readValue(); // still logs "outer", not "inner"
}
runWithDifferentValue();
readValue ne se soucie pas de savoir qui l’appelle ni quelles variables existent dans son environnement. Tout ce qui l’intéresse, c’est l’endroit où il a été physiquement défini, juste à côté de const value = „outer“. C’est le seul value qu’il pourra jamais voir, quel que soit l’endroit du programme d’où il est appelé. C’est généralement à ce stade que les développeurs venant de langages avec un encadrement dynamique (ou, en réalité, ceux qui n’ont jamais eu à y réfléchir, puisque l’encadrement lexical est la norme presque partout) se sentent perdus. L’encadrement est intégré à la structure du code dès son écriture ; il ne change pas en fonction de la pile d’appels au moment de l’exécution.
Le véritable mécanisme de recherche : parcourir la chaîne d’encadrement
Lorsque le moteur doit résoudre une référence à une variable, il ne parcourt pas l’ensemble de votre base de code. Il commence précisément à l’endroit où la variable est utilisée et avance progressivement, étape par étape à travers les ensembles de portée imbriqués, s’arrêtant dès qu’il trouve une correspondance :
const a = "global";
function outer() {
const b = "outer";
function inner() {
const c = "inner";
console.log(a, b, c); // "global outer inner"
}
inner();
}
outer();
inner vérifie d’abord la variable c et la trouve immédiatement, sans avoir besoin de chercher ailleurs. Ensuite, il vérifie b : comme cette variable n’est pas définie localement, la recherche se poursuit dans le scope de outer, où elle est trouvée. Puis il vérifie a : comme cette variable n’existe ni dans inner ni dans outer, la recherche continue à s’étendre vers l’extérieur jusqu’à atteindre le scope global, où elle est finalement localisée. Cette séquence — le scope interne, puis celui qui l’entoure, et ainsi de suite jusqu’au scope global — constitue tout l’algorithme de recherche. Rien de plus complexe que le principe « regarder ici, puis chercher un niveau plus haut, et répéter jusqu’à la découverte ».
Celui-ci même mécanisme explique l’ombrage variable sans nécessiter de règle distincte. Si inner déclarait son propre const b, la recherche s’arrêterait dès qu’elle trouverait ce b local et n’atteindrait jamais le b défini dans outer. Rien n’est écrasé dans ce scénario ; il s’agit simplement du fait que la correspondance la plus proche est trouvée en premier, de sorte que la recherche n’a aucune raison de se poursuivre.
Pourquoi var se comporte différemment, et pourquoi cela provoque un bug bien connu
let et const s’attachent au bloc contenant le plus près, c’est-à-dire à n’importe quel ensemble de crochets, qu’il s’agisse d’une instruction if ou du corps d’une boucle. var, quant à lui, ne suit pas du tout cette règle. Au lieu de cela, var s’attache à la fonction contenant le plus près, ignorant complètement les limites des blocs.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// logs: 3, 3, 3
Il n’y a qu’une seule variable i dans ce fragment de code, délimitée à la fonction, et chaque itération du boucle partage cette même variable. Lorsque l’un des callbacks de setTimeout s’exécute enfin, le boucle a déjà terminé son exécution et i vaut déjà 3. Les trois callbacks font référence à la même variable plutôt que d’avoir chacun leur propre copie, ce qui fait qu’ils affichent tous la valeur finale qu’elle a fini par prendre.
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// logs: 0, 1, 2
Puisque let a un champ d’application par bloc, et parce que le langage crée spécifiquement une nouvelle liaison pour i à chaque itération de la boucle, chaque fonction de rappel finit par conserver sa propre version distincte de i, figée à la valeur qu’elle avait durant cette itération particulière. Le code ressemble presque identiquement à l’exemple précédent, mais la règle de champ d’application sous-jacente est différente, ce qui produit un résultat bien plus prévisible.
Les closures ne sont pas une fonctionnalité distincte, mais simplement une conséquence de la chaîne de champs
Lorsque vous comprenez le fonctionnement de la chaîne de portée, les fermetures n’ont presque plus besoin d’explication supplémentaire. Une fermeture n’est pas un mécanisme additionnel superposé à la portée ; c’est simplement ce qui se produit naturellement chaque fois que vous définez une fonction à l’intérieur d’une autre fonction, et que cette fonction interne est ensuite utilisée après que la portée externe aurait normalement été abandonnée.
function debounce(fn, delayMs) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delayMs);
};
}
const debouncedSearch = debounce((query) => runSearch(query), 300);
debounce s’exécute exactement une fois. Si vous êtes habitué à des langages sans fermetures, votre instinct pourrait être que timeoutId devrait disparaître dès que debounce a terminé son exécution. Il ne disparaît pas, car la fonction debounce retournée a été définie à l’intérieur d’elle, ce qui signifie que la chaîne de portée de cette fonction retournée inclut de manière permanente la propre portée de debounce, y compris timeoutId. Chaque appel ultérieur à debouncedSearch, peu importe à quel point il est différé, parcourt cette même chaîne de portée pour retrouver ce timeoutId précis. C’est précisément pourquoi la logique de debounce fonctionne : il est nécessaire d’avoir une variable unique et persistante pour suivre le délai en attente à chaque invocation, et c’est la fermeture qui garantit cela.
La même fonctionnalité qui permet les fermetures peut également entraîner des fuites de mémoire
Ce qui rend les clôtures utiles est précisément la même propriété qui engendre un problème récurrent et spécifique : une clôture conserve l’ensemble de son contexte d’environnement, et non seulement les variables auxquelles elle fait réellement référence ; de plus, elle détient une référence vivante à la variable elle-même, plutôt qu’une copie figée de sa valeur au moment où la clôture a été créée.
function createHandlers() {
let clickCount = 0;
const massiveDataset = loadHugeArray(); // large, no longer needed after setup
return {
onClick: () => {
clickCount++; // only this variable is actually used
console.log(clickCount);
},
};
}
onClick capture l’ensemble du contexte appartenant à createHandlers, y compris massiveDataset, même si onClick ne le lit jamais réellement. Tant que onClick reste actif, tout ce dont il a fait la capture reste également actif, ce qui représente un véritable problème de mémoire, bien que généralement mineur, lorsque une closure à longue durée de vie conserve des références à des données volumineuses ou inutiles. Et comme la closure contient la variable réelle plutôt qu’une valeur copiée, elle voit toujours l’état actuel et mis à jour. C’est la même règle fondamentale qui a permis à l’exemple de debounce de fonctionner correctement et qui a fait en sorte que la boucle var affiche 3, 3, 3 : un mécanisme unique qui apparaît comme une fonctionnalité utile dans un contexte et comme une erreur dans un autre.
Comprendre le mécanisme vaut mieux que mémoriser la définition
La définition du manuel selon laquelle « une fermeture est une fonction associée à son environnement lexical » est techniquement correcte, mais elle reste peu claire tant que l’on n’a pas suivi manuellement la chaîne de portée à plusieurs reprises et observé précisément où se termine la recherche. Une fois que ce suivi devient une habitude, les fermetures n’ont plus besoin d’aucune explication particulière. Ce ne sont qu’une conséquence ordinaire du fonctionnement habituel des portées, appliquée à une fonction qui survit par hasard à l’environnement dans lequel elle a été créée.
Lectures complémentaires
- Ce que le support natif de TypeScript par Node.js fait réellement et ne fait pas — Cet article explique comment Node.js exécute les fichiers .ts de manière native grâce au suppression des types, pourquoi il omet la vérification des types, et quand une véritable étape de compilation est encore nécessaire.