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 ?
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. Cette tâche 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 générer des 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 ?
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.
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 peut avoir 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.
Différer manuellement l'exécution du code
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 le renversement. 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. Le traitement de l'ensemble du tableau de données peut prendre beaucoup de temps, même si chaque itération individuelle s'exécute rapidement. Tout cela s'ajoute, et setTimeout()
n'est pas l'outil approprié pour cette tâche, du moins pas de cette manière.
Utiliser async
/await
pour créer des points de rendement
Pour vous assurer que les tâches importantes visibles par l'utilisateur sont exécutées avant les tâches de priorité inférieure, vous pouvez céder le pas au thread principal en interrompant brièvement la file d'attente de tâches afin de permettre au navigateur d'exécuter des tâches plus importantes.
Comme expliqué précédemment, setTimeout
peut être utilisé pour céder le thread principal. Toutefois, pour plus de commodité et de lisibilité, vous pouvez appeler setTimeout
dans un Promise
et transmettre sa méthode resolve
comme 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
. En partant de l'exemple précédent, vous pouvez créer un tableau de fonctions à exécuter et céder au thread principal après l'exécution de chacune d'elles:
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();
}
}
La tâche monolithique est donc désormais divisée en tâches distinctes.
Une API de planification dédiée
setTimeout
est un moyen efficace de diviser les tâches, mais il peut présenter un inconvénient: lorsque vous cédez au thread principal en différant le code à exécuter dans une tâche ultérieure, cette tâche est ajoutée à la fin de la file d'attente.
Si vous contrôlez tout le 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. En effet, vous ne pouvez pas prioriser le travail dans de tels environnements. Vous ne pouvez le faire que par blocs ou en céder explicitement la priorité aux interactions utilisateur.
L'API de planification propose la fonction postTask()
, qui permet de planifier les tâches de manière plus précise. Elle permet également d'aider le navigateur à hiérarchiser le travail afin que les tâches de faible priorité cèdent la place au thread principal. postTask()
utilise des promesses et accepte l'un des trois paramètres priority
suivants:
'background'
pour les tâches de priorité la plus faible.'user-visible'
pour les tâches à priorité moyenne. Il s'agit de la valeur par défaut si aucunpriority
n'est défini.'user-blocking'
pour les tâches critiques qui doivent s'exécuter en priorité.
Prenons l'exemple du code suivant, dans lequel l'API postTask()
est utilisée pour exécuter trois tâches avec la priorité la plus élevée possible et les deux tâches restantes avec la priorité la plus faible 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 sorte que les tâches prioritaires du navigateur (telles que les interactions utilisateur) puissent s'intercaler si nécessaire.
Il s'agit d'un exemple simplifié de l'utilisation de postTask()
. Vous pouvez instancier différents objets TaskController
pouvant partager des priorités entre les tâches, y compris la possibilité de modifier les priorités de différentes instances TaskController
si nécessaire.
Rendement intégré avec continuation à l'aide de l'API scheduler.yield()
scheduler.yield()
est une API conçue spécifiquement pour céder au thread principal du navigateur. Son utilisation ressemble à celle de la fonction yieldToMain()
présentée plus tôt 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 est largement familier, mais au lieu d'utiliser yieldToMain()
, il utilise await scheduler.yield()
.
L'avantage de scheduler.yield()
est la continuation, ce qui signifie que si vous cédez au milieu d'un ensemble de tâches, les autres tâches planifiées continueront dans le même ordre après le point de cession. Cela évite que le code des scripts tiers ne vienne interrompre l'ordre d'exécution de votre code.
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.
JavaScript peut ainsi continuer si aucune entrée n'est 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 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 générer 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.
- Priorisez les tâches avec
postTask()
. - Envisagez de tester
scheduler.yield()
. - Enfin, effectuez le moins de travail possible dans vos fonctions.
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 de vignette provenant de Unsplash, avec l'aimable autorisation d'Amirali Mirhashemian.