Optimiser les longues tâches

Les conseils les plus courants pour rendre vos applications JavaScript plus rapides sont les suivants : "Ne pas bloquer le thread principal" et "Diviser vos longues tâches". Cette page explique ce que signifie ce conseil et pourquoi il est important d'optimiser les tâches en JavaScript.

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

Une tâche désigne tout travail discret effectué par le navigateur. Cela inclut l'affichage, l'analyse de 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 de contrôle direct. Le code JavaScript de vos pages est l'une des principales sources de tâches du navigateur.

Capture d'écran d'une tâche dans le panneau 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. Cette tâche inclut également des opérations 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 ont un impact sur les performances de plusieurs manières. Par exemple, lorsqu'un navigateur télécharge un fichier JavaScript au démarrage, il met en file d'attente des tâches pour analyser et compiler ce code JavaScript afin qu'il puisse être exécuté. Plus tard dans le cycle de vie de la page, d'autres tâches commencent lorsque votre code JavaScript fonctionne, par exemple la génération d'interactions via des gestionnaires d'événements, des animations basées sur JavaScript et une activité en arrière-plan telle que la collecte d'analyses. Toutes ces opérations se produisent sur le thread principal, à l'exception des nœuds de calcul Web et des API similaires.

Quel est 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 tâche à la fois. Toute tâche qui prend plus de 50 millisecondes est considérée comme une tâche longue. Si l'utilisateur tente d'interagir avec la page lors d'une tâche longue ou d'une mise à jour du rendu, le navigateur doit attendre pour gérer cette interaction, ce qui entraîne une latence.

Une longue tâche dans le Profileur de performances des outils pour les développeurs Chrome. La partie bloquante de la tâche (pour une durée supérieure à 50 millisecondes) est marquée par des bandes diagonales rouges.
Longue tâche affichée 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, la partie bloquante de la tâche étant remplie d'un motif de bandes rouges diagonales.

Pour éviter cela, divisez chaque tâche longue en tâches plus petites dont l'exécution prend chacune moins de temps. C'est ce qu'on appelle la décomposition de longues tâches.

Une seule tâche longue contre la même tâche divisée en tâches plus courtes. La tâche longue est un grand rectangle et la tâche fragmentée est composée de cinq zones plus petites dont la longueur s'ajoute à la longueur de 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.

La division des tâches donne au navigateur plus de possibilités de répondre entre d'autres tâches aux tâches prioritaires, y compris les interactions utilisateur. Cela permet aux interactions d'avoir lieu beaucoup plus rapidement ; sinon, un utilisateur aurait pu remarquer un décalage pendant que le navigateur attendait l'achèvement d'une longue tâche.

La division d'une tâche peut faciliter les interactions des utilisateurs. 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 qu'il ne l'aurait fait autrement.
Lorsque les tâches sont trop longues, le navigateur ne peut pas répondre assez rapidement aux interactions. Décomposer les tâches permet d'effectuer ces interactions plus rapidement.

Stratégies de gestion des tâches

JavaScript traite chaque fonction comme une tâche unique, car il utilise un modèle d'exécution de tâche d'exécution jusqu'à la fin. Cela signifie qu'une fonction qui appelle plusieurs autres fonctions, comme dans l'exemple suivant, doit s'exécuter jusqu'à ce que toutes les fonctions appelées soient terminées, ce qui ralentit le navigateur:

function saveSettings () { //This is a long task.
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}
Fonction saveSettings affiché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.

Si votre code contient des fonctions qui appellent plusieurs méthodes, divisez-le en plusieurs fonctions. Cela offre non seulement au navigateur davantage de possibilités de répondre à l'interaction, mais cela facilite également la lecture, la gestion et l'écriture de votre code pour les tests. Les sections suivantes présentent certaines stratégies permettant de diviser les fonctions longues et de hiérarchiser les tâches qui les composent.

Reporter manuellement l'exécution du code

Vous pouvez reporter l'exécution de certaines tâches en transmettant la fonction concernée à setTimeout(). Cela fonctionne 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 mieux pour une série de fonctions qui doivent s'exécuter dans l'ordre. Un code organisé différemment nécessite une approche différente. L'exemple suivant est une fonction qui traite une grande quantité de données à l'aide d'une boucle. Plus l'ensemble de données est grand, plus cela prend du temps, et il n'y a pas nécessairement un bon endroit dans la boucle pour placer un setTimeout():

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

Heureusement, il existe quelques autres API qui vous permettent de reporter l'exécution du code dans une tâche ultérieure. Nous vous recommandons d'utiliser postMessage() pour bénéficier de délais d'inactivité plus rapides.

Vous pouvez également répartir le travail à l'aide de requestIdleCallback(), mais cela ne planifie les tâches avec la priorité la plus basse que pendant l'inactivité du navigateur. Ainsi, si le thread principal est particulièrement occupé, les tâches planifiées avec requestIdleCallback() risquent de ne jamais s'exécuter.

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

Pour vous assurer que les tâches importantes destinées aux utilisateurs sont effectuées avant les tâches de priorité inférieure, abandonnez 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.

La méthode la plus claire consiste à utiliser 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 étape si vous utilisez await la fonction yieldToMain() après chaque appel de fonction. Cela divise efficacement votre longue tâche en plusieurs tâches:

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

Important: Vous n'avez pas besoin de renvoyer après chaque appel de fonction. Par exemple, si vous exécutez deux fonctions qui entraînent des mises à jour critiques de l'interface utilisateur, vous ne souhaitez probablement pas laisser passer entre elles. Si vous le pouvez, laissez ce travail s'exécuter en premier, puis envisagez de céder entre des fonctions qui s'exécutent en arrière-plan ou des tâches moins critiques que l'utilisateur ne voit pas.

La même fonction saveSettings dans le Profileur de performances de Chrome, désormais avec le rendement.
    La tâche 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.

Une API de planification dédiée

Les API mentionnées jusqu'à présent peuvent vous aider à répartir les tâches, mais elles présentent un inconvénient important: 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 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 pour hiérarchiser les tâches. Toutefois, les scripts tiers n'utilisent pas votre planificateur. Dans ce cas, vous ne pouvez pas vraiment prioriser le travail. Vous pouvez uniquement le diviser ou céder aux interactions de l'utilisateur.

Navigateurs pris en charge

  • 94
  • 94
  • x

Source

L'API du programmeur offre la fonction postTask(), qui permet une planification plus précise des tâches et peut aider le navigateur à hiérarchiser les tâches afin que les tâches à faible priorité soient affectées au thread principal. postTask() utilise des promesses et accepte un paramètre priority.

L'API postTask() propose trois priorités:

  • 'background' pour les tâches dont la priorité est la plus faible.
  • 'user-visible' pour les tâches à priorité moyenne. Il s'agit de la valeur par défaut si aucun élément priority n'est défini.
  • 'user-blocking' pour les tâches critiques devant s'exécuter avec une priorité élevée.

L'exemple de code suivant utilise l'API postTask() pour exécuter trois tâches avec la priorité la plus élevée possible et les deux tâches restantes à la priorité la plus basse:

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 être prises en compte.

Fonction saveSettings affichée dans le Profileur de performances de Chrome, mais utilisant postTask. postTask divise chaque fonction "saveSettings" exécutée et les priorise afin qu'une interaction utilisateur puisse s'exécuter sans être bloquée.
Lorsque saveSettings() s'exécute, la fonction planifie les appels de fonction individuels à 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.

Vous pouvez également instancier différents objets TaskController qui partagent 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 continuation à l'aide de la prochaine API scheduler.yield()

Important: Pour obtenir une explication plus détaillée de scheduler.yield(), consultez son essai d'origine (c'est-à-dire sa phase d'évaluation terminée), ainsi que son explication.

Un ajout proposé à l'API du programmeur est scheduler.yield(), une API spécialement conçue pour céder au thread principal du navigateur. Son utilisation ressemble à la fonction yieldToMain() présentée précédemment sur cette page:

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 très familier, mais au lieu d'utiliser yieldToMain(), il utilise await scheduler.yield().

Trois diagrammes montrant les tâches sans céder, avec du rendement, et avec un rendement et une continuation. Sans céder, les tâches sont longues. Avec le rendement, davantage de tâches sont plus courtes, mais peuvent être interrompues par d'autres tâches sans rapport. Avec le rendement et la continuation, l'ordre d'exécution des tâches les plus courtes est conservé.
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. Cela signifie que si vous générez un rendement au milieu d'un ensemble de tâches, les autres tâches planifiées se poursuivent dans le même ordre après le point de rendement. Cela empêche les scripts tiers de prendre le contrôle de l'ordre dans lequel votre code est exécuté.

L'utilisation de scheduler.postTask() avec priority: 'user-blocking' présente également une forte probabilité de continuation en raison de la priorité élevée de user-blocking. Vous pouvez donc l'utiliser comme alternative jusqu'à ce que scheduler.yield() devienne plus largement disponible.

L'utilisation de setTimeout() (ou de scheduler.postTask() avec priority: 'user-visible' ou sans priority explicite) planifie la tâche à la fin de la file d'attente, laissant les autres tâches en attente s'exécuter avant la poursuite.

Cédez en entrée avec 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 le 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 finir à la fin de la file d'attente de tâches. Cela peut entraîner des améliorations de performances impressionnantes, comme détaillé dans l'Intention de livraison, pour les sites qui pourraient autrement ne pas revenir au thread principal.

Toutefois, depuis le lancement de cette API, notre compréhension du rendement s'est améliorée, en particulier depuis l'introduction d'INP. Nous ne recommandons plus d'utiliser cette API. À la place, nous recommandons de générer un résultat, que l'entrée soit en attente ou non. Ce changement dans les recommandations s'explique par plusieurs raisons:

  • L'API peut renvoyer de manière incorrecte false dans les cas où un utilisateur a interagi.
  • L'entrée n'est pas le seul cas où les tâches doivent être renvoyées. Les animations et 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 telles que scheduler.postTask() et scheduler.yield() ont depuis été introduites pour résoudre les problèmes de rendement.

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 existe diverses techniques pour gérer et hiérarchiser les tâches en fonction de votre cas d'utilisation. Pour le rappeler, voici les principaux éléments à prendre en compte lors de la gestion des tâches:

  • Basculez vers le thread principal pour les tâches critiques destinées aux utilisateurs.
  • Nous vous conseillons de tester scheduler.yield().
  • Hiérarchisez les tâches avec postTask().
  • Enfin, effectuez le moins de tâches possible dans vos fonctions.

Avec un ou plusieurs de ces outils, vous devriez être en mesure de structurer le travail dans votre application de manière à donner la priorité aux besoins de l'utilisateur tout en veillant à ce qu'un travail moins important reste effectué. Cela améliore l'expérience utilisateur en la rendant plus réactive et plus agréable à utiliser.

Nous remercions tout particulièrement Philip Walton pour sa vérification technique de ce document.

Vignette Unsplash, fournie par Amirali Mirhashemian.