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 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.

Visualisation d'une tâche telle qu'elle apparaît dans le profileur de 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.
Une tâche démarrée par un gestionnaire d'événements 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 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.

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, comme illustré dans l'outil de profilage des performances de Chrome. Les tâches longues sont indiquées par un triangle rouge dans le coin de la tâche, et la partie bloquante de la tâche est remplie d'un motif de rayures 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 un grand rectangle, tandis que la tâche segmentée est composée de cinq petites cases qui ont collectivement la même largeur que la tâche longue.
Visualisation d'une tâche longue par rapport à la même tâche divisée en cinq tâches plus courtes.

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é.

Illustration de la façon dont la division d'une tâche peut faciliter l'interaction utilisateur. En haut, une tâche longue empêche l'exécution d'un gestionnaire d'événements jusqu'à ce qu'elle soit terminée. En bas, la tâche segmenté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 lorsque les tâches sont trop longues et que le navigateur ne peut pas y répondre assez rapidement, 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. 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.

Fonction saveSettings telle qu'elle apparaît dans le profileur de performances de Chrome. Bien que la fonction de niveau supérieur appelle cinq autres fonctions, tout le travail se déroule dans une longue tâche qui fait que le résultat visible par l'utilisateur de l'exécution de la fonction n'est pas visible tant que toutes les tâches ne sont pas terminées.
Une seule fonction 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, 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()

Browser Support

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

Source

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();
}
La fonction saveSettings, telle qu'elle apparaît dans le profileur de performances de Chrome, est désormais divisée en deux tâches. La première tâche appelle deux fonctions, puis génère une réponse, ce qui permet à la mise en page et à la peinture de se produire et de fournir une réponse visible à l'utilisateur. Par conséquent, l'événement de clic se termine en 64 millisecondes, ce qui est beaucoup plus rapide. 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 la peinture peuvent s'exécuter entre les tâches, ce qui offre à l'utilisateur une réponse visuelle plus rapide, comme le montre l'interaction du pointeur désormais beaucoup plus courte.

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.

Trois schémas illustrant des tâches sans cession, avec cession et avec cession et continuation. Sans céder, il y a des tâches longues. Avec le versement, il y a plus de tâches plus courtes, mais elles peuvent être interrompues par d'autres tâches sans rapport. Avec la cession et la continuation, un plus grand nombre de tâches sont 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é 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.

Série de fonctions de travail, affichée dans le panneau des performances de Chrome DevTools, dont l'exécution est répartie sur plusieurs tâches
Jobs groupé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 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 incorrecte false, 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() et scheduler.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.