Optimiser les longues tâches

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.

Visualisation d'une tâche telle que décrite dans le professionnel des performances des outils de développement de 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. Elle inclut également des tâches de rendu sur la droite.
Tâche lancé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 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.

Une longue tâche dans le profileur de performances des outils de développement de Chrome. La partie bloquante de la tâche (plus de 50 millisecondes) est représentée par un motif de bandes diagonales rouges.
Une tâche longue décrite dans le Profileur de performances de Chrome. Les longues tâches sont indiquées par un triangle rouge dans l'angle de la tâche, et la partie bloquante est remplie par un motif de bandes rouges diagonales.

Pour éviter que le thread principal 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 une tâche plus courte. La tâche longue est un grand rectangle, tandis que la tâche fragmentée est composée de cinq boîtes plus petites qui ont collectivement la même largeur que la tâche longue.
Représentation visuelle d'une seule longue tâche par rapport à cette même tâche, divisée en cinq tâches plus courtes.

Ce point est important, car lorsque des tâches sont séparé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.

Représentation de la façon dont la division d'une tâche peut faciliter une interaction utilisateur. En haut, une longue tâche empêche un gestionnaire d'événements de s'exécuter 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.
Représentation visuelle de ce qu'il advient des interactions lorsque les tâches sont trop longues et que le navigateur ne peut pas répondre suffisamment rapidement aux interactions, ou lorsque des 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 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 diviser 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.

La fonction saveSettings, telle qu'elle est décrite dans le Profileur de performances de Chrome. Alors que la fonction de niveau supérieur appelle cinq autres fonctions, tout le travail a lieu dans une longue tâche qui bloque le thread principal.
Une seule fonction saveSettings() qui appelle cinq fonctions. Le travail est exécuté dans le cadre d'une longue tâche monolithique.

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 vous assurer que les tâches importantes visibles par l'utilisateur se produisent avant les tâches de priorité inférieure, vous pouvez générer des résultats sur le thread principal en interrompant brièvement la file d'attente de tâches pour permettre au navigateur d'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.

Même fonction saveSettings que celle décrite dans le Profileur de performances de Chrome, mais avec un rendement. Il en résulte une tâche autrefois monolithique divisée en cinq tâches distinctes, une pour chaque fonction.
La fonction saveSettings() exécute désormais ses fonctions enfants en tant que 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.

Navigateurs pris en charge

  • 94
  • 94
  • x

Source

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

La fonction "saveSettings" telle qu'elle est illustrée dans le profileur de performances de Chrome, mais avec "postTask. postTask" divise chaque fonction "saveSettings" exécutée et les priorise de sorte qu'une interaction de l'utilisateur puisse s'exécuter sans être bloquée.
Lorsque saveSettings() est exécuté, la fonction planifie les fonctions individuelles à l'aide de postTask(). Les tâches critiques visibles par l'utilisateur sont planifiées à une priorité élevée, tandis que celles dont l'utilisateur n'a pas connaissance sont planifiées pour s'exécuter en arrière-plan. Les interactions des utilisateurs s'exécutent plus rapidement, car le travail est à la fois divisé et hiérarchisé de manière appropriée.

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

Trois diagrammes représentant des tâches sans rendement, avec rendement et continuation Sans rendement, les tâches sont longues. Avec le rendement, certaines tâches sont plus courtes, mais peuvent être interrompues par d'autres tâches sans rapport. Avec le rendement et la continuation, davantage de tâches sont plus courtes, mais leur ordre d'exécution est préservé.
Lorsque vous utilisez scheduler.yield(), l'exécution de la tâche reprend là où elle s'était arrêtée, même après le point de rendement.

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 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 ê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() et scheduler.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.