Optimiser les longues tâches

On vous a dit de "ne pas bloquer le thread principal" et de "diviser vos longues tâches", mais que signifient ces choses ?

Jeremy Wagner
Jeremy Wagner

Si vous lisez beaucoup d'informations sur les performances Web, voici quelques-uns des conseils pour maintenir la rapidité de vos applications JavaScript:

  • "Ne bloquez pas le thread principal."
  • « Divisez vos longues tâches. »

Qu’est-ce que cela signifie ? Il est préférable de fournir moins de code JavaScript, mais cela équivaut-il automatiquement à des interfaces utilisateur plus dynamiques tout au long du cycle de vie de la page ? Peut-être, mais peut-être pas.

Pour bien comprendre pourquoi il est important d'optimiser les tâches en JavaScript, vous devez comprendre leur rôle et la façon dont le navigateur les gère. Pour cela, il faut commencer par comprendre ce qu'est une tâche.

Qu'est-ce qu'une tâche ?

Une tâche désigne toute tâche discrète effectuée par le navigateur. Elles impliquent des tâches telles que l'affichage, l'analyse du code HTML et CSS, l'exécution du code JavaScript que vous écrivez et d'autres éléments sur lesquels vous n'avez peut-être pas le contrôle direct. De tous ces éléments, le code JavaScript que vous écrivez et déployez sur le Web est une source de tâches majeure.

Capture d'écran d'une tâche telle qu'elle est présentée dans le panneau de performances des outils pour les développeurs Chrome. La tâche se trouve en haut d'une pile, sous un gestionnaire d'événements de clic, un appel de fonction et d'autres éléments en dessous. La tâche inclut également des tâches de rendu sur la droite.
Représentation d'une tâche lancée par un gestionnaire d'événements click dans le profileur de performances des outils pour les développeurs Chrome.

Les tâches ont un impact sur les performances de plusieurs façons. Par exemple, lorsqu'un navigateur télécharge un fichier JavaScript au démarrage, il met en file d'attente des tâches afin d'analyser et de compiler ce fichier JavaScript afin de pouvoir l'exécuter. Plus tard dans le cycle de vie de la page, les tâches sont lancées lorsque votre code JavaScript fonctionne, par exemple pour générer des 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 d'analyses. À l'exception des nœuds de calcul Web et des API similaires, toutes ces opérations se produisent sur le thread principal.

Quel est le thread principal ?

Le thread principal est l'endroit où la plupart des tâches sont exécutées dans le navigateur. On l'appelle le thread principal pour une raison: c'est le seul thread où presque tout le JavaScript que vous écrivez fait son travail.

Le thread principal ne peut traiter qu'une tâche à la fois. Les tâches qui s'étendent au-delà d'un certain point (50 millisecondes pour être exactes) sont considérées comme des tâches longues. Si l'utilisateur tente d'interagir avec la page pendant l'exécution d'une longue tâche ou si une mise à jour importante du rendu doit être effectuée, le navigateur sera retardé dans le traitement de cette tâche. Cela entraîne une interaction ou une latence d'affichage.

Une longue tâche dans le Profileur de performances des outils pour les développeurs Chrome. La partie bloquante de la tâche (pendant plus de 50 millisecondes) est représentée par un motif de bandes diagonales rouges.
Tâche longue, telle que décrite dans le Profileur de performances de Chrome. Les tâches longues sont signalées par un triangle rouge dans le coin de la tâche, la partie bloquante étant remplie d'un motif de bandes rouges diagonales.

Vous devez diviser des tâches. Cela signifie prendre une seule longue tâche et la diviser en tâches plus petites dont l'exécution individuelle prend moins de temps.

Une seule tâche longue par rapport à la même tâche divisée en tâche plus courte. La tâche longue est un grand rectangle, tandis que la tâche en blocs est composée de cinq cases plus petites qui ont collectivement 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.

Cela est important, car lorsque les tâches sont dissociées, le navigateur a plus de possibilités de répondre aux tâches prioritaires, y compris les interactions des utilisateurs.

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'à la fin de la tâche. En bas, la tâche fragmentée permet au gestionnaire d'événements de s'exécuter plus tôt que prévu.
Ce schéma montre ce qu'il advient des interactions lorsque les tâches sont trop longues et que le navigateur ne peut pas répondre assez rapidement aux interactions, par rapport aux tâches plus longues 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 suite à une interaction utilisateur a dû attendre une seule longue tâche avant de pouvoir s'exécuter, ce qui retarde l'interaction. En bas, le gestionnaire d'événements peut s'exécuter plus tôt. Comme le gestionnaire d'événements a eu l'occasion de s'exécuter entre des tâches plus petites, il s'exécute plus tôt que s'il devait attendre la fin d'une tâche longue. Dans l'exemple du haut, l'utilisateur a peut-être remarqué un décalage ; dans le bas, l'interaction peut sembler instantanée.

Le problème, cependant, c'est que le conseil de"diviser vos longues tâches " et de "ne pas bloquer le thread principal" n'est pas assez précis, sauf si vous savez déjà comment faire ces choses. C'est ce que explique ce guide.

Stratégies de gestion des tâches

Un conseil courant en matière d’architecture logicielle est de diviser votre travail en fonctions plus petites. Vous bénéficiez ainsi d'une meilleure lisibilité du code et de la gestion des projets. Cela facilite également l'écriture des tests.

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

Dans cet exemple, une fonction nommée saveSettings() appelle cinq fonctions pour effectuer le travail, comme valider un formulaire, afficher une icône de chargement, envoyer des données, etc. D'un point de vue conceptuel, elle est bien conçue. Si vous devez déboguer l'une de ces fonctions, vous pouvez parcourir l'arborescence du projet pour voir à quoi sert chacune d'elles.

Toutefois, le problème 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écutent comme une seule tâche.

Fonction saveSettings telle qu'elle est présentée 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. La tâche est exécutée 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 à la durée totale de la tâche. Dans le pire des cas, l'exécution d'un plus grand nombre de ces tâches peut être un peu plus longue, en particulier sur les appareils disposant de ressources limitées. Vous trouverez ci-dessous un ensemble de stratégies que vous pouvez utiliser pour décomposer et hiérarchiser les tâches.

Reporter manuellement l'exécution du code

L'une des méthodes utilisées par les développeurs pour diviser les tâches en tâches plus petites est setTimeout(). Avec cette technique, vous transmettez la fonction à setTimeout(). Cette action 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);
}

Cela fonctionne bien si vous disposez d'une série de fonctions qui doivent s'exécuter de manière séquentielle, mais il est possible que votre code ne soit 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 si vous avez des millions d'articles.

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

L'utilisation de setTimeout() ici est problématique, car son ergonomie rend sa mise en œuvre difficile. De plus, le traitement de l'ensemble des données peut prendre beaucoup de temps, même si chaque élément peut être traité très rapidement. Tout est cumulé, et setTimeout() n'est pas l'outil adapté à la tâche, du moins pas lorsqu'il est utilisé de cette manière.

En plus de setTimeout(), d'autres API vous permettent de reporter l'exécution du code à une tâche ultérieure. L'un d'entre eux implique l'utilisation de postMessage() pour des délais d'inactivité plus rapides. Vous pouvez également répartir le travail à l'aide de requestIdleCallback(), mais attention ! requestIdleCallback() planifie les tâches avec la priorité la plus basse possible et uniquement pendant l'inactivité du navigateur. Lorsque le thread principal est encombré, les tâches planifiées avec requestIdleCallback() risquent de ne jamais s'exécuter.

Utiliser async/await pour créer des points de rendement

Dans la suite de ce guide, vous retrouverez la phrase "yield to the main thread " (rendre le fil de discussion principal). Mais qu'est-ce que cela signifie ? Pourquoi le faire ? Quand devez-vous le faire ?

Lorsque les tâches sont dissociées, le schéma de hiérarchisation interne du navigateur permet de mieux hiérarchiser les autres. Une façon de céder au thread principal consiste à utiliser une combinaison d'un Promise qui se résout par un appel à setTimeout():

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

Dans la fonction saveSettings(), vous pouvez céder au thread principal après chaque bit de travail si vous exécutez la commande await pour la fonction yieldToMain() après chaque appel de fonction:

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 maintenant divisée en tâches distinctes.

La même fonction saveSettings que celle décrite dans le Profileur de performances de Chrome, mais avec une couche de rendement. Résultat : la tâche autrefois monolithique est maintenant 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.

L'utilisation d'une approche de rendement basée sur des promesses plutôt que d'une utilisation manuelle de setTimeout() présente l'avantage d'améliorer l'ergonomie. Les points de rendement deviennent déclaratifs, et donc plus faciles à écrire, à lire et à comprendre.

Ne cédez que si nécessaire

Que se passe-t-il si vous avez un ensemble de tâches, mais que vous ne voulez céder que si l'utilisateur tente d'interagir avec la page ? C'est pour cela que isInputPending() est fait pour ça.

isInputPending() est une fonction que vous pouvez exécuter à tout moment pour déterminer si l'utilisateur tente d'interagir avec un élément de page: un appel à isInputPending() renvoie true. Sinon, elle renvoie false.

Supposons que vous ayez une file d'attente de tâches à exécuter, mais que vous ne voulez pas perturber les entrées. Ce code, qui utilise à la fois isInputPending() et notre fonction yieldToMain() personnalisée, garantit qu'une entrée n'est pas retardée lorsque l'utilisateur tente d'interagir avec la page:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

Pendant l'exécution de saveSettings(), il boucle les tâches de la file d'attente. Si isInputPending() renvoie true pendant la boucle, saveSettings() appelle yieldToMain() pour que l'entrée utilisateur puisse être traitée. Sinon, il décale la tâche suivante de la file d'attente et l'exécute en continu. Il le fera jusqu'à ce qu'il ne reste plus aucune tâche.

Représentation de la fonction saveSettings exécutée dans le Profileur de performances de Chrome. La tâche résultante bloque le thread principal jusqu'à ce que isInputPending renvoie la valeur "true". La tâche est alors renvoyée au thread principal.
saveSettings() exécute une file d'attente pour cinq tâches, mais l'utilisateur a cliqué pour ouvrir un menu pendant l'exécution de la deuxième tâche. isInputPending() cède au thread principal pour gérer l'interaction et reprend l'exécution du reste des tâches.

L'utilisation de isInputPending() avec un mécanisme de rendement est un excellent moyen d'amener le navigateur à arrêter les tâches en cours de traitement afin qu'il puisse répondre aux interactions critiques des utilisateurs. Cela peut améliorer la capacité de votre page à répondre à l'utilisateur dans de nombreuses situations lorsque de nombreuses tâches sont en cours.

Vous pouvez également utiliser isInputPending(), en particulier si vous ne souhaitez pas fournir une solution de secours pour les navigateurs non compatibles, consiste à utiliser une approche temporelle avec l'opérateur de chaînage facultatif:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

Avec cette approche, vous obtenez une solution de secours pour les navigateurs qui ne sont pas compatibles avec isInputPending(). Pour ce faire, vous appliquez une approche temporelle qui utilise (et ajuste) un délai afin que le travail soit interrompu si nécessaire, que ce soit en cédant à l'entrée utilisateur ou avant un certain moment.

Lacunes dans les API actuelles

Les API mentionnées jusqu'à présent peuvent vous aider à diviser les tâches, mais elles présentent un inconvénient majeur: lorsque vous cédez au thread principal en reportant l'exécution du code dans une tâche ultérieure, ce code est ajouté à la toute fin de la file d'attente de tâches.

Si vous contrôlez l'ensemble du code de votre page, vous pouvez créer votre propre planificateur avec la capacité de hiérarchiser les tâches, mais les scripts tiers n'utiliseront pas votre planificateur. En effet, vous n'êtes pas vraiment en mesure de prioriser le travail dans de tels environnements. Vous pouvez uniquement la fragmenter ou céder explicitement aux interactions des utilisateurs.

Heureusement, il existe une API de planification dédiée qui est en cours de développement pour résoudre ces problèmes.

API de programmeur dédiée

L'API du programmeur propose actuellement la fonction postTask() qui, au moment de la rédaction de ce document, est disponible dans les navigateurs Chromium et avec un indicateur dans Firefox. postTask() permet une planification plus précise des tâches et permet au navigateur de 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 un paramètre priority.

L'API postTask() a trois priorités que vous pouvez utiliser:

  • 'background' pour les tâches dont la priorité est 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, où l'API postTask() est utilisée pour exécuter trois tâches à la priorité la plus élevée possible et les deux tâches restantes à 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 manière à ce que les tâches prioritaires du navigateur, telles que les interactions utilisateur, puissent fonctionner à leur place.

Fonction saveSettings telle qu'elle est décrite 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 ait une chance de s'exécuter sans être bloquée.
Lorsque saveSettings() est exécuté, la fonction programme les fonctions individuelles à l'aide de postTask(). Les tâches critiques destinées à l'utilisateur sont planifiées avec 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. Cela permet aux interactions utilisateur de s'exécuter plus rapidement, car le travail est à la fois décomposé et hiérarchisé de manière appropriée.

Voici un exemple simpliste 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 de différentes instances TaskController si nécessaire.

Rendement intégré avec poursuite via scheduler.yield

Une partie proposée de l'API du programmeur est scheduler.yield, une API spécialement conçue pour céder au thread principal du navigateur et actuellement disponible pour une phase d'évaluation. Son utilisation ressemble à la fonction yieldToMain() présentée précédemment dans cet article:

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();
  }
}

Notez que le code ci-dessus est très familier, mais qu'au lieu d'utiliser yieldToMain(), vous appelez et await scheduler.yield() à la place.

Trois diagrammes représentant des tâches sans céder, sans céder, avec un rendement et une continuation. Sans céder, il y a de longues tâches. Avec le rendement, il existe plus de tâches plus courtes, mais qui peuvent être interrompues par d'autres tâches sans rapport. Avec le rendement et la continuation, il existe davantage de tâches plus courtes, mais leur ordre d'exécution est préservé.
Visualisation de l'exécution d'une tâche sans rendement, avec rendement, et avec rendement et continuation. Lorsque scheduler.yield() est utilisé, 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. Cela signifie que si vous abandonnez 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 des scripts tiers usurpent l'ordre d'exécution de votre code.

Conclusion

La gestion des tâches est un défi, mais cela permet à votre page de répondre plus rapidement aux interactions des utilisateurs. Il n'y a pas qu'un seul conseil pour gérer et hiérarchiser les tâches. Il s'agit plutôt de plusieurs techniques différentes. Pour le réitérer, voici les principaux éléments à prendre en compte lors de la gestion des tâches:

  • Concentrez-vous sur le thread principal pour les tâches critiques destinées aux utilisateurs.
  • Utilisez isInputPending() pour revenir au thread principal lorsque l'utilisateur tente d'interagir avec la page.
  • Hiérarchisez les tâches avec postTask().
  • Enfin, traitez le moins possible de travail dans vos fonctions.

Avec un ou plusieurs de ces outils, vous devriez être en mesure de structurer le travail dans 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. Vous obtiendrez ainsi une meilleure expérience utilisateur, plus réactive et plus agréable à utiliser.

Nous remercions Philip Walton pour sa vérification technique de cet article.

Image principale provenant de Unsplash, avec l'aimable autorisation d'Amirali Mirhashemian.