On vous a dit "Ne pas bloquer le thread principal" et « diviser vos longues tâches », mais que signifie faire ces choses ?
Les conseils les plus courants pour maintenir la rapidité des applications JavaScript se résument à ceux-ci:
- "Ne bloquez pas le thread principal."
- "Diviser vos longues tâches."
C'est un bon conseil, mais quel travail cela implique-t-il ? Il est recommandé d'utiliser moins de code JavaScript, mais cela équivaut-il automatiquement à 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 tâche distincte que le navigateur effectue. Ces tâches incluent l'affichage, l'analyse 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. De tout cela, le 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 plusieurs manières:
- Lorsqu'un navigateur télécharge un fichier JavaScript au démarrage, il met des tâches en file d'attente afin d'analyser et de compiler ce fichier JavaScript afin de pouvoir l'exécuter ultérieurement.
- À d'autres moments au cours de la durée de vie de la page, des tâches sont mises en file d'attente lorsque JavaScript fonctionne, par exemple en générant des interactions via des gestionnaires d'événements, des animations basées sur JavaScript et une activité en arrière-plan telle que la collecte de données analytiques.
À l'exception des web workers et des API similaires, toutes ces opérations se produisent 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 longue tâche. 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 lorsqu'une tâche, quelle que soit sa durée, est en cours d'exécution, mais cela n'est pas visible pour 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 qu'il y a de nombreuses longues tâches, l'interface utilisateur ne répond pas, voire ne fonctionne même pas si le thread principal est bloqué pendant de très longues périodes.
Pour éviter que le thread principal soit bloqué trop longtemps, vous pouvez diviser une tâche longue en plusieurs tâches plus petites.
Ce point est important, car lorsque les tâches sont divisées, le navigateur peut réagir bien plus rapidement aux tâches prioritaires, y compris aux interactions des utilisateurs. Les tâches restantes sont ensuite exécutées jusqu'à ce qu'elles soient terminées.
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 retard. En bas, le gestionnaire d'événements peut commencer à s'exécuter plus tôt, et l'interaction peut avoir eu l'impression instantanée.
Maintenant que vous savez pourquoi il est important de répartir les tâches, vous pouvez apprendre à le faire en JavaScript.
Stratégies de gestion des tâches
Un conseil courant en architecture logicielle est de 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.
Sur le plan 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 à quoi sert chaque fonction. Une telle répartition du travail facilite la navigation et la gestion des projets.
Toutefois, un problème potentiel ici est que 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 comme une seule tâche.
Dans le meilleur des cas, même une seule de ces fonctions peut contribuer à 50 millisecondes ou plus de la durée totale de la tâche. Dans le pire des cas, la plupart de ces tâches peuvent s'exécuter beaucoup plus longtemps, en particulier sur les appareils dont les ressources sont limitées.
Reporter manuellement l'exécution du code
setTimeout()
est une méthode que les développeurs ont utilisée pour diviser 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 que l'on appelle le rendement. Il convient mieux à une série de fonctions devant s'exécuter de manière séquentielle.
Cependant, votre code ne sera peut-être 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 du développeur. Le traitement de l'ensemble des données peut prendre beaucoup de temps, même si chaque itération s'exécute rapidement. Tout s'accumule, et setTimeout()
n'est pas l'outil adapté à la tâche, du moins pas lorsqu'il est utilisé de cette façon.
Utiliser async
/await
pour créer des points de rendement
Pour s'assurer que les tâches importantes des utilisateurs sont effectuées avant les tâches de priorité inférieure, vous pouvez dériver le thread principal en interrompant brièvement la file d'attente de tâches pour donner à les possibilités offertes par le navigateur pour exécuter des tâches plus importantes.
Comme expliqué précédemment, setTimeout
peut être utilisé pour céder au thread principal. Pour plus de commodité et une meilleure lisibilité, vous pouvez cependant appeler setTimeout
dans un Promise
et transmettre sa méthode resolve
en tant que rappel.
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
L'avantage de la fonction yieldToMain()
est que vous pouvez la await
dans n'importe quelle fonction async
. Sur la base de l'exemple précédent, vous pouvez créer un tableau de fonctions à exécuter et renvoyer au thread principal après chaque exécution:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread:
await yieldToMain();
}
}
Résultat : la tâche autrefois monolithique est désormais divisée en tâches distinctes.
API de programmeur dédié
setTimeout
est un moyen efficace de diviser des tâches, mais il peut présenter un inconvénient: lorsque vous cèdez au thread principal en reportant le code pour qu'il s'exécute dans une tâche ultérieure, cette tâche est ajoutée à la fin de la file d'attente.
Si vous contrôlez l'ensemble du code de votre page, vous pouvez créer votre propre planificateur avec la possibilité de hiérarchiser les tâches. Toutefois, les scripts tiers n'utiliseront pas votre planificateur. Dans les faits, vous ne pouvez pas prioriser le travail dans ces environnements. Vous pouvez seulement le diviser ou céder explicitement aux interactions des utilisateurs.
L'API Scheduler propose la fonction postTask()
, qui permet de planifier plus précisément les tâches. C'est un moyen d'aider le navigateur à hiérarchiser les tâches afin que les tâches à faible priorité soient transférées au thread principal. postTask()
utilise des promesses et accepte l'un des trois paramètres priority
:
'background'
pour les tâches ayant la priorité la plus faible.'user-visible'
pour les tâches de priorité moyenne. Il s'agit de la valeur par défaut si aucunpriority
n'est défini.'user-blocking'
pour les tâches critiques devant être exécutées à une priorité élevée.
Prenons l'exemple du code suivant, dans lequel l'API postTask()
permet d'exécuter trois tâches avec la priorité la plus élevée possible et les deux autres tâches avec la priorité la plus basse possible.
function saveSettings () {
// Validate the form at high priority
scheduler.postTask(validateForm, {priority: 'user-blocking'});
// Show the spinner at high priority:
scheduler.postTask(showSpinner, {priority: 'user-blocking'});
// Update the database in the background:
scheduler.postTask(saveToDatabase, {priority: 'background'});
// Update the user interface at high priority:
scheduler.postTask(updateUI, {priority: 'user-blocking'});
// Send analytics data in the background:
scheduler.postTask(sendAnalytics, {priority: 'background'});
};
Ici, la priorité des tâches est planifiée de telle sorte que les tâches priorisées par le navigateur, telles que les interactions utilisateur, puissent s'effectuer en fonction des besoins.
Voici un exemple simplifié d'utilisation de postTask()
. Il est possible d'instancier différents objets TaskController
qui peuvent partager des priorités entre les tâches, y compris la possibilité de modifier les priorités pour différentes instances TaskController
si nécessaire.
Rendement intégré avec continuation à l'aide de la prochaine API scheduler.yield()
scheduler.yield()
est un ajout proposé à l'API Scheduler. Il s'agit d'une API spécifiquement conçue pour générer le thread principal dans le navigateur. Son utilisation ressemble à la fonction yieldToMain()
présentée précédemment dans ce guide:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread with the scheduler
// API's own yielding mechanism:
await scheduler.yield();
}
}
Ce code vous est familier, mais au lieu d'utiliser yieldToMain()
, il utilise
await scheduler.yield()
L'avantage de scheduler.yield()
est la continuité, ce qui signifie que si vous cédez le processus au milieu d'un ensemble de tâches, les autres tâches planifiées se poursuivront dans le même ordre après le point de rendement. Cela évite que le code de scripts tiers n'interrompe l'ordre d'exécution de votre code.
L'utilisation de scheduler.postTask()
avec priority: 'user-blocking'
a également une forte probabilité de continuation en raison de la priorité user-blocking
élevée. Cette approche peut donc être utilisée comme alternative en attendant.
L'utilisation de setTimeout()
(ou de scheduler.postTask()
avec priority: 'user-visibile'
ou sans priority
explicite) planifie la tâche à la fin de la file d'attente et permet ainsi aux autres tâches en attente de s'exécuter avant la continuation.
Ne pas utiliser isInputPending()
Navigateurs pris en charge
- 87
- 87
- x
- x
L'API isInputPending()
permet de vérifier si un utilisateur a tenté d'interagir avec une page et de ne renvoyer une réponse 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 finir en arrière de la file d'attente de tâches. Cela peut entraîner des améliorations de performances impressionnantes, comme indiqué dans l'article Intention de livraison, pour les sites qui, autrement, n'auraient pas pu céder au thread principal.
Cependant, depuis le lancement de cette API, nous comprenons mieux le rendement, en particulier avec l'introduction d'INP. Nous vous recommandons de ne plus utiliser cette API. À la place, nous vous recommandons d'utiliser le rendement , que l'entrée soit en attente ou non pour plusieurs raisons:
isInputPending()
peut renvoyerfalse
de manière incorrecte, même si un utilisateur a interagi dans certaines circonstances.- L'entrée n'est pas le seul cas où les tâches doivent être générées. 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.
- Depuis, des API de rendement plus complètes ont été introduites pour répondre aux problèmes de rendement, comme
scheduler.postTask()
etscheduler.yield()
.
Conclusion
La gestion des tâches est difficile, mais cela permet de s'assurer que votre page répond plus rapidement aux interactions des utilisateurs. Il n'y a pas un seul conseil pour gérer et hiérarchiser les tâches, mais plutôt un certain nombre de techniques différentes. Pour rappel, voici les principaux éléments dont vous devrez tenir compte lors de la gestion des tâches:
- Rendez-vous au thread principal pour les tâches critiques des utilisateurs.
- Hiérarchisez les tâches avec
postTask()
. - Essayez
scheduler.yield()
. - Enfin, travaillez le moins possible dans vos fonctions.
Avec un ou plusieurs de ces outils, vous devriez être en mesure de structurer le travail de votre application afin qu'elle donne la priorité aux besoins de l'utilisateur, tout en vous assurant que les tâches moins critiques sont toujours effectuées. Cela va créer une meilleure expérience utilisateur, plus réactive et plus agréable à utiliser.
Nous remercions Philip Walton pour sa vérification technique de ce guide.
Vignette extraite d'Unsplash, avec l'aimable autorisation d'Amirali Mirhashemian.