On vous a dit de ne pas bloquer le thread principal et de diviser vos tâches longues, mais qu'est-ce que cela signifie ?
Publié le 30 septembre 2022, dernière mise à jour le 19 décembre 2024
Les conseils courants pour accélérer les applications JavaScript se résument généralement à ce qui suit:
- "Ne bloquez pas le thread principal."
- "Décomposez vos tâches longues."
C'est un excellent conseil, mais quel travail cela implique-t-il ? Envoyer moins de code JavaScript est une bonne chose, mais cela signifie-t-il automatiquement que les interfaces utilisateur sont plus réactives ? Peut-être, mais peut-être pas.
Pour comprendre comment optimiser les tâches en JavaScript, vous devez d'abord savoir ce que sont les tâches et comment le navigateur les gère.
Qu'est-ce qu'une tâche ?
Une tâche est une tâche distincte effectuée par le navigateur. Ce travail comprend l'affichage, l'analyse du code HTML et CSS, l'exécution de code JavaScript et d'autres types de tâches que vous ne pouvez pas contrôler directement. Parmi tous ces éléments, le code JavaScript que vous écrivez est peut-être la plus grande source de tâches.
Les tâches associées à JavaScript ont un impact sur les performances de deux manières:
- Lorsqu'un navigateur télécharge un fichier JavaScript au démarrage, il met en file d'attente des tâches d'analyse et de compilation de ce code JavaScript afin qu'il puisse être exécuté plus tard.
- À d'autres moments de la durée de vie de la page, les tâches sont mises en file d'attente lorsque JavaScript fonctionne, par exemple pour répondre aux interactions via des gestionnaires d'événements, des animations JavaScript et des activités en arrière-plan telles que la collecte d'informations analytiques.
Tout cela, à l'exception des nœuds de calcul Web et des API similaires, se produit dans le thread principal.
Qu'est-ce que le thread principal ?
C'est dans le thread principal que la plupart des tâches s'exécutent dans le navigateur et que presque tout le code JavaScript que vous écrivez est exécuté.
Le thread principal ne peut traiter qu'une seule tâche à la fois. Toute tâche qui prend plus de 50 millisecondes est une tâche longue. Pour les tâches qui dépassent 50 millisecondes, la durée totale de la tâche moins 50 millisecondes est appelée période de blocage.
Le navigateur empêche les interactions de se produire pendant l'exécution d'une tâche, quelle que soit sa durée, mais cela n'est pas perceptible par l'utilisateur tant que les tâches ne s'exécutent pas trop longtemps. Toutefois, lorsqu'un utilisateur tente d'interagir avec une page alors que de nombreuses tâches longues sont en cours, l'interface utilisateur semble ne pas répondre, voire même ne pas fonctionner si le thread principal est bloqué pendant de très longues périodes.
Pour éviter que le thread principal ne soit bloqué trop longtemps, vous pouvez diviser une tâche longue en plusieurs tâches plus petites.
Cela est important, car lorsque les tâches sont divisées, le navigateur peut répondre beaucoup plus rapidement aux tâches de priorité plus élevée, y compris aux interactions utilisateur. Les tâches restantes sont ensuite exécutées jusqu'à leur achèvement, ce qui garantit que le travail que vous avez initialement mis en file d'attente est effectué.
En haut de la figure précédente, un gestionnaire d'événements mis en file d'attente par une interaction utilisateur a dû attendre une seule tâche longue avant de pouvoir commencer. Cela retarde l'interaction. Dans ce scénario, l'utilisateur a peut-être remarqué un temps de latence. En bas, le gestionnaire d'événements peut commencer à s'exécuter plus tôt, et l'interaction peut sembler instantanée.
Maintenant que vous savez pourquoi il est important de diviser les tâches, vous pouvez apprendre à le faire en JavaScript.
Stratégies de gestion des tâches
Un conseil courant en architecture logicielle consiste à diviser votre travail en fonctions plus petites:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
Dans cet exemple, une fonction nommée saveSettings()
appelle cinq fonctions pour valider un formulaire, afficher une icône de chargement, envoyer des données au backend de l'application, mettre à jour l'interface utilisateur et envoyer des données analytiques.
D'un point de vue conceptuel, saveSettings()
est bien conçu. Si vous devez déboguer l'une de ces fonctions, vous pouvez parcourir l'arborescence du projet pour déterminer la fonction de chacune. En divisant le travail de cette manière, vous facilitez la navigation et la maintenance des projets.
Cependant, un problème potentiel se pose ici, car JavaScript n'exécute pas chacune de ces fonctions en tant que tâches distinctes, car elles sont exécutées dans la fonction saveSettings()
. Cela signifie que les cinq fonctions seront exécutées en tant qu'une seule tâche.
Dans le meilleur des cas, même une seule de ces fonctions peut ajouter 50 millisecondes ou plus à la durée totale de la tâche. Dans le pire des cas, un plus grand nombre de ces tâches peuvent s'exécuter beaucoup plus longtemps, en particulier sur les appareils à ressources limitées.
Dans ce cas, saveSettings()
est déclenché par un clic de l'utilisateur. Comme le navigateur ne peut pas afficher de réponse tant que l'exécution de la fonction n'est pas terminée, le résultat de cette longue tâche est une UI lente et non réactive, qui sera mesurée comme une mauvaise interaction jusqu'à la prochaine peinture (INP).
Différer manuellement l'exécution du code
Pour vous assurer que les tâches importantes destinées aux utilisateurs et les réponses de l'UI sont exécutées avant les tâches de faible priorité, vous pouvez céder le pas au thread principal en interrompant brièvement votre travail pour permettre au navigateur d'exécuter des tâches plus importantes.
Les développeurs ont utilisé une méthode impliquant setTimeout()
pour décomposer les tâches en tâches plus petites. Avec cette technique, vous transmettez la fonction à setTimeout()
. Cela reporte l'exécution du rappel dans une tâche distincte, même si vous spécifiez un délai avant expiration de 0
.
function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Defer work that isn't user-visible to a separate task:
setTimeout(() => {
saveToDatabase();
sendAnalytics();
}, 0);
}
C'est ce qu'on appelle la rendition. Cette méthode est particulièrement adaptée à une série de fonctions qui doivent s'exécuter de manière séquentielle.
Toutefois, votre code n'est pas toujours organisé de cette manière. Par exemple, vous pouvez avoir une grande quantité de données à traiter dans une boucle, et cette tâche peut prendre beaucoup de temps s'il y a de nombreuses itérations.
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
L'utilisation de setTimeout()
ici est problématique en raison de l'ergonomie pour les développeurs. Après cinq séries d'setTimeout()
imbriquées, le navigateur commencera à imposer un délai minimal de cinq millisecondes pour chaque setTimeout()
supplémentaire.
setTimeout
présente également un autre inconvénient en termes de cession: lorsque vous cédez au thread principal en différant le code à exécuter dans une tâche ultérieure à l'aide de setTimeout
, cette tâche est ajoutée à la fin de la file d'attente. Si d'autres tâches sont en attente, elles s'exécutent avant votre code différé.
Une API de rendement dédiée: scheduler.yield()
scheduler.yield()
est une API conçue spécifiquement pour céder au thread principal du navigateur.
Il ne s'agit pas d'une syntaxe au niveau du langage ni d'une construction spéciale. scheduler.yield()
n'est qu'une fonction qui renvoie un Promise
qui sera résolu dans une tâche ultérieure. Tout code en chaîne à exécuter après la résolution de cet Promise
(dans une chaîne .then()
explicite ou après l'avoir await
dans une fonction asynchrone) s'exécutera dans cette tâche future.
En pratique, insérez un await scheduler.yield()
. La fonction suspendra alors l'exécution à ce stade et cédera la place au thread principal. L'exécution du reste de la fonction (appelée continuation de la fonction) sera planifiée pour s'exécuter dans une nouvelle tâche de boucle d'événements. Lorsque cette tâche démarre, la promesse attendue est résolue et la fonction continue son exécution là où elle s'était arrêtée.
async function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Yield to the main thread:
await scheduler.yield()
// Work that isn't user-visible, continued in a separate task:
saveToDatabase();
sendAnalytics();
}
Cependant, l'avantage réel de scheduler.yield()
par rapport aux autres approches de rendement est que sa poursuite est prioritaire, ce qui signifie que si vous cédez au milieu d'une tâche, la poursuite de la tâche en cours s'exécute avant le démarrage d'autres tâches similaires.
Cela évite que le code d'autres sources de tâches interrompe l'ordre d'exécution de votre code, comme les tâches provenant de scripts tiers.
Compatibilité entre les navigateurs
scheduler.yield()
n'est pas encore compatible avec tous les navigateurs. Un plan de secours est donc nécessaire.
Une solution consiste à ajouter scheduler-polyfill
à votre build, puis à utiliser scheduler.yield()
directement. Le polyfill gérera le recours à d'autres fonctions de planification des tâches afin que le fonctionnement soit similaire dans tous les navigateurs.
Vous pouvez également écrire une version moins sophistiquée en quelques lignes, en n'utilisant que setTimeout
encapsulé dans une promesse comme solution de secours si scheduler.yield()
n'est pas disponible.
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
Bien que les navigateurs non compatibles avec scheduler.yield()
ne reçoivent pas la continuation prioritaire, ils cèdent la priorité pour que le navigateur reste réactif.
Enfin, il peut arriver que votre code ne puisse pas céder au thread principal si sa poursuite n'est pas prioritaire (par exemple, une page connue comme étant occupée où le céder risque de ne pas terminer la tâche pendant un certain temps). Dans ce cas, scheduler.yield()
peut être traité comme une sorte d'amélioration progressive: le rendement est généré dans les navigateurs où scheduler.yield()
est disponible, sinon la progression continue.
Pour ce faire, vous pouvez utiliser la détection de fonctionnalités et revenir à l'attente d'une seule microtâche dans une seule ligne:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Diviser les tâches de longue durée avec scheduler.yield()
L'avantage de ces méthodes d'utilisation de scheduler.yield()
est que vous pouvez les await
dans n'importe quelle fonction async
.
Par exemple, si vous avez un tableau de tâches à exécuter qui se résume souvent à une tâche longue, vous pouvez insérer des rendements pour la diviser.
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
La poursuite de runJobs()
sera prioritaire, mais permettra toujours d'exécuter des tâches de priorité plus élevée, comme répondre visuellement à l'entrée utilisateur, sans avoir à attendre la fin de la liste potentiellement longue de tâches.
Toutefois, ce n'est pas une utilisation efficace de la tolérance. scheduler.yield()
est rapide et efficace, mais présente un certain coût. Si certains des jobs de jobQueue
sont très courts, les frais généraux peuvent rapidement représenter plus de temps passé à générer et à reprendre qu'à exécuter le travail réel.
Une approche consiste à regrouper les tâches, en ne générant un résultat qu'après un délai suffisant depuis le dernier résultat. Un délai courant est de 50 millisecondes pour éviter que les tâches ne deviennent longues, mais il peut être ajusté en fonction du compromis entre la réactivité et le temps nécessaire pour terminer la file d'attente de tâches.
async function runJobs(jobQueue, deadline=50) {
let lastYield = performance.now();
for (const job of jobQueue) {
// Run the job:
job();
// If it's been longer than the deadline, yield to the main thread:
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}
Les tâches sont donc divisées pour ne jamais prendre trop de temps à s'exécuter, mais le runner ne cède au thread principal qu'environ toutes les 50 millisecondes.
Ne pas utiliser isInputPending()
L'API isInputPending()
permet de vérifier si un utilisateur a tenté d'interagir avec une page et ne renvoie un résultat que si une entrée est en attente.
Cela permet à JavaScript de continuer si aucune entrée n'est en attente, au lieu de céder et de se retrouver à l'arrière de la file d'attente des tâches. Cela peut entraîner des améliorations de performances impressionnantes, comme indiqué dans l'Intent to Ship, pour les sites qui ne reviendraient pas au thread principal.
Toutefois, depuis le lancement de cette API, notre compréhension du rendement a augmenté, en particulier avec l'introduction de l'INP. Nous ne recommandons plus d'utiliser cette API et vous conseillons plutôt de générer un résultat que l'entrée soit en attente ou non pour plusieurs raisons:
- Dans certains cas,
isInputPending()
peut renvoyer de manière incorrectefalse
, même si un utilisateur a interagi. - Les tâches ne doivent pas toujours produire de résultats en cas d'entrée. Les animations et les autres mises à jour régulières de l'interface utilisateur peuvent être tout aussi importantes pour fournir une page Web responsive.
- Des API de rendement plus complètes ont été introduites depuis, qui répondent aux problèmes de rendement, comme
scheduler.postTask()
etscheduler.yield()
.
Conclusion
La gestion des tâches est un défi, mais elle permet de garantir que votre page répond plus rapidement aux interactions des utilisateurs. Il n'existe pas de conseil unique pour gérer et hiérarchiser les tâches, mais plusieurs techniques différentes. Pour rappel, voici les principaux points à prendre en compte lorsque vous gérez des tâches:
- Cède le pas au thread principal pour les tâches critiques visibles par l'utilisateur.
- Utilisez
scheduler.yield()
(avec un remplacement multinavigateur) pour obtenir des continuations prioritaires et ergonomiques. - Enfin, effectuez le moins de travail possible dans vos fonctions.
Pour en savoir plus sur scheduler.yield()
, son scheduler.postTask()
relatif à la planification explicite des tâches et la hiérarchisation des tâches, consultez la documentation de l'API Prioritized Task Scheduling.
Avec un ou plusieurs de ces outils, vous devriez pouvoir structurer le travail dans votre application de manière à donner la priorité aux besoins de l'utilisateur, tout en vous assurant que les tâches moins critiques sont tout de même effectuées. Cela permettra d'améliorer l'expérience utilisateur, qui sera plus réactive et plus agréable à utiliser.
Remerciements particuliers à Philip Walton pour son examen technique de ce guide.
Image miniature provenant de Unsplash, avec l'aimable autorisation d'Amirali Mirhashemian.