Optimiser les longues tâches

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 maintenir la rapidité des applications JavaScript se résument généralement aux suivants :

  • "Ne pas bloquer 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 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 un travail distinct effectué par le navigateur. Cela inclut l'affichage, l'analyse du code HTML et CSS, l'exécution du code JavaScript et d'autres types de tâches sur lesquelles vous n'avez peut-être pas de contrôle direct. De tout cela, le code JavaScript que vous écrivez est peut-être la plus grande source de tâches.

Visualisation d'une tâche telle qu'elle est représentée dans l'outil de profilage des performances des outils pour les développeurs Chrome. La tâche se trouve en haut d'une pile, avec un gestionnaire d'événements de clic, un appel de fonction et d'autres éléments en dessous. La tâche inclut également un travail de rendu sur le côté droit.
Tâche lancée par un gestionnaire d'événements click, affichée dans l'outil de profilage des performances des outils pour les développeurs Chrome.

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 les tâches d'analyse et de compilation de ce code JavaScript afin qu'il puisse être exécuté ultérieurement.
  • À d'autres moments de la vie de la page, les tâches sont mises en file d'attente lorsque JavaScript effectue des opérations, par exemple en répondant aux interactions via des gestionnaires d'événements, des animations basées sur JavaScript et des 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 sont exécutées 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, 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 comportant de nombreuses tâches longues, l'interface utilisateur ne répond pas, voire ne fonctionne pas du tout si le thread principal est bloqué pendant de très longues périodes.

Une tâche longue dans le profileur de performances des outils pour les développeurs Chrome. La partie bloquante de la tâche (supérieure à 50 millisecondes) est représentée par un motif de bandes diagonales rouges.
Une tâche longue telle qu'elle est représentée dans l'outil de profilage des performances de Chrome. Les tâches longues sont indiquées par un triangle rouge dans l'angle de la tâche, la partie bloquante de la tâche étant remplie d'un motif de bandes rouges diagonales.

Pour éviter que le thread principal ne soit bloqué trop longtemps, vous pouvez diviser une tâche longue en plusieurs tâches plus petites.

Une seule tâche longue par rapport à la même tâche divisée en tâches plus courtes. La tâche longue est représentée par un grand rectangle, tandis que la tâche fragmentée est représentée par cinq cases plus petites qui, ensemble, ont la même largeur que la tâche longue.
Visualisation d'une seule tâche longue par rapport à la même tâche divisée en cinq tâches plus courtes.

C'est important, car lorsque les tâches sont divisées, le navigateur peut répondre beaucoup plus rapidement aux tâches plus prioritaires, y compris aux interactions des utilisateurs. Ensuite, les tâches restantes s'exécutent jusqu'à leur terme, ce qui garantit que le travail que vous avez initialement mis en file d'attente est effectué.

Illustration de la façon dont la division d'une tâche peut faciliter l'interaction de l'utilisateur. En haut, une tâche longue empêche l'exécution d'un gestionnaire d'événements jusqu'à ce que la tâche soit terminée. En bas, la tâche fragmentée permet au gestionnaire d'événements de s'exécuter plus tôt qu'il ne l'aurait fait autrement.
Visualisation de ce qui se passe pour les interactions lorsque les tâches sont trop longues et que le navigateur ne peut pas répondre assez rapidement aux interactions, par rapport à ce qui se passe lorsque les tâches plus longues sont divisées en tâches plus petites.

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, 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 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 un indicateur de chargement, envoyer des données au backend de l'application, mettre à jour l'interface utilisateur et envoyer des données analytiques.

Conceptuellement, 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. En divisant le travail de cette manière, il est plus facile de naviguer dans les projets et de les gérer.

Toutefois, un problème potentiel 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 s'exécuteront en une seule tâche.

Fonction saveSettings telle qu'elle est représentée dans l'outil de profilage des performances de Chrome. Bien que la fonction de niveau supérieur appelle cinq autres fonctions, tout le travail s'effectue dans une seule tâche longue, de sorte que le résultat visible par l'utilisateur de l'exécution de la fonction n'est pas visible tant que toutes les fonctions ne sont pas terminées.
Une seule fonction saveSettings() qui appelle cinq fonctions. Le travail est exécuté dans le cadre d'une seule tâche monolithique longue, 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 peut 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 ne peut pas afficher de réponse tant que l'exécution de la fonction n'est pas terminée, cette tâche longue entraîne une UI lente et qui ne répond pas, ce qui sera mesuré comme une mauvaise métrique Interaction to Next Paint (INP).

Différer manuellement l'exécution du code

Pour vous assurer que les tâches importantes visibles par 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 le thread principal en interrompant brièvement votre travail pour permettre au navigateur d'exécuter des tâches plus importantes.

Pour diviser les tâches en tâches plus petites, les développeurs ont notamment utilisé 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 yielding, qui fonctionne mieux pour une série de fonctions qui doivent s'exécuter de manière séquentielle.

Cependant, votre code n'est pas toujours organisé de cette façon. 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. Après cinq cycles de setTimeout() imbriqués, le navigateur commencera à imposer un délai minimal de 5 millisecondes pour chaque setTimeout() supplémentaire.

setTimeout présente un autre inconvénient en ce qui concerne le rendement : lorsque vous cédez 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 seront exécutées avant votre code différé.

Une API de rendement dédiée : scheduler.yield()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: 142.
  • Safari: not supported.

Source

scheduler.yield() est une API spécialement conçue pour céder la priorité au thread principal dans le navigateur.

Il ne s'agit pas d'une syntaxe au niveau du langage ni d'une construction spéciale. scheduler.yield() est simplement une fonction qui renvoie un Promise qui sera résolu dans une tâche ultérieure. Tout code enchaîné pour s'exécuter après la résolution de ce Promise (soit dans une chaîne .then() explicite, soit après l'avoir await dans une fonction asynchrone) s'exécutera ensuite dans cette tâche future.

En pratique, insérez un await scheduler.yield() pour que la fonction suspende l'exécution à ce stade 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énements. Lorsque cette tâche démarre, la promesse attendue est résolue et la fonction reprend 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();
}
La fonction saveSettings, telle qu'elle est représentée dans l'outil de profilage des performances de Chrome, est désormais divisée en deux tâches. La première tâche appelle deux fonctions, puis génère un résultat, ce qui permet d'effectuer la mise en page et l'affichage, et de fournir une réponse visible à l'utilisateur. Par conséquent, l'événement de clic se termine beaucoup plus rapidement, en 64 millisecondes. La deuxième tâche appelle les trois dernières fonctions.
L'exécution de la fonction saveSettings() est désormais répartie sur deux tâches. Par conséquent, la mise en page et l'affichage 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, désormais beaucoup plus courte.

Le véritable avantage de scheduler.yield() par rapport aux autres approches de rendement est que sa continuation est prioritaire. Cela signifie que si vous cédez au milieu d'une tâche, la continuation de la tâche actuelle s'exécutera avant que d'autres tâches similaires ne soient lancées.

Cela évite que le code d'autres sources de tâches n'interrompe l'ordre d'exécution de votre code, comme les tâches de scripts tiers.

Trois diagrammes illustrant des tâches sans rendement, avec rendement, et avec rendement et continuation. Sans rendement, il y a des tâches longues. Avec le yield, il y a plus de tâches, qui sont plus courtes, mais qui peuvent être interrompues par d'autres tâches non liées. Avec le rendement et la continuation, il y a plus de tâches plus courtes, mais leur ordre d'exécution est préservé.
Lorsque vous utilisez scheduler.yield(), la continuation reprend là où elle s'était arrêtée avant de passer à d'autres tâches.

Compatibilité avec plusieurs navigateurs

scheduler.yield() n'est pas encore compatible avec tous les navigateurs. Une solution de remplacement est donc nécessaire.

Une solution consiste à déposer le scheduler-polyfill dans votre build, puis à utiliser directement scheduler.yield(). Le polyfill gérera le remplacement par d'autres fonctions de planification des tâches afin qu'il fonctionne de la même manière sur tous les navigateurs.

Vous pouvez également écrire une version moins sophistiquée en quelques lignes, en utilisant uniquement 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);
  });
}

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 priorité pour que le navigateur reste réactif.

Enfin, il peut arriver que votre code ne puisse pas céder la place au thread principal si sa continuation n'est pas prioritaire (par exemple, une page connue pour être occupée, où le fait de céder la place 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éder le contrôle dans les navigateurs où scheduler.yield() est disponible, sinon continuer.

Pour ce faire, vous pouvez détecter les fonctionnalités et attendre une seule microtâche dans une ligne de code pratique :

// 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 d'utiliser l'une de ces méthodes pour utiliser scheduler.yield() est que vous pouvez l'await dans n'importe quelle fonction async.

Par exemple, si vous avez un tableau de tâches à exécuter qui finissent souvent par s'ajouter à une tâche longue, vous pouvez insérer des rendements pour diviser la tâche.

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

    // Yield to the main thread:
    await yieldToMain();
  }
}

La continuation de runJobs() sera prioritaire, mais permettra toujours d'exécuter des tâches de priorité plus élevée, comme la réponse visuelle aux entrées utilisateur, sans avoir à attendre la fin de la liste de tâches potentiellement longue.

Toutefois, il ne s'agit pas d'une utilisation efficace de la suspension. scheduler.yield() est rapide et efficace, mais il entraîne une certaine surcharge. Si certains des jobs de jobQueue sont très courts, l'overhead pourrait rapidement s'accumuler et dépasser le temps consacré à l'exécution du travail réel.

Une approche consiste à regrouper les tâches par lot, en ne cédant la place entre elles que si un certain temps s'est écoulé depuis la dernière interruption. Un délai courant est de 50 millisecondes pour essayer d'éviter que les tâches ne deviennent des tâches longues, mais il peut être ajusté en fonction de la réactivité et du 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();
    }
  }
}

Les tâches sont donc divisées pour ne jamais prendre trop de temps à s'exécuter, mais l'exécuteur ne cède la place au thread principal que toutes les 50 millisecondes environ.

Série de fonctions de job, affichées dans le panneau "Performances" des outils pour les développeurs Chrome, dont l'exécution est répartie sur plusieurs tâches
Jobs regroupés en plusieurs tâches.

Ne pas utiliser isInputPending()

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

L'API isInputPending() permet de vérifier si un utilisateur a tenté d'interagir avec une page et de ne céder 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 à 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 de livraison, pour les sites qui, autrement, ne renverraient pas 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 recommandons plutôt de céder la priorité qu'une entrée soit en attente ou non pour plusieurs raisons :

  • isInputPending() peut renvoyer false 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 céder le contrôle. 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 depuis été introduites pour répondre aux problèmes de rendement, telles que scheduler.postTask() et scheduler.yield().

Conclusion

La gestion des tâches est difficile, mais elle permet de s'assurer que votre page répond plus rapidement aux interactions des utilisateurs. Il n'existe pas de conseil unique pour gérer et prioriser les tâches, mais plutôt un certain nombre de techniques différentes. Pour rappel, voici les principaux éléments à prendre en compte lors de la gestion des tâches :

  • Cédez le thread principal aux tâches critiques visibles par l'utilisateur.
  • Utilisez scheduler.yield() (avec un remplacement multi-navigateur) pour générer de manière ergonomique et obtenir des continuations prioritaires.
  • Enfin, effectuez le moins de travail possible dans vos fonctions.

Pour en savoir plus sur scheduler.yield(), son équivalent de planification de tâches explicite scheduler.postTask() 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 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.

Nous remercions tout particulièrement Philip Walton pour la validation technique de ce guide.