Le flux dâexécution JavaScript dans un navigateur, de même que dans Node.js, est basé sur la boucle dâévénement.
Comprendre comment fonctionne la boucle dâévénement est important pour les optimisations, et parfois pour le bon choix dâune architecture.
Dans ce chapitre, nous couvrons dâabord les détails théoriques sur le fonctionnement des choses, puis nous verrons les applications pratiques de ces connaissances.
La boucle dâévénement
Le concept de boucle dâévénement est très simple. Il y a une boucle sans fin, où le moteur JavaScript attend les tâches, les exécute puis dort, en attendant plus de tâches.
Lâalgorithme général du moteur:
- Tant quâil y a des tâches:
- il les exécute, en commençant par la tâche la plus ancienne.
- Dort jusquâà ce quâune tâche apparaisse, puis repasse à 1.
Câest une formalisation de ce que nous voyons lors de la navigation sur une page. Le moteur JavaScript ne fait rien la plupart du temps, il ne fonctionne que si un script / gestionnaire / événement sâactive.
Exemples de tâches:
- Lorsquâun script externe
< script src = "...">charge, la tâche consiste à lâexécuter. - Lorsquâun utilisateur déplace sa souris, la tâche consiste à envoyer un événement
mousemoveet à exécuter des gestionnaires. - Lorsque le temps est écoulé pour un
setTimeoutplanifié, la tâche consiste à exécuter la fonction de callback. - â¦et ainsi de suite.
Les tâches sont définies â le moteur les gère â puis attend plus de tâches (tout en dormant et en consommant près de zéro CPU).
Il peut arriver quâune tâche arrive pendant que le moteur est occupé, il est alors mis en file dâattente.
Les tâches forment une file dâattente, dite âfile dâattente des macrotâchesâ (terme v8) :
Par exemple, alors que le moteur est occupé à exécuter un script, un utilisateur peut déplacer sa souris provoquant un mousemove, et un setTimeout peut être écoulé, et ainsi de suite, ces tâches forment une file dâattente, comme illustré sur lâimage ci-dessus.
Les tâches de la file dâattente sont traitées sur la base du «premier arrivé â premier servi». Lorsque le navigateur du moteur en a fini avec le script, il gère lâévénement mousemove, puis le gestionnaire setTimeout, etc.
Jusquâà présent, câest assez simple, nâest-ce pas ?
Deux détails supplémentaires:
- Le rendu ne se produit jamais pendant que le moteur exécute une tâche. Peu importe que la tâche prenne beaucoup de temps. Les modifications apportées au DOM ne sont peintes quâaprès la fin de la tâche.
- Si une tâche prend trop de temps, le navigateur ne peut pas effectuer dâautres tâches, telles que le traitement des événements utilisateur. Donc, après un certain temps, cela soulève une alerte du type âLa page ne répond plusâ, suggérant de tuer la tâche avec toute la page. Cela se produit lorsquâil y a beaucoup de calculs complexes ou une erreur de programmation conduisant à une boucle infinie.
Câétait la théorie. Voyons maintenant comment nous pouvons appliquer ces connaissances.
Cas dâutilisation 1: fractionnement des tâches consommatrices de CPU
Supposons que nous avons une tâche gourmande en CPU.
Par exemple, la mise en évidence de la syntaxe (utilisée pour coloriser des exemples de code sur cette page) est assez lourde en termes de CPU. Pour mettre en évidence le code, il effectue lâanalyse, crée de nombreux éléments colorés, les ajoute au document â pour une grande quantité de texte qui prend beaucoup de temps.
Bien que le moteur soit occupé à mettre en évidence la syntaxe, il ne peut pas faire dâautres trucs liés au DOM, traiter les événements utilisateur, etc. Cela peut même amener le navigateur à âsaccaderâ ou même à âfigerâ un peu, ce qui est inacceptable.
Nous pouvons éviter les problèmes en divisant la grande tâche en morceaux. Mettez en surbrillance les 100 premières lignes, puis planifiez un setTimeout (avec un délai à 0) pour les 100 lignes suivantes, etc.
Pour démontrer cette approche, par souci de simplicité, au lieu de la colorisation du texte, prenons une fonction qui compte de 1 à 1000000000.
Si vous exécutez le code ci-dessous, le moteur va se âfigerâ pendant un certain temps. Pour du JS côté serveur, câest clairement perceptible, et si vous lâexécutez dans le navigateur, essayez de cliquer sur dâautres boutons de la page â vous verrez quâaucun autre événement nâest géré jusquâà la fin du comptage.
let i = 0;
let start = Date.now();
function count() {
// réalise un gros job
for (let j = 0; j < 1e9; j++) {
i++;
}
alert("Effectué en " + (Date.now() - start) + 'ms');
}
count();
Le navigateur peut même afficher un avertissement âle script prend trop de tempsâ.
Divisons le travail en utilisant des appels de setTimeout imbriqués :
let i = 0;
let start = Date.now();
function count() {
// réalise un morceau du gros job (*)
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Effectué en " + (Date.now() - start) + 'ms');
} else {
setTimeout(count); // planifie un nouvel appel (**)
}
}
count();
Désormais, lâinterface du navigateur est pleinement fonctionnelle pendant le processus de âcomptageâ.
Une seule exécution de count fait une partie du travail (*), puis se re-calcule (**) si nécessaire:
- La première manche compte :
i=1...1000000. - La deuxième manche compte :
i=1000001..2000000. - â¦et ainsi de suite.
Maintenant, si une nouvelle tâche secondaire (par ex. un événement onclick) apparaît pendant que le moteur est occupé à exécuter la partie 1, elle est mise en file dâattente, puis sâexécute lorsque la partie 1 est terminée, avant la partie suivante. Les retours périodiques à la boucle dâévénement entre les exécutions «count» fournissent juste assez d '«air» pour que le moteur JavaScript fasse autre chose, pour réagir à dâautres actions de lâutilisateur.
La chose notable est que les deux variantes â avec et sans diviser le travail par setTimeout â sont comparables en vitesse. Il nây a pas beaucoup de différence dans le temps de comptage global.
Pour les minimiser, faisons une amélioration.
Nous déplacerons la planification au début du count():
let i = 0;
let start = Date.now();
function count() {
// déplace la planification au début
if (i < 1e9 - 1e6) {
setTimeout(count); // planifie le nouvel appel
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Effectué en " + (Date.now() - start) + 'ms');
}
}
count();
Maintenant, quand nous commençons à count() et voyons que nous aurons besoin de count() supplémentaires, nous planifions cela immédiatement, avant de faire le travail.
Si vous lâexécutez, il est facile de remarquer que cela prend beaucoup moins de temps.
Pourquoi ?
Câest simple: comme vous vous en souvenez, il y a le retard minimal dans le navigateur de 4 ms pour de nombreux appels de setTimeout imbriqués. Même si nous définissons un délai à 0, câest 4ms (ou un peu plus). Donc, plus nous les planifions tôt â plus ils sâexécuteront rapidement.
Voilà , nous avons divisé une tâche gourmande en CPU en morceaux â maintenant elle ne bloque pas lâinterface utilisateur. Et son temps dâexécution global nâest pas beaucoup plus long.
Cas dâutilisation 2: indicateur de progression
Un autre avantage de la division de tâches lourdes pour les scripts de navigateur est que nous pouvons afficher un indicateur de progression.
Comme mentionné précédemment, les modifications apportées au DOM ne sont peintes quâaprès la fin de la tâche en cours dâexécution, quel que soit le temps nécessaire.
Dâune part, câest génial, car notre fonction peut créer de nombreux éléments, les ajouter un par un au document et changer leurs styles â le visiteur ne verra aucun état âintermédiaireâ et inachevé. Une chose importante, non?
Voici la démo, les modifications apportées à i nâapparaîtront pas avant la fin de la fonction, nous ne verrons donc que la dernière valeur :
<div id="progress"></div>
<script>
function count() {
for (let i = 0; i < 1e6; i++) {
i++;
progress.innerHTML = i;
}
}
count();
</script>
â¦Mais nous pouvons également vouloir afficher quelque chose pendant la tâche, par exemple une barre de progression.
Si nous divisons la tâche lourde en morceaux à lâaide dâun setTimeout, les modifications sont peintes entre elles.
Cela semble plus joli :
<div id="progress"></div>
<script>
let i = 0;
function count() {
// réalise un morceau du travail lourd (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e7) {
setTimeout(count);
}
}
count();
</script>
Maintenant, la <div> montre des valeurs croissantes de i, une sorte de barre de progression.
Cas dâutilisation 3: faire quelque chose après lâévénement
Dans un gestionnaire dâévénements, nous pouvons décider de reporter certaines actions jusquâà ce que lâévénement âbouillonneâ (bubble up) et soit géré à tous les niveaux. Nous pouvons le faire en enveloppant le code dans un setTimeout avec un délai nul.
Dans le chapitre Distribution d'événements personnalisés, nous avons vu un exemple: lâévénement personnalisé menu-open est envoyé dans un setTimeout, de sorte quâil se produit après que lâévénement âclicâ soit entièrement géré.
menu.onclick = function() {
// ...
// crée un événement personnalisé avec les données de l'élément de menu cliqué
let customEvent = new CustomEvent("menu-open", {
bubbles: true
});
// dispatche l'événement personnalisé de manière asynchrone
setTimeout(() => menu.dispatchEvent(customEvent));
};
Les Macrotâches et les Microtâches
Avec les macrotâches, décrits dans ce chapitre, il y a les microtâches, mentionnés dans le chapitre Les micro-tâches.
Les microtâches proviennent uniquement de notre code. Ils sont généralement créés par des Promesses: une exécution du gestionnaire .then / catch / finally devient une microtâche. Les microtâches sont également utilisées âsous la couvertureâ dâun await, car câest une autre forme de gestion des Promesses.
Il existe également une fonction spéciale queueMicrotask(func) qui met en file dâattente func pour lâexécution dans la file dâattente des microtâches.
Immédiatement après chaque macrotâche, le moteur exécute toutes les tâches à partir de la file dâattente des microtâches, avant dâexécuter dâautres macrotâches, ou rendu, ou quoi que ce soit dâautre.
Par exemple, jetez un Åil là -dessus:
setTimeout(() => alert("timeout"));
Promise.resolve()
.then(() => alert("promise"));
alert("code");
Quel sera lâordre ici?
codesâaffiche en premier, car il sâagit dâun appel synchrone régulier.promisesâaffiche en second, car.thenpasse par la file dâattente des microtâches et sâexécute après le code actuel.timeoutsâaffiche en dernier, car câest une macrotâche.
Une image, plus parlante, de la boucle dâévénements ressemble à ceci (lâordre est de haut en bas, câest-à -dire: le script dâabord, puis les microtâches, le rendu, etc.):
Toutes les microtâches sont terminées avant toute autre gestion ou rendu dâévénement ou toute autre macrotâche.
Câest important, car cela garantit que lâenvironnement de lâapplication est fondamentalement le même (pas de changement de coordonnées de souris, pas de nouvelles données réseau, etc.) entre les microtâches.
Si nous souhaitons exécuter une fonction de manière asynchrone (après le code actuel), mais avant que les modifications ne soient rendues ou que de nouveaux événements ne soient traités, nous pouvons la planifier avec un queueMicrotask.
Voici un exemple avec le âcalcul de la barre de progressionâ, similaire à celui illustré précédemment, mais âqueueMicrotaskâ est utilisé à la place de âsetTimeoutâ. Vous pouvez voir que cela se produit à la toute fin. Tout comme le code synchrone :
<div id="progress"></div>
<script>
let i = 0;
function count() {
// réalise un morceau du travail lourd (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e6) {
queueMicrotask(count);
}
}
count();
</script>
Résumé
Un algorithme de boucle dâévénement plus détaillé (bien que toujours simplifié par rapport à la spécification):
- Dépile et exécute la tâche la plus ancienne de la file dâattente macrotâches (par ex. âscriptâ).
- Exécute toutes les microtâches :
- Tant que la file dâattente des microtâches nâest pas vide :
- Dépile et exécute la plus ancienne microtâche.
- Tant que la file dâattente des microtâches nâest pas vide :
- Le rendu change le cas échéant.
- Si la file dâattente des macrotâches est vide, attend quâune macrotâche apparaisse.
- Passez à lâétape 1.
Pour planifier une nouvelle macrotâche:
- Utilisez un
setTimeout(f)avec un délai de 0.
Cela peut être utilisé pour fractionner en morceaux une grande tâche de calcul lourd, pour que le navigateur puisse réagir aux événements utilisateur et afficher une progression entre eux.
Ãgalement, câest utilisé dans les gestionnaires dâévénements pour planifier une action après que lâévénement ait été entièrement géré (âbubblingâ terminé).
Pour planifier une nouvelle microtâche
- Utilisez un
queueMicrotask(f). - Les gestionnaires de Promesses passent également par la file dâattente des microtâches.
Il nây a pas de gestion dâinterface utilisateur ou dâévénement réseau entre les microtâches: elles fonctionnent immédiatement lâune après lâautre.
On peut donc vouloir utiliser un queueMicrotask pour exécuter une fonction de manière asynchrone, mais dans lâétat de lâenvironnement.
Pour les calculs longs et lourds qui ne devraient pas bloquer la boucle dâévénement, nous pouvons utiliser les Web Workers.
Câest une façon dâexécuter du code dans un autre thread parallèle.
Les Web Workers peuvent échanger des messages avec le processus principal, mais ils ont leurs propres variables et leur propre boucle dâévénements.
Les Web Workers nâont pas accès au DOM, ils sont donc utiles, principalement, pour les calculs, pour utiliser simultanément plusieurs cÅurs CPU.
Commentaires
<code>, pour plusieurs lignes â enveloppez-les avec la balise<pre>, pour plus de 10 lignes - utilisez une sandbox (plnkr, jsbin, codepenâ¦)