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 ?

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."
  • "Diviser vos longues tâches."

C'est un excellent conseil, mais que devez-vous faire pour le mettre en œuvre ? Il est bon d'envoyer moins de code JavaScript, 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 discrète exécutée par le navigateur. Cette tâche inclut le rendu, l'analyse HTML et CSS, l'exécution de 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 la droite.
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 plusieurs 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.

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 le profileur de 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 tâche unique 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é.

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 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 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 comme une seule tâche.

Fonction saveSettings telle qu'elle apparaît 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 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.

Reporter 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 pose problème 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 est cumulé, 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 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 au 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. 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();
  }
}

La tâche monolithique est donc désormais divisée en tâches distinctes.

Même fonction saveSettings que celle représentée dans l'outil de profilage des performances de Chrome, mais avec une cession. La tâche monolithique est désormais 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 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 pouvez seulement le diviser ou céder explicitement aux interactions des utilisateurs.

Navigateurs pris en charge

  • Chrome: 94
  • Edge : 94.
  • Firefox: derrière un drapeau.
  • Safari : non compatible.

Source

L'API de planification propose la fonction postTask(), qui permet de planifier les tâches de manière plus précise. Il s'agit d'un moyen 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 aucun priority 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.

Fonction saveSettings telle qu'elle apparaît dans le profileur de performances de Chrome, mais à l'aide de postTask. postTask divise chaque fonction exécutée par saveSettings et les hiérarchise de sorte qu'une interaction utilisateur puisse s'exécuter sans être bloquée.
Lorsque saveSettings() est exécuté, la fonction planifie les fonctions individuelles à l'aide de postTask(). Le travail critique visible par l'utilisateur est planifié avec une priorité élevée, tandis que le travail dont l'utilisateur n'a pas connaissance est planifié pour s'exécuter en arrière-plan. Cela permet d'exécuter les interactions utilisateur plus rapidement, car le travail est à la fois divisé et hiérarchisé de manière appropriée.

Il s'agit d'un exemple simplifié de l'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 l'API scheduler.yield()

Navigateurs pris en charge

  • Chrome : 129.
  • Edge : 129.
  • Firefox : non compatible.
  • Safari: non compatible.

Source

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

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

Navigateurs pris en charge

  • Chrome: 87
  • Edge : 87.
  • Firefox : non compatible.
  • Safari : non compatible.

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.

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 incorrecte false, même si un utilisateur a interagi.
  • L'entrée n'est pas le seul cas où les tâches doivent être générées. Les animations et les autres mises à jour régulières de l'interface utilisateur peuvent être tout aussi importantes pour proposer 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.
  • 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 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.