La plupart des gens commencent le JavaScript en construisant des choses. Vous liez un bouton, récupérez des données et regardez le DOM changer. Puis, les abstractions fuient. Des bugs apparaissent et n'ont aucun sens : des variables existent avant leurs déclarations, des fonctions se souviennent de variables auxquelles elles ne devraient pas avoir accès, et le mot-clé this pointe vers la fenêtre (window), un bouton, ou rien du tout. C'est généralement à ce moment-là que vous réalisez que vous devez regarder sous le capot pour comprendre ce que le moteur fait réellement.
Contexte d'exécution : la configuration en deux phases
Lorsque le moteur JavaScript de votre navigateur ou de Node.js rencontre un script, il ne se contente pas de lire le fichier de haut en bas comme une personne parcourant une page. Au lieu de cela, il construit un contexte d'exécution, un conteneur qui détient tout ce qui est nécessaire pour exécuter un bloc de code particulier. Chaque contexte d'exécution passe par deux phases distinctes.
Phase de création de la mémoire. Lors de ce premier passage, le moteur parcourt l'ensemble de la portée (scope) et alloue de la mémoire pour chaque déclaration de variable et de fonction qu'il trouve. S'il voit un var, il réserve de l'espace et stocke undefined comme valeur de substitution. S'il voit une déclaration de fonction, il stocke le corps complet de la fonction. C'est pourquoi une fonction déclarée avec le mot-clé traditionnel function peut être appelée depuis des lignes qui apparaissent plus tôt dans la même portée. Le moteur la connaît déjà avant de commencer l'exécution.
Phase d'exécution du code. Maintenant, le moteur exécute votre code ligne par ligne. Les affectations se produisent ici. Les expressions sont évaluées. Les fonctions sont invoquées. Si vous avez écrit var name = "Alice";, la valeur de substitution de la première phase est enfin remplacée par la chaîne de caractères. Comprendre ce comportement en deux passages dissipe une quantité surprenante de confusion. Le moteur n'est pas négligent ; il suit une routine de configuration stricte.
Variables et la zone de mort temporelle (Temporal Dead Zone)
Choisir entre var, let et const n'est pas seulement une préférence stylistique. var a une portée de fonction (function-scoped), ce qui signifie qu'il ignore totalement les accolades. Déclarez-le à l'intérieur d'un bloc if, et il déborde vers l'extérieur. Ce comportement avait peut-être du sens dans les débuts de JavaScript, mais dans les applications modernes, il cause de réels problèmes de maintenance. let et const ont une portée de bloc (block-scoped). Ils respectent les accolades et disparaissent lorsque le bloc se termine.
Il existe également une différence subtile mais cruciale dans la manière dont le hoisting (remontée) les traite. Les déclarations var sont remontées et immédiatement initialisées avec undefined. let et const sont techniquement aussi remontés ; le moteur sait qu'ils existent avant d'atteindre la ligne de déclaration. Mais ils ne sont pas initialisés. Ils se trouvent dans un entre-deux appelé la zone de mort temporelle (temporal dead zone). Si vous essayez de les lire avant l'exécution de la ligne de déclaration, vous obtenez une erreur ReferenceError brutale plutôt qu'un undefined furtif. Ce plantage est en réalité utile : il empêche la logique de se poursuivre sur des données non initialisées.
Portée lexicale et closures
La portée (scope) répond à une question simple : où puis-je accéder à cette variable ? JavaScript utilise la portée lexicale, ce qui signifie que les droits d'accès d'une fonction sont décidés par l'endroit où elle a été physiquement écrite dans le code source, et non par l'endroit où elle finit par être appelée. Si vous définissez une fonction à l'intérieur d'une autre fonction, la fonction interne peut regarder vers l'extérieur pour lire les variables de sa parente. La fonction externe ne peut pas regarder vers l'intérieur. Cette relation est statique. Vous pourriez passer cette fonction interne à travers des modules, la stocker dans une variable globale et l'appeler depuis un fichier totalement différent. Elle se souviendra toujours des variables de la portée où elle est née.
Ce comportement produit naturellement des closures (fermetures). Une closure se forme lorsqu'une fonction interne conserve une référence à une variable de sa portée externe. Même après que la fonction externe a fini de s'exécuter et que ses variables locales auraient dû être collectées par le ramasse-miettes (garbage collector), JavaScript les préserve en mémoire car la fonction interne en a toujours besoin. La fonction interne transporte son environnement environnant avec elle.
Ce n'est pas un simple détail académique. Les closures vous offrent un moyen pratique de créer un état privé dans un langage qui manque de modificateurs d'accès explicites.
function makeCounter() {
let count = 0;
return function() {
count = count + 1;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
Ici, count est caché. Rien à l'extérieur de la fonction retournée ne peut le réinitialiser ou le lire directement. C'est une variable privée construite uniquement grâce aux mécanismes de portée.
Le hoisting en pratique
On entend souvent dire que JavaScript « déplace les déclarations en haut ». C'est un modèle mental utile, mais le code n'est pas réellement réécrit. Pendant la phase de création de la mémoire, le moteur enregistre simplement les déclarations avant que l'exécution ne commence. Une instruction telle que var x = 5; se comporte comme si la déclaration et l'initialisation étaient découplées. La déclaration var x; est traitée tôt et initialisée à undefined. L'affectation x = 5; reste exactement là où vous l'avez écrite et s'exécute pendant la phase d'exécution.
À cause de cela, var peut mener à des résultats surprenants. Une variable utilisée près du haut d'une fonction peut contenir undefined même si une affectation massive se trouve en bas. L'utilisation de let et const élimine ce piège particulier, car la zone de mort temporelle (temporal dead zone) vous oblige à placer vos déclarations au-dessus de leur utilisation.
Comment this obtient sa valeur
Si le contexte d'exécution et la portée (scope) déterminent où vivent les variables, this détermine quel objet est actuellement responsable. Contrairement aux variables lexicales, this n'est pas décidé par l'endroit où une fonction est écrite. Il est entièrement décidé par la manière dont la fonction est appelée.
La liaison par défaut (Default binding) se produit lorsque vous invoquez une fonction simple et autonome. En mode non strict, this se rabat sur l'objet global. Dans un navigateur, il s'agit de window. Appelez une fonction sans aucun contexte, et vous pourriez accidentellement toucher l'état global sans vous en rendre compte.
La liaison implicite (Implicit binding) se produit lorsque vous appelez une fonction en tant que méthode sur un objet. Si vous écrivez user.sayName(), le point indique discrètement au moteur de définir this sur user pour la durée de cet appel. Le lieu de l'appel importe plus que le lieu de la définition.
La liaison explicite (Explicit binding) vous permet de tout écraser manuellement. call() et apply() invoquent une fonction immédiatement tout en forçant this à être un objet spécifique que vous fournissez. La seule différence entre les deux est la manière dont les arguments sont passés : call prend une liste séparée par des virgules, tandis qu' apply prend un tableau. bind() fonctionne différemment. Il n'invoque pas la fonction immédiatement. Au lieu de cela, il renvoie une nouvelle fonction avec this verrouillé de manière permanente sur la valeur que vous avez fournie. C'est inestimable pour les rappels (callbacks) qui pourraient autrement perdre leur contexte lorsqu'ils sont transmis.
La liaison par new (New binding) entre en jeu lorsque vous utilisez le mot-clé new devant un appel de fonction. Le moteur construit un tout nouvel objet vide, définit son lien de prototype et pointe this à l'intérieur du constructeur vers cette nouvelle instance.
Écrire du code qui dure
Comprendre la mécanique n'est que la moitié du métier. L'autre moitié consiste à écrire du code que les humains pourront lire dans six mois.
DRY (Don't Repeat Yourself) semble évident, mais il est constamment enfreint. Si vous vous surprenez à écrire la même logique de validation ou le même modèle d'appel d'API dans trois fichiers différents, extrayez-la. Écrivez une seule fonction. Une source unique de vérité signifie un seul endroit à mettre à jour lorsque les exigences changent, et cela fait gagner du temps de manière difficile à surestimer.
KISS (Keep It Simple, Stupid) est une défense contre l'ego. Les ternaires imbriqués et les fermetures (closures) en une seule ligne semblent astucieux, mais ils coûtent des heures lors du débogage. Le modèle de closure que j'ai montré précédemment est puissant, mais imbriquer cinq niveaux simplement parce que vous le pouvez est une erreur. Un code simple survit au renouvellement des équipes, aux incidents de production et à ces alertes en pleine nuit lorsqu'un problème survient et que personne ne se souvient pourquoi.
Le véritable bénéfice
Étudier l'exécution
