On vous a dit de "ne pas bloquer le thread principal" et de "découper vos longues tâches", mais qu'est-ce que cela signifie concrètement ?
Publié le 30 septembre 2022, dernière mise à jour le 19 décembre 2024
Les conseils courants pour que les applications JavaScript restent rapides se résument généralement aux suivants :
- "Ne bloquez pas le thread principal."
- "Décompose tes longues tâches."
C'est un excellent conseil, mais qu'implique-t-il concrètement ? Il est bon d'expédier moins de JavaScript, mais cela se traduit-il automatiquement par des interfaces utilisateur 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 partie distincte du travail effectué par le navigateur. Cela inclut le rendu, l'analyse du code HTML et CSS, l'exécution de JavaScript et d'autres types de tâches sur lesquels vous n'avez peut-être pas de contrôle direct. Parmi tous ces éléments, le code JavaScript que vous écrivez est peut-être la plus grande source de tâches.
click
, affichée dans le profileur de performances des outils pour les développeurs Chrome.
Les tâches associées à JavaScript ont un impact sur les performances de plusieurs façons :
- Lorsqu'un navigateur télécharge un fichier JavaScript au démarrage, il met en file d'attente les tâches d'analyse et de compilation de ce fichier JavaScript afin qu'il puisse être exécuté ultérieurement.
- À d'autres moments de la durée de vie de la page, les tâches sont mises en file d'attente lorsque JavaScript effectue des opérations telles que la réponse aux interactions via des gestionnaires d'événements, les animations pilotées par JavaScript et les activités en arrière-plan telles que la collecte de données analytiques.
Tout cela, à l'exception des Web Workers et des API similaires, se produit sur le thread principal.
Qu'est-ce que le thread principal ?
Le thread principal est l'endroit où la plupart des tâches s'exécutent dans le navigateur et où 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 de la tâche.
Le navigateur bloque les interactions pendant l'exécution d'une tâche, quelle que soit sa durée. Toutefois, l'utilisateur ne s'en rend pas compte tant que les tâches ne durent pas trop longtemps. Toutefois, lorsqu'un utilisateur tente d'interagir avec une page comportant de nombreuses tâches longues, l'interface utilisateur semble ne pas répondre, voire même être cassée 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 longue tâche en plusieurs tâches plus petites.
C'est important, car lorsque les tâches sont fractionnées, le navigateur peut répondre beaucoup plus rapidement aux tâches de priorité supérieure, y compris aux interactions utilisateur. Ensuite, les tâches restantes sont exécutées jusqu'à la fin, 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 longue tâche avant de pouvoir commencer, ce qui retarde l'interaction. Dans ce scénario, l'utilisateur a peut-être remarqué un décalage. En bas, le gestionnaire d'événements peut commencer à s'exécuter plus tôt, et l'interaction peut avoir semblé instantanée.
Maintenant que vous savez pourquoi il est important de fractionner 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 un spinner, 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 comprendre ce que fait chaque fonction. Cette méthode permet de faciliter la navigation et la maintenance des projets.
Toutefois, un problème potentiel se pose ici : 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 s'exécuteront en tant qu'une seule tâche.
saveSettings()
qui appelle cinq fonctions. Le travail est exécuté dans le cadre d'une longue tâche monolithique, ce qui bloque toute réponse visuelle jusqu'à ce que les cinq fonctions soient terminées.
Dans le meilleur des cas, 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 aux ressources limitées.
Dans ce cas, saveSettings()
est déclenché par un clic de l'utilisateur. Comme le navigateur n'est pas en mesure d'afficher une réponse tant que la fonction n'est pas entièrement exécutée, cette longue tâche entraîne une interface utilisateur lente et non réactive, et sera mesurée comme une Interaction to Next Paint (INP) médiocre.
Différer manuellement l'exécution du code
Pour vous assurer que les tâches importantes pour l'utilisateur et les réponses de l'UI sont exécutées avant les tâches de priorité inférieure, vous pouvez céder la place au thread principal en interrompant brièvement votre travail pour donner au navigateur la possibilité d'exécuter des tâches plus importantes.
Une méthode utilisée par les développeurs pour décomposer les tâches en plus petites consiste à utiliser setTimeout()
. 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 le rendement, qui fonctionne mieux pour 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 pose problème en raison de l'ergonomie des développeurs. De plus, après cinq cycles de setTimeout()
imbriqués, le navigateur commencera à imposer un délai minimum de cinq millisecondes pour chaque setTimeout()
supplémentaire.
setTimeout
présente également un autre inconvénient en termes de rendement : lorsque vous cédez la place au thread principal en différant l'exécution du code 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écuteront avant votre code différé.
Une API de génération dédiée : scheduler.yield()
scheduler.yield()
est une API spécialement conçue pour céder la place 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 enchaîné à exécuter après la résolution de ce Promise
(dans une chaîne .then()
explicite ou après l'avoir await
dans une fonction asynchrone) s'exécutera ensuite dans cette future tâche.
En pratique : insérez un await scheduler.yield()
. L'exécution de la fonction s'arrête à ce moment-là et cède 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énement. Lorsque cette tâche démarre, la promesse attendue est résolue et la fonction poursuit 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();
}
saveSettings()
est désormais divisée en deux tâches. Par conséquent, la mise en page et la peinture peuvent s'exécuter entre les tâches, ce qui donne à l'utilisateur une réponse visuelle plus rapide, comme le montre l'interaction du pointeur, qui est désormais beaucoup plus courte.
Le véritable avantage de scheduler.yield()
par rapport aux autres approches de yield est que sa continuation est prioritaire. Cela signifie que si vous effectuez un yield au milieu d'une tâche, la continuation de la tâche en cours s'exécutera avant le démarrage de toute autre tâche similaire.
Cela évite que le code provenant d'autres sources de tâches n'interrompe l'ordre d'exécution de votre code, comme les tâches provenant de scripts tiers.
scheduler.yield()
, la continuation reprend là où elle s'était arrêtée avant de passer à d'autres tâches.
Compatibilité entre navigateurs
scheduler.yield()
n'est pas encore compatible avec tous les navigateurs. Une solution de remplacement est donc nécessaire.
Une solution consiste à insérer scheduler-polyfill
dans votre build, puis à utiliser scheduler.yield()
directement. Le polyfill gérera le retour à d'autres fonctions de planification des tâches afin qu'il fonctionne de manière similaire sur tous les navigateurs.
Vous pouvez également écrire une version moins sophistiquée en quelques lignes, en utilisant uniquement setTimeout
enveloppé dans une promesse comme solution de repli 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);
});
}
Les navigateurs qui ne sont pas compatibles avec scheduler.yield()
ne bénéficieront pas de la continuation prioritaire, mais ils continueront de céder la place au navigateur pour qu'il reste réactif.
Enfin, il peut arriver que votre code ne puisse pas se rendre au thread principal si sa continuation n'est pas prioritaire (par exemple, une page connue pour être occupée où le rendu risque de ne pas terminer le travail pendant un certain temps). Dans ce cas, scheduler.yield()
peut être traité comme une sorte d'amélioration progressive : cédez le contrôle dans les navigateurs où scheduler.yield()
est disponible, sinon continuez.
Pour ce faire, vous pouvez détecter les fonctionnalités et revenir à l'attente d'une seule microtâche en une seule ligne de code pratique :
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Décomposer les tâches de longue durée avec scheduler.yield()
L'avantage d'utiliser l'une de ces méthodes d'utilisation de scheduler.yield()
est que vous pouvez await
dans n'importe quelle fonction async
.
Par exemple, si vous avez un tableau de jobs à exécuter qui finissent souvent par s'ajouter à une longue tâche, vous pouvez insérer des rendements pour fractionner la tâche.
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 priorisée, mais permettra tout de même l'exécution de tâches de priorité plus élevée, comme la réponse visuelle à l'entrée utilisateur, sans avoir à attendre la fin de la liste potentiellement longue des tâches.
Toutefois, il ne s'agit pas d'une utilisation efficace du yielding. scheduler.yield()
est rapide et efficace, mais il entraîne une certaine surcharge. Si certains des jobs dans jobQueue
sont très courts, la surcharge peut rapidement s'accumuler et entraîner un temps de traitement plus long pour la suspension et la reprise que pour l'exécution du travail proprement dit.
Une approche consiste à regrouper les tâches par lots, en n'effectuant un yield entre elles que si suffisamment de temps s'est écoulé depuis le dernier yield. Un délai courant est de 50 millisecondes pour éviter que les tâches ne deviennent longues, mais il peut être ajusté en fonction d'un compromis entre la réactivité et le temps nécessaire pour terminer la file d'attente des 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();
}
}
}
Il en résulte que les tâches sont divisées pour ne jamais prendre trop de temps à s'exécuter, mais le coureur ne cède la place 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 de n'effectuer un rendu que si une entrée est en attente.
Cela permet à JavaScript de continuer s'il n'y a pas d'entrées en attente, au lieu de céder et de se retrouver à la fin de la file d'attente des tâches. Cela peut entraîner des améliorations impressionnantes des performances, comme indiqué dans l'intention d'expédition, pour les sites qui pourraient autrement ne pas revenir au thread principal.
Toutefois, depuis le lancement de cette API, notre compréhension du rendement s'est améliorée, en particulier avec l'introduction de l'INP. Nous ne recommandons plus d'utiliser cette API. Nous vous conseillons plutôt d'utiliser yield, que l'entrée soit en attente ou non, pour plusieurs raisons :
isInputPending()
peut renvoyer incorrectementfalse
même si un utilisateur a interagi dans certaines circonstances.- L'entrée n'est pas le seul cas où les tâches doivent céder. Les animations et autres mises à jour régulières de l'interface utilisateur peuvent être tout aussi importantes pour fournir une page Web réactive.
- Des API de rendement plus complètes ont été introduites depuis pour répondre aux préoccupations concernant le rendement, comme
scheduler.postTask()
etscheduler.yield()
.
Conclusion
La gestion des tâches est difficile, mais elle permet à votre page de répondre plus rapidement aux interactions des utilisateurs. Il n'existe pas de conseil unique pour gérer et hiérarchiser les tâches, mais plutôt un certain nombre de techniques différentes. Pour récapituler, voici les principaux points à prendre en compte lorsque vous gérez des tâches :
- Cédez le thread principal pour les tâches critiques destinées à l'utilisateur.
- Utilisez
scheduler.yield()
(avec un remplacement internavigateur) pour céder la place de manière ergonomique et obtenir des continuations prioritaires. - Enfin, limitez au maximum le travail effectué dans vos fonctions.
Pour en savoir plus sur scheduler.yield()
, sa planification explicite des tâches par rapport à scheduler.postTask()
et la priorisation 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 veillant à ce que les tâches moins critiques soient toujours effectuées. Cela permettra d'améliorer l'expérience utilisateur, qui sera plus réactive et plus agréable.
Merci tout particulièrement à Philip Walton pour son expertise technique dans la validation de ce guide.
Vignette provenant d'Unsplash, avec l'aimable autorisation d'Amirali Mirhashemian.