Comment JavaScript exécute votre code : pile, tas, boucle d’événements et générateurs
Visite guidée du runtime JavaScript : pile d’appels et frames, références au heap, boucle d’événements de Node.js, DOM, fonctions de rappel, promesses, async/await et générateurs.
Sources: JavaScript
Le JavaScript asynchrone se comporte souvent de manières qui surprennent les développeurs : une ligne de journal apparaît « trop tôt », un await semble sauter des étapes, un générateur affiche seulement la moitié de ses messages. Presque toutes ces surprises disparaissent une fois que l’on dispose d’un modèle clair de la manière dont le moteur d’exécution traite le code. Cet article construit ce modèle pas à pas, depuis la pile d’appels et le heap, en passant par le boucle d’événements de Node.js et le DOM, jusqu’aux promesses, aux patterns async/await et aux générateurs, afin que vous puissiez prédire la sortie de chaque fragment de code avant même de le lire.
Les composants du moteur d’exécution
Le JavaScript s’exécute sur une seule thread principale, pourtant les programmes attendent des temporiseurs, des réponses réseau et des fichiers sans se figer, car le moteur ne fonctionne pas seul :
- la pile d’appels, qui suit les fonctions en cours d’exécution
Votre code s’exécute sur la pile d’appels tandis que le moteur de exécution gère les opérations lentes. Lorsqu’une opération est terminée, son rappel est mis en file d’attente, et la boucle d’événements le place sur la pile dès que celle-ci est disponible.
Un aperçu de la boucle d’événements de Node.js
Le diagramme montre le cycle de Node.js. Le code synchrone s’exécute en premier, suivi des microtâches en file d’attente, puis des phases du boucle : les temporiseurs (setTimeout, setInterval), les callbacks en attente, la phase de sondage (où s’exécutent la plupart des callbacks liés aux fichiers et au réseau), la phase de vérification (setImmediate) et les callbacks de fermeture.
┌───────────────────┐
│ CALL STACK │
│ JavaScript │
└─────────┬─────────┘
│
│ Synchronous code finishes
▼
┌───────────────────┐
│ MICROTASK QUEUE │
│ │
│ Promise.then() │
│ Promise.catch() │
│ queueMicrotask() │
└─────────┬─────────┘
│
▼
╔═════════════════════════════════════════════╗
║ EVENT LOOP ║
║ ║
║ ┌───────────────┐ ║
║ │ TIMERS │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ PENDING │ ║
║ │ CALLBACKS │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ POLL │ ║
║ │ I/O │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ CHECK │ ║
║ │ setImmediate │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ CLOSE │ ║
║ │ CALLBACKS │ ║
║ └───────┬───────┘ ║
║ └───────────────────────────────┐ ║
║ │ ║
╚══════════════════════════════════════════╪══╝
│
▼
┌───────────────────┐
│ CALL STACK │
│ Execute JS │
└───────────────────┘
Deux points importants méritent d’être soulignés. Les microtâches ne sont pas traitées qu’une seule fois par boucle : Node vide la file des microtâches (ainsi que la file process.nextTick avant elle) après chaque callback, de sorte que le gestionnaire .then d’une promesse s’exécute avant le prochain callback de timer. De plus, les navigateurs suivent le principe des tâches et microtâches, mais ne disposent ni de fonction setImmediate ni de phases libuv, et ils intercalent le rendu. Pour en savoir plus sur les particularités de Node, consultez comment libuv, le cycle d’événements et le pool de threads fonctionnent ensemble.
La pile d’appels
La pile d’appels enregistre quelle fonction est en cours d’exécution et quelles fonctions attendent son retour. Il s’agit d’une structure LIFO, dernier entré, premier sorti : la fonction appelée récemment doit d’abord s’achever avant que celle qui se trouve en dessous ne puisse continuer.
Dans l’exemple suivant, func1 appelle func2, qui à son tour appelle func3, ce dernier enregistrant un message :
function func1() {
func2();
}
function func2() {
func3();
}
function func3() {
console.log("Hello");
}
func1();
Les appels s’empilent dans cet ordre :
func1()
↓
func2()
↓
func3()
↓
console.log()
Comme une pile de assiettes, console.log est exécuté en dernier et en premier, puis func3, func2 et func1 retournent à leur tour. Les fonctions de rappel en file d’attente ne peuvent s’exécuter que lorsque la pile est vide, c’est pourquoi une boucle synchrone longue bloque tout le reste.
Le heap
Le heap est la mémoire réservée aux données dont la durée de vie n’est pas liée à une seule appel, comme les objets, les tableaux et les closures.
La création d’un objet littéral l’attribue à cet endroit :
const user = {
name: "Rayane",
age: 20
};
La variable ne contient que une référence à l’objet stocké dans le heap :
CALL STACK HEAP
┌───────────────┐ ┌────────────────────┐
│ user variable │─────────▶│ { │
│ │ │ name: "Rayane", │
└───────────────┘ │ age: 20 │
│ } │
└────────────────────┘
Lorsque user est assigné à une autre variable, c’est la référence qui est copiée, non l’objet lui-même, de sorte que les modifications effectuées via l’une ou l’autre variable sont visibles dans les deux. Lorsqu’il n’y a plus de référence accessible pointant vers un objet, le collecteur de déchets peut le récupérer ; en revanche, une fermeture à long terme qui capture un objet le maintient en vie.
Ce que coordonne la boucle d’événements
La boucle d’événements relie votre code à l’achèvement des tâches asynchrones. Dans Node.js, V8 exécute votre code ; une appel à une API de Node, comme la lecture d’un fichier, est transmis à libuv, qui le passe au système d’exploitation ou à son pool de threads. Lorsque la tâche est terminée, la fonction de rappel devient disponible et la boucle d’événements l’exécute finalement sur la pile.
JavaScript code
│
▼
V8 Engine
│
▼
Call Stack
│
▼
Node.js APIs
│
▼
libuv
│
├───────────────┐
▼ ▼
OS Thread Pool
│ │
└───────┬───────┘
▼
Asynchronous operation
│
▼
Callback becomes ready
│
▼
Event Loop
│
▼
Callback Queue
│
▼
Call Stack
Le chemin à suivre dépend de l’opération : les sockets sont généralement gérés par le système d’exploitation via epoll ou kqueue, tandis que les opérations sur le système de fichiers ainsi que certaines appels cryptographiques et DNS utilisent le pool de threads.
Frames de pile
Chaque appel ajoute un frame de pile contenant ce dont la fonction a besoin pendant son exécution :
- ses paramètres
- ses variables locales
- l’adresse de retour, c’est-à-dire l’endroit où l’exécution reprend dans la fonction appelante
- d’autres informations de suivi gérées par le moteur
Prenons une fonction qui additionne deux nombres :
function add(a, b) {
const result = a + b;
return result;
}
add(2, 3);
Lorsque add(2, 3) est en cours d’exécution, son frame contient les arguments, le résultat local ainsi qu’un lien vers la fonction appelante :
CALL STACK
┌─────────────────────┐
│ stack frame: add │
│ │
│ a = 2 │
│ b = 3 │
│ result = 5 │
│ return → caller │
└─────────────────────┘
Lorsqu’elle retourne, le frame est supprimé :
CALL STACK
┌─────────────────────┐
│ frame removed │
└─────────────────────┘
Tout le cycle de vie est très compact :
Function call
↓
Stack Frame created
↓
Function executes
↓
Function returns
↓
Stack Frame removed
La récursivité allant trop profondément empile des frames jusqu’à ce que le moteur génère une erreur RangeError en raison dépassement de la taille maximale de la pile d’appels.
Le DOM
Le Document Object Model est l’arbre en temps réel des nœuds créé par le navigateur à partir d’un document HTML. Grâce à lui, JavaScript peut :
- lire des éléments
- modifier leur contenu
- insérer de nouveaux éléments
- supprimer des éléments existants
- modifier les styles et attributs
Le DOM est une API de navigateur, et non faisant partie du langage, c’est pourquoi document n’existe pas dans Node.js pur.
Voici un titre simple en HTML :
<h1 id="title">Hello</h1>
Un script peut obtenir une référence à cet élément via son id :
const title = document.getElementById("title");
puis remplacer son texte :
title.textContent = "Hello Rayane!";
Au préalable, le nœud de texte enfant de l’élément contient la chaîne d’origine :
document
│
▼
<h1 id="title">
│
▼
"Hello"
Après exécution de cette même affectation :
title.textContent = "Hello Rayane!";
l’arbre reflète le nouveau texte :
document
│
▼
<h1 id="title">
│
▼
"Hello Rayane!"
Le navigateur réredessine ensuite. Dans du code réel, vérifiez la valeur null que renvoie getElementById lorsqu’aucun élément ne correspond ; préférez textContent à innerHTML pour des chaînes non fiables, car il ne parse jamais d’HTML.
Appels de fonction
greet est passé à execute, qui l’appelle avec un nom :
function greet(name) {
console.log(`Hello ${name}`);
}
function execute(callback) {
callback("Ajami");
}
execute(greet);
Le programme affiche :
Hello Ajami
Dans execute, le paramètre nommé callback fait référence à greet, et cette ligne l’appelle :
callback("Ajami");
Un appel de fonction n’est pas intrinsèquement asynchrone ; celui-ci s’exécute immédiatement. Il devient asynchrone lorsque le récepteur le déclenche après un événement, comme le déclenchement d’un chronomètre.
Promesses
fetch est une API typique qui retourne une promesse :
const promise = fetch("https://api.example.com/data");
.then()pour un résultat réussi.catch()en cas d’échec.finally()pour des opérations de nettoyage qui s’exécutent dans tous les cas
fetch("https://api.example.com/data")
.then((response) => {
console.log("Request finished");
})
.catch((error) => {
console.log("Something went wrong:", error.message);
})
.finally(() => {
console.log("Request completed");
});
fetch ne rejette la promesse que en cas d’échec réseau. Un code 404 ou 500 fait toujours aboutir la promesse, il faut donc vérifier soi-même response.ok.
États de la promesse
PENDING
│
├───────────────┐
▼ ▼
FULFILLED REJECTED
- Pendante : l’opération n’est pas encore terminée.
- Terminée : elle s’est achevée avec succès et possède une valeur.
- Réfusée : elle a échoué et présente une raison, généralement une erreur.
Async/await
Une fonction async renvoie toujours une promesse, et await permet d’écrire des étapes asynchrones de manière séquentielle. Cette fonction enregistre un message, attend une requête puis enregistre à nouveau un message, avec un autre enregistrement après l’appel :
async function leetfinish() {
console.log("The function starts");
const res = await fetch("https://api.example.com/finish");
console.log("The request finished");
}
leetfinish();
console.log("Let's review our code");
Un résultat probable est :
The function starts
Let's review our code
The request finished
Pourquoi l’enregistrement externe s’exécute en deuxième
La fonction affiche son premier message puis atteint cette ligne :
const res = await fetch("https://api.example.com/finish");
fetch renvoie une promesse en attente, et await suspend la fonction tout en restituant le contrôle à l’appelant sans geler le thread. L’appelant affiche son message ; ce n’est que lorsque la réponse arrive que la fonction reprend son exécution. Ainsi :
The function starts
Let's review our code
The request finished
await pause la fonction, pas le thread. De plus, le terme « probablement » est important : si la requête échoue, la dernière ligne n’est jamais affichée et l’erreur non capturée devient un rejet non géré.
Gestion des erreurs avec try, catch et finally
Avec async/await, on utilise les structures ordinaires try/catch/finally. Cet exemple récupère des données, valide la réponse ainsi que le budget, et réinitialise toujours un indicateur de chargement. Ses commentaires soulignent deux points : les erreurs HTTP ne provoquent pas de rejet, et response.json() renvoie également une promesse.
let isLoading = true;
async function checkBudget() {
try {
// fetch() returns a Promise.
// await pauses this function until the Promise settles.
const response = await fetch(
"https://api.example.com/khouribga/business/budget"
);
// fetch() does NOT reject automatically for HTTP errors
// such as 404 or 500.
if (!response.ok) {
throw new Error("Failed to fetch business data");
}
// response.json() also returns a Promise.
const business = await response.json();
if (business.budget < 10) {
throw new Error("Budget is too low");
}
console.log("Budget is sufficient:", business.budget);
} catch (error) {
console.log("Something went wrong:", error.message);
} finally {
// This runs whether the request succeeds or fails.
isLoading = false;
console.log("Loading finished:", isLoading);
}
}
checkBudget();
Étape par étape :
fetch()envoie la requête etawaitsuspend la fonction.- Lorsque la réponse arrive, la fonction reprend son exécution.
- Un statut HTTP échoué est transformé en erreur lancée.
- Le corps JSON est analysé et le budget vérifié.
- Toute erreur dans le bloc
try, y compris les pannes réseau, est capturée par le bloccatch. finallys’exécute toujours et met à jour l’état de chargement.
Exercice : exécuter des fonctions asynchrones en parallèle
Promise.all.
Générateurs
function*, et les points d’arrêt sont indiqués par yield:
function* generatorAjami() {
// Instructions ...
yield i;
// More instructions ...
yield i;
// More instructions ...
}
Comparez yield avec return :
returnproduit une valeur et termine la fonction.yieldproduit une valeur et suspend la fonction, en préservant son état.
Déroulement d’un générateur
Ce générateur affiche un message avant chacune des trois instructions yield :
function* generatorAjami() {
console.log("First born in casa");
yield 10;
console.log("Second move to Khouribga");
yield 20;
console.log("Third live in Khouribga");
yield 30;
}
Lorsqu’on l’appelle, aucune partie du corps de la fonction n’est exécutée :
const generator = generatorAjami();
On obtient un objet générateur, un itérateur à partir duquel on récupère des valeurs avec .next() :
console.log(generator.next());
La première appelation se poursuit jusqu’à la première instruction yield, affichant ainsi le premier message :
First born in casa
{ value: 10, done: false }
L’exécution s’arrête à cette instruction :
yield 10;
et renvoie 10. Chaque résultat de .next() possède deux propriétés :
value, la valeur qui a été rendue
done, indiquant si le générateur a terminéAinsi, le résultat suivant :
{ value: 10, done: false }
signifie « voici 10, et il y en a davantage ».
Appel de .next() une deuxième fois :
console.log(generator.next());
produit :
Second move to Khouribga
{ value: 20, done: false }
Le deuxième message apparaît seulement maintenant parce que le générateur reprend immédiatement après le précédent yield.
Un troisième appel :
console.log(generator.next());
affiche le troisième message et produit 30 :
Third live in Khouribga
{ value: 30, done: false }
et un quatrième :
console.log(generator.next());
indique qu’il ne reste rien :
{ value: undefined, done: true }
Sans return explicite, la valeur finale est undefined.
Qu’est-ce qu’un générateur vraiment ?
Un générateur ne s’exécute pas plusieurs fois : une exécution commence et se termine une seule fois, mais peut être suspendue et reprise pour fournir de nombreuses valeurs. La première appelation s’exécute jusqu’à yield 10:
generator.next()
↓
Execute instructions
↓
yield 10
↓
Pause and return { value: 10, done: false }
La deuxième reprend son exécution et s’arrête à yield 20:
generator.next()
↓
Resume after yield 10
↓
yield 20
↓
Pause and return { value: 20, done: false }
La troisième reprend son exécution et s’arrête à yield 30:
generator.next()
↓
Resume after yield 20
↓
yield 30
↓
Pause and return { value: 30, done: false }
La quatrième ne trouve plus de instructions et signale sa fin :
generator.next()
↓
No more instructions
↓
Return { value: undefined, done: true }
for...of ainsi que la syntaxe de déploiement appellent automatiquement .next() et s’arrêtent lorsque done: true est atteint. Pour en savoir plus sur d’autres cas d’usage, consultez les fonctions pauseables et l’itération différée avec les générateurs.
Exercice : un générateur infini de Fibonacci
Le problème LeetCode 2648 convient parfaitement. Le générateur produit la valeur actuelle, puis met à jour les deux valeurs en cours d’exécution. La boucle while (true) est sûre car chaque itération s’arrête à l’appel de yield.
var fibGenerator = function* () {
let a = 0;
let b = 1;
while (true) {
yield a;
const next = a + b;
a = b;
b = next;
}
};
L’état évolue de la manière suivante :
a = 0, b = 1
↓
yield 0
↓
a = 1, b = 1
↓
yield 1
↓
a = 1, b = 2
↓
yield 1
↓
a = 2, b = 3
↓
yield 2
Les valeurs sont calculées de manière différée, une par appel à .next() ; il ne faut jamais les parcourir ou les distribuer sans limite.
Assemblage du modèle
Pour Node.js : V8 exécute votre code avec sa pile et son tas mémoire, les API de Node transmettent les tâches lentes à libuv via sa boucle d’événements et son pool de threads, et le système d’exploitation gère les réseaux.
┌─────────────────────────────────────────────┐
│ NODE.JS │
│ │
│ ┌─────────────┐ │
│ │ V8 │ │
│ │ │ │
│ │ Call Stack │ │
│ │ Heap │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ Node.js APIs │ │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ libuv │ │
│ │ │ │
│ │ ┌─────────┐ │ │
│ │ │ Event │ │ │
│ │ │ Loop │ │ │
│ │ └─────────┘ │ │
│ │ │ │
│ │ Thread Pool │ │
│ └───────┬───────┘ │
│ │ │
└───────────┼─────────────────────────────────┘
│
▼
┌───────────────┐
│ OS │
│ │
│ Networking │
│ Kernel │
│ epoll/kqueue │
└───────────────┘
Points clés
- Rien n’est exécuté tant que la pile d’appels n’est pas vide, donc de longues tâches synchrones bloquent tout.
- Les variables conservent des références vers des objets du tas mémoire ; les objets inaccessibles sont collectés.
fetch rejette uniquement en cas d’erreurs réseau, il faut donc vérifier response.ok.await suspend la fonction, pas le thread.yield et se reprendrent avec .next(), ce qui permet des séquences différées, voire infinies.