L’endroit où une fonction est écrite détermine ce qu’elle voit : le champ lexical de JavaScript
Comprendre comment JavaScript résout le nom d’une variable à travers les environnements lexicaux, pourquoi l’endroit d’appel n’a jamais d’importance pour la recherche, et comment cela se reflète dans les gestionneurs de React.
Sources: How JavaScript Finds Variables: Scope, Lexical Environment & Scope Chain
Lorsque JavaScript évalue un nom comme user, le moteur doit déterminer à quel binding il fait référence. L’erreur la plus fréquente, qui consiste à supposer qu’une fonction appelée peut voir les variables de sa fonction appelante, se retrouve aussi bien lors des entretiens qu’avec des bugs en production. Ce guide aborde successivement le concept de portée, les environnements lexicaux et la résolution des identifiants, en insistant particulièrement sur la différence entre la chaîne de portée et la pile d’appels, afin que vous puissiez anticiper tous les problèmes liés aux fonctions imbriquées et comprendre clairement le fonctionnement des gestionnaires React.
La question : comment une fonction imbriquée trouve-t-elle les variables externes ?
Commençons par trois niveaux d’imbrication :
const appName = "HealthApp";
function dashboard() {
const user = "Jashan";
function showProfile() {
const status = "Active";
console.log(appName);
console.log(user);
console.log(status);
}
showProfile();
}
dashboard();
showProfile() ne déclare que status, pourtant toutes les trois appels à console.log réussissent. Chaque nom est trouvé en un endroit différent :
status → showProfile()
user → dashboard()
appName → global
Expliquer cela nécessite une courte série de concepts :
Scope
↓
Lexical Scope
↓
Lexical Environment
↓
Scope Chain
↓
Identifier Resolution
Portée : où un nom est utilisable
La portée désigne la région de code dans laquelle un identifiant peut être référencé : elle répond à la question « D’où puis-je utiliser ce nom ? »
function dashboard() {
const user = "Jashan";
console.log(user); // ✅
}
dashboard();
console.log(user); // ❌ ReferenceError
user appartient à dashboard() ; le code en dehors génère une erreur ReferenceError. En tant qu’arbre :
GLOBAL
│
└── dashboard()
└── user
Cela donne la règle suivante :
Inside dashboard → accessible ✅
Outside dashboard → inaccessible ❌
Pourquoi l’isolation est utile
Ces deux fonctions déclarent chacune une variable data :
function loadPatients() {
const data = ["Patient A"];
}
function loadAppointments() {
const data = ["Appointment A"];
}
Elles ne se chevauchent jamais, car chacune existe dans sa propre portée de fonction. Cette isolation permet au code d’être réutilisable et plus facile à déboguer.
Les quatre types de portée
JavaScript dispose de quatre principaux types de portée :
Scope
├── Global Scope
├── Function Scope
├── Block Scope
└── Module Scope
Portée globale
Dans un script classique (non modulaire), les déclarations de niveau supérieur appartiennent au scope global et sont accessibles depuis n’importe où :
var appName = "HealthApp";
function showAppName() {
console.log(appName);
}
showAppName() trouve appName car son chemin de recherche s’arrête dans l’environnement global. Un détail : les variables var de niveau supérieur deviennent également des propriétés de l’objet global (window dans les navigateurs), tandis que les variables let et const de niveau supérieur ne le font pas. Limitez l’état global au strict nécessaire ; cela crée des liens entre des parties éloignées du code et complique les tests.
Scope de la fonction
Tout ce qui est déclaré à l’intérieur d’une fonction est local à cette fonction :
function calculateTotal() {
const price = 100;
const tax = 20;
return price + tax;
}
console.log(price); // ❌
price et tax sont locaux, c’est pourquoi le message d’erreur final est affiché.
var ignore les blocs
var a son scope défini par la fonction la plus proche, et non par le couple de crochets les plus proches :
function example() {
if (true) {
var message = "Hello";
}
console.log(message); // ✅ Hello
}
Le bloc if ne contient pas de message, il reste donc lisible après ce bloc.
Portée de bloc avec let et const
let et const sont confinés au bloc dans lequel ils sont déclarés :
if (true) {
const message = "Payment successful";
let status = "completed";
}
console.log(message); // ❌
console.log(status); // ❌
Les deux enregistrements échouent. Les boucles se comportent de la même manière, et chaque itération d’une boucle for (let ...) obtient une nouvelle liaison pour i:
for (let i = 0; i < 3; i++) {
console.log(i);
}
console.log(i); // ❌
Notez que les crochets d’un littéral objet décrivent une valeur, pas un bloc, donc ils ne créent aucune portée.
Portée de module
Dans les modules ES, les déclarations au niveau le plus haut sont privées du fichier :
// auth.js
const token = "secret";
export function getUser() {}
Un autre module ne peut pas voir token:
// dashboard.js
console.log(token); // ❌
Seul ce qu’un module exporte explicitement peut être importé ailleurs :
import { getUser } from "./auth.js";
C’est le champ d’application courant dans React, Next.js, Vite et les projets Node.js modernes.
Champ lexical : c’est la position dans le code source qui décide
JavaScript est à champ lexical : une fonction a accès aux variables de tous les champs qui l’entourent dans le texte source. Cette structure est définie lors de la parsing du code, avant que quoi que ce soit ne s’exécute.
const name = "Global";
function outer() {
const name = "Outer";
function inner() {
console.log(name);
}
inner();
}
outer();
Le programme affiche :
Outer
inner() est imbriqué à l’intérieur de outer() dans le code source, donc name de outer est le plus proche :
GLOBAL
│
└── outer()
├── name = "Outer"
│
└── inner()
La recherche de name part de inner et s’arrête à outer :
inner
↓
not found locally
↓
outer
↓
name = "Outer" ✅
Le champ lexical est déterminé par l’endroit où une fonction est définie, jamais par l’endroit où elle est appelée.
Le piège lors des entretiens : définir versus appeler
Déplacez maintenant inner() en dehors de outer(), tout en maintenant la possibilité pour outer() de l’appeler :
const name = "Global";
function outer() {
const name = "Outer";
inner();
}
function inner() {
console.log(name);
}
outer();
Une supposition fréquente est :
Outer
Le résultat réel est :
Global
inner() est défini au niveau le plus élevé, à côté de outer(), et non à l’intérieur.
GLOBAL
├── name = "Global"
├── outer()
└── inner()
Ainsi, son chemin de recherche va directement vers l’environnement global :
inner
↓
Global
Il ne passe pas par outer :
inner
↓
outer ❌
Le fait que outer() ait invoqué inner() ne change rien. Le point d’appel façonne la pile d’appels ; le point de définition détermine le champ lexical.
Environnements lexicaux : le mécanisme derrière le champ
La spécification ECMAScript modélise le champ avec des environnements lexicaux. Chacun d’eux enregistre deux éléments :
- les identifiants liés dans cet environnement
En tant que modèle mental :
Lexical Environment
│
├── Environment Record
└── Outer Environment Reference
Les dernières éditions de la spécification intègrent le lien externe dans le Enregistrement d’environnement sous la forme d’un champ [[OuterEnv]] ; le modèle reste inchangé.
L’enregistrement d’environnement contient les liaisons
Prenons une fonction avec deux constantes locales :
function dashboard() {
const user = "Jashan";
const role = "Admin";
}
Conceptuellement, son enregistrement d’environnement associe des noms à des valeurs :
dashboard Environment
user → "Jashan"
role → "Admin"
C’est un modèle, et non un objet que votre code peut inspecter.
La référence externe relie les environnements
Chaque environnement fait référence à celui qui le contient lexicalement :
const appName = "HealthApp";
function dashboard() {
const user = "Jashan";
}
Pour dashboard(), cela se présente comme ceci :
dashboard Environment
│
├── user = "Jashan"
└── Outer
↓
Global Environment
Ce pointeur permet de rechercher en dehors du scope local. Il est défini lors de la création de la fonction, en fonction de l’endroit où elle a été définie, c’est pourquoi le lieu d’appel n’a pas d’importance.
Suivre toute la chaîne
Revenons à l’exemple initial, avec les trois enregistrements combinés :
const appName = "HealthApp";
function dashboard() {
const user = "Jashan";
function showProfile() {
const status = "Active";
console.log(appName, user, status);
}
showProfile();
}
dashboard();
Lorsque showProfile() est exécuté, ses environnements sont liés de la manière suivante :
SHOWPROFILE ENVIRONMENT
│
├── status = "Active"
└── Outer
↓
DASHBOARD ENVIRONMENT
│
├── user = "Jashan"
└── Outer
↓
GLOBAL ENVIRONMENT
│
├── appName = "HealthApp"
└── Outer
↓
null
La référence externe de l’environnement global est null, ce qui met fin à la chaîne.
La chaîne de scope et la résolution des identifiants
La chaîne de scope est le parcours suivi par le moteur pour trouver un identifiant : on vérifie l’environnement actuel, puis on suit la référence externe, jusqu’à ce que le nom soit trouvé ou que la chaîne s’achève.
Current Environment
↓
Outer Environment
↓
Outer Environment
↓
Global
↓
null
La recherche du lien le long de ce parcours s’appelle la résolution des identifiants. Prenons les trois recherches :
console.log(status);
console.log(user);
console.log(appName);
Résolution de status
Trouvé immédiatement dans l’environnement local :
showProfile
↓
status found ✅
Résolution de user
Manquant localement, trouvé un niveau au-dessus :
showProfile ❌
↓
dashboard
↓
user found ✅
Résolution de appName
Manquant dans les deux fonctions, trouvé dans l’environnement global :
showProfile ❌
↓
dashboard ❌
↓
Global
↓
appName found ✅
Dans tous les cas, la recherche s’arrête à la première correspondance. Pour en savoir plus sur l’algorithme de recherche lui-même, consultez comment la chaîne de portée de JavaScript résout réellement les variables.
Lorsque la résolution échoue
Considérez une fonction qui lit un nom que personne n’a déclaré :
function showProfile() {
console.log(age);
}
et une appel à cette fonction :
showProfile();
Si age n’existe dans aucun des environnements accessibles, la recherche s’arrête :
showProfile
↓
Global
↓
null
L’engine ne parvient pas à résoudre le nom et lance une erreur :
ReferenceError: age is not defined
Cela diffère d’une variable contenant undefined, ou d’un binding let non encore initialisé (la zone morte temporaire). Ces trois états sont facilement confondus :
undefined
uninitialized
undeclared
Trois règles couvrant la plupart des cas
La recherche ne se fait que vers l’extérieur
Inner
↓
Outer
↓
Global
Le code interne peut lire les variables externes. Le code externe ne peut pas accéder à un scope imbriqué pour lire des variables déclarées uniquement là.
Le binding le plus proche l’emporte
Lorsqu’un même nom existe à plusieurs niveaux, c’est le plus proche qui est utilisé :
const user = "Global User";
function dashboard() {
const user = "Dashboard User";
function showProfile() {
console.log(user);
}
showProfile();
}
Résultat :
Dashboard User
La recherche s’arrête dans dashboard, un niveau plus haut :
showProfile ❌
↓
dashboard ✅
↓
STOP
Le user global n’est jamais consulté. Une déclaration interne qui cache une déclaration externe est appelée ombrage.
Rien n’est copié dans les fonctions imbriquées
L’idée séduisante mais fausse est que les fonctions intérieures reçoivent des copies des variables extérieures :
showProfile
├── copied user
└── copied appName
La réalité est plutôt une chaîne de références :
showProfile
↓
dashboard
↓
Global
Lorsqu’on accède à une variable via un lien, la fonction intérieure voit toujours la valeur actuelle de la variable extérieure associée, c’est ce qui permet au mécanisme des closures de fonctionner.
Chaîne de portée versus pile d’appels
Voici à nouveau l’exemple de piège :
const name = "Global";
function outer() {
const name = "Outer";
inner();
}
function inner() {
console.log(name);
}
outer();
Pendant l’exécution de inner(), la pile d’appels ressemble à ceci :
┌──────────────────┐
│ inner FEC │ ← Current
├──────────────────┤
│ outer FEC │
├──────────────────┤
│ Global EC │
└──────────────────┘
outer() se trouve clairement sur la pile, car c’est elle qui a appelé inner(). Pourtant inner() affiche quand même :
Global
car la recherche suit la chaîne de portée, et la chaîne de inner mène directement à l’environnement global. Ces deux structures répondent à des questions différentes :
- La pile d’appels suit les contextes d’exécution actuellement actifs ; la chaîne de portée résout les identifiants.
- La pile d’appels est ordonnée selon le fait que telle fonction appelle telle autre ; la chaîne de portée est déterminée par le niveau de nidification du code source.
- La pile d’appels suit le principe dernier entré, premier sorti ; la chaîne de portée suit les liens vers l’environnement externe.
- La pile d’appels répond à la question « Qu’est-ce qui est en cours d’exécution en ce moment ? » ; la chaîne de portée répond à la question « Où se trouve ce nom ? »
Contextes d’exécution et environnements lexicaux
Contexte d’exécution
Global Execution Context
Function Execution Context
Environnement lexical
Execution Context
│
└── environment information
↓
Lexical Environment
│
├── bindings
└── outer environment
Ainsi, les deux ne sont pas la même chose :
Execution Context ≠ Lexical Environment
Le contexte d’exécution explique comment le code s’exécute ; l’environnement lexical explique comment les noms sont trouvés.
Portée lexicale dans les composants React
Le code React dépend constamment de la portée lexicale. Prenons un gestionnaire d’événement défini à l’intérieur d’un composant :
function UserDashboard({ userId }: { userId: string }) {
const role = "Admin";
function handleProfileClick() {
console.log(userId);
console.log(role);
}
return (
<button onClick={handleProfileClick}>
View Profile
</button>
);
}
handleProfileClick() ne déclare aucun de ces noms :
userId
role
Pourtant il les utilise, car il est écrit à l’intérieur de UserDashboard :
handleProfileClick
↓
UserDashboard
├── userId
└── role
Le même mécanisme s’applique aux props, à l’état, aux callbacks, aux timers, aux promesses et aux hooks. Chaque rendu crée un nouvel environnement avec les valeurs de ce rendu, et les gestionnaires définis pendant celui-ci font référence précisément à ces valeurs. C’est là la base des closures, ainsi que des closures obsolètes, où un callback provenant d’un rendu précédent continue de lire l’état ancien.
Idées reçues courantes
« La recherche suit l’appelant »
C’est faux. Ce qui compte, c’est le site de définition :
Where function is called ❌
Where function is defined ✅
« La pile d’appels et la chaîne de portée sont identiques »
Elles ont des fonctions différentes :
Call Stack → execution
Scope Chain → identifier lookup
« Le code externe peut lire les variables internes »
La recherche est unidirectionnelle :
Inner → Outer → Global ✅
Outer → Inner ❌
« var respecte les blocs »
C’est faux ; seuls let et const le font :
var → function-scoped
let/const → block-scoped
« Un environnement lexical est un objet ordinaire »
C’est un concept de spécification ; les moteurs l’implémentent librement, et votre code ne peut pas y accéder.
Un modèle mental à garder en tête
Ce sujet se résume en une série de questions :
Scope
↓
Where can I use this variable?
↓
Lexical Scope
↓
Where was this function defined?
Lexical Environment
↓
What variables exist here?
Outer Environment
↓
Where do I look next?
Scope Chain
↓
Current → Outer → Global
Identifier Resolution
↓
Find the matching variable
Points clés
- Le champ d’application détermine où un nom peut être utilisé ; JavaScript dispose de champs d’application globaux, fonctionnels, de bloc et de module,
varétant limité aux fonctions tandis quelet/constle sont aux blocs. - Le champ d’application est lexical : l’environnement externe d’une fonction est fixé au lieu où elle est définie, de sorte qu’appeler cette fonction depuis un autre endroit ne change jamais ce qu’elle peut voir.
- La résolution des identifiants se fait en partant de l’environnement actuel vers l’extérieur, s’arrête à la première correspondance (ombrage) et génère une erreur
ReferenceErrorsi elle atteint la fin. - Les fonctions internes conservent des références vers les environnements externes, et non des copies, ce qui constitue le principe des fermetures ainsi que celui des fermetures obsolètes dans React.