Accueil / Articles

Maintenir la page réactive : quand déplacer le travail du CPU vers un Web Worker

Découvrez pourquoi les boucles lourdes figent l’interface utilisateur du navigateur, comment le modèle de messages des Web Workers permet de décharger ce travail, et comment déterminer si une tâche mérite réellement l’utilisation d’un Worker.

Éditeur : REACTAPP.TOP12 min de lecture2703 mots

Sources: JavaScript Workers: How to Run Code Without Freezing Your UI

Lorsqu’un utilisateur appuie sur un bouton, l’étiquette passe en « Processing… », puis plus rien ne répond : les animations s’arrêtent, la navigation devient impossible et les clics ultérieurs sont ignorés. La page n’a pas plané ; un morceau de JavaScript monopolise simplement le thread principal. Ce guide explique pourquoi cela se produit, présente le modèle de base des Web Workers pour exécuter des calculs gourmands ailleurs, et vous offre une méthode pratique pour déterminer quand un Worker constitue la solution adéquate et quand il ne fait qu’ajouter des éléments complexes.

Pourquoi une tâche longue bloque tout

Le navigateur dans son ensemble fait bien plus que d’exécuter vos scripts, mais le JavaScript appartenant à la page s’exécute sur un seul thread principal. Si vous souhaitez comprendre en détail comment ce thread s’intègre dans la conception multi-processus du navigateur, consultez JavaScript et son thread unique face au moteur multi-processus du navigateur. La conséquence pratique est simple : ce thread peut devenir un goulot d’étranglement.

Prenons une fonction qui additionne tous les entiers inférieurs à un milliard. Il n’y a rien de mal avec elle, et le moteur l’exécutera volontiers jusqu’au bout :

function calculate() {
  let total = 0;
  for (let i = 0; i < 1_000_000_000; i++) {
      total += i;
    }
    return total;
  }
calculate();

Le problème réside dans ce qui se passe pendant ce temps. Tant que la boucle est en exécution, le thread principal ne peut pas s’occuper d’autre chose qui dépend de lui. Les gestionnaires de clics, le traitement des entrées ainsi que la majeure partie du travail d’actualisation de l’affichage attendent tous en file d’attente ce même thread. C’est précisément cette file d’attente que l’utilisateur perçoit comme une interface figée.

La chaîne d’événements se présente comme suit :

User clicks button
       ↓
JavaScript starts heavy calculation
       ↓
Main thread stays busy
       ↓
UI work has to wait
       ↓
Page feels frozen

Rien n’est cassé ici. Votre code retient le thread trop longtemps, et le navigateur agit de manière équitable en lui permettant de terminer. C’est aussi pour cette raison que du code qui semblait parfaitement fonctionnel en développement peut devenir un véritable problème de performance en production : la fonction n’a pas changé, mais l’ensemble des données ou la quantité de calculs à effectuer a augmenté.

Fournir un contexte d’exécution distinct pour les tâches lourdes

Un Web Worker est un contexte d’exécution JavaScript distinct qui fonctionne de manière indépendante du script principal de la page. Au lieu d’accumuler tout le travail sur un seul thread :

MAIN THREAD
     │
     ├── UI
     ├── Events
     ├── JavaScript
     └── Heavy calculation  ← problem

on divise les responsabilités de sorte que la page gère l’interface utilisateur, les entrées et la logique de la page, tandis que le Worker s’occupe des calculs coûteux :

MAIN THREAD                  WORKER
     │                         │
     ├── UI                    ├── Heavy calculation
     ├── User input            ├── Data processing
     └── Page logic            └── Result
             │                  │
             └── messages ─────┘

La ligne reliant les deux parties dans ce diagramme est le détail clé. Un Worker ne partage pas les variables ou objets de votre page. Les deux parties échangent des messages, et cette contrainte influence tout le reste concernant l’utilisation des Workers.

Un premier Worker, étape par étape

Voici la boucle de sommation vue précédemment, déplacée hors du thread principal. Le script de la page crée le Worker à partir d’un fichier séparé, lui envoie la limite supérieure et enregistre un gestionnaire pour recevoir la réponse :

const worker = new Worker("worker.js");

worker.postMessage(1_000_000_000);

worker.onmessage = (event) => {
  console.log("Result:", event.data);
};

Le fichier Worker écoute les messages reçus, exécute la boucle en utilisant le nombre qui lui a été donné, puis renvoie la somme totale :

self.onmessage = (event) => {
  let total = 0;
  for (let i = 0; i < event.data; i++) {
    total += i;
  }
  self.postMessage(total);
};

Il est utile de suivre le trajet aller-retour appel par appel. La page transmet ses données au Worker à l’aide de postMessage:

worker.postMessage(...)

Dans le Worker, self fait référence au contexte global du Worker, et c’est son gestionnaire onmessage qui reçoit les données entrantes :

self.onmessage = (...)

Lorsque le calcul est terminé, le Worker répond via son propre postMessage:

self.postMessage(...)

Finalement, le gestionnaire de la page sur l’objet Worker reçoit cette réponse sous forme de event.data:

worker.onmessage = (...)

Remarquez que l’attribution de onmessage après l’appel à postMessage dans le script de la page est acceptable : la réponse ne peut arriver qu’asynchrone, une fois que le script en cours a terminé son exécution, de sorte que le gestionnaire est toujours prêt à temps.

Qu’est-ce qui change, et qu’est-ce qui ne change pas

Une façon utile de l’imaginer est par la délégation. Le thread principal dit en fait "voici les données, vous vous en occupez" ; le Worker traite la tâche et rapporte ses résultats une fois terminé. Sans Worker, le flux est strictement séquentiel et l’interface utilisateur attend :

Main thread
     ↓
Heavy calculation
     ↓
UI waits

Avec un Worker, le thread principal transfère la tâche et continue de servir la page tandis que le calcul se déroule en parallèle :

Main thread ──→ Worker
     │             │
     │             └── Heavy calculation
     │
     └── continues handling the page

Soyez clair sur ce que vous gagnez. Transférer du travail dans un Worker ne fait généralement pas en sorte que ce travail s’achève plus rapidement. La boucle met à peu près autant de temps qu’avant, et les allers-retours de messages ajoutent un léger délai supplémentaire. Ce que vous gagnez, c’est que le calcul ne concurrence plus la thread principale pour du temps de traitement, ce qui permet à la page de rester interactive pendant son exécution.

Communiquer avec un Worker : des messages, pas le DOM

Le principal changement de mentalité est que un Worker ne peut pas accéder à la page. Il n’y a pas de document dans le scope global d’un Worker, donc une telle appel échouera :

document.querySelector("#result");

Tout ce qui traverse cette frontière se fait via des messages. Vous envoyez des données avec :

worker.postMessage(data);

et vous écoutez ce qui revient avec :

worker.onmessage = (event) => {
  console.log(event.data);
};

Cette restriction s’avère en fait être une fonctionnalité. Comme le Worker ne peut recevoir que des entrées et émettre que des sorties, le flux de données entre les deux parties est toujours explicite :

Main Thread
     │
     │ postMessage()
     ▼
  Worker
     │
     │ postMessage()
     ▼
Main Thread

Pour la plupart des applications, une frontière étroite, sous forme de messages, entre « la partie qui affiche » et « la partie qui calcule » correspond exactement au design souhaité. Le Worker ne touche jamais au DOM, et la page décide de ce qu’elle fait avec chaque résultat.

Refactorisation d’un gestionnaire de clic bloquant

Le point de départ typique est un gestionnaire de clic qui traite un grand ensemble de données de manière synchrone et affiche immédiatement le résultat :

button.addEventListener("click", () => {
  const result = processHugeDataset(data);
  renderResult(result);
});

Si processHugeDataset() s’exécute pendant une durée significative, le thread principal est bloqué tout au long de cette opération, et la page cesse de répondre jusqu’à ce que la fonction revienne.

La version basée sur le Worker divise le gestionnaire en deux parties. Le clic ne fait que envoyer les données ; l’affichage a lieu plus tard, lorsque le message de résultat arrive :

const worker = new Worker("worker.js");

button.addEventListener("click", () => {
  worker.postMessage(data);
});

worker.onmessage = (event) => {
  renderResult(event.data);
};

Le traitement coûteux a maintenant lieu à l’intérieur du Worker, ce qui permet au thread principal de continuer à gérer la page en attendant. Deux points importants doivent être gardés à l’esprit avec cette approche. Premièrement, le Worker est créé une seule fois, en dehors du gestionnaire, ce qui permet aux clics répétés de le réutiliser au lieu d’en créer un nouveau à chaque fois. Deuxièmement, si un utilisateur clique plusieurs fois rapidement, les résultats peuvent arriver après que l’utilisateur ait déjà continué ; dans du code réel, on attribue généralement un id à chaque message ou on ignore les réponses obsolètes afin qu’un résultat ancien ne supprime pas un résultat plus récent.

Décider de ce qui doit figurer dans un Worker

Il est facile de devenir enthousiaste et de vouloir placer toutes les fonctions dans un Worker. C’est rarement une bonne idée. Un Worker trouve sa place lorsque la tâche est assez gourmande en CPU pour concurrencer le thread principal pour du temps de traitement. Parmi les exemples typiques, on trouve :

  • Le traitement ou l’agrégation de grands ensembles de données
  • Des calculs numériques ou algorithmiques complexes
  • De grandes transformations JSON
  • Le parsing de gros fichiers
  • La manipulation d’images
  • Le traitement audio ou vidéo
  • La compression et d’autres transformations de données
  • Certaines routines cryptographiques ou mathématiques
  • L’affichage syntaxique et d’autres traitements de texte coûteux

Le profil récurrent est le même : beaucoup de calculs et peu de besoin de toucher directement au DOM.

Lorsqu’un Worker est un excès

Pour une opération aussi banale que la multiplication de deux nombres, un Worker représente uniquement des coûts supplémentaires inutiles :

const total = price * quantity;

Le démarrage d’un Worker entraîne des coûts initiaux, chaque message génère des frais de communication, et vous devez gérer un fichier supplémentaire avec son propre cycle de vie. Pour de petites opérations, le JavaScript classique sur le thread principal est le choix préférable. Au lieu de vous demander si vous pouvez utiliser un Worker, demandez-vous plutôt si la tâche est suffisamment lourde pour que son exécution sur le thread principal nuise réellement à l’expérience utilisateur. Cette question permet d’éliminer la plupart des cas où un Worker ne ferait qu’ajouter de la complexité.

L’effet secondaire architectural : une frontière claire

Les Workers ne sont pas seulement un outil de performance. Ils imposent également une séparation des préoccupations. Votre code d’interface utilisateur peut se concentrer sur la transformation des entrées en état et de l’état en affichage :

Input
↓
State
↓
Display

Pendant que le Worker se concentre sur la transformation des données d’entrée en données de sortie :

Input data
↓
Processing
↓
Output data

Isoler le traitement coûteux de cette manière facilite ses tests et sa compréhension, car le Worker se comporte comme une fonction pure derrière une interface de messages.

Cependant, il y a un compromis réel : vous devez désormais gérer le flux asynchrone basé sur les messages, le démarrage et l’arrêt des Workers, la manière dont les erreurs apparaissent, ainsi que le transfert de données à travers cette frontière. C’est un prix raisonnable lorsque le travail est lourd. Une bonne ingénierie ne consiste pas à éliminer la complexité partout ; elle consiste à la placer là où elle apporte un réel avantage.

Les Workers dédiés, partagés et de service sont des outils différents

« Web Worker » est souvent utilisé comme s’il désignait une seule API, mais plusieurs APIs liées aux Workers existent et elles servent des objectifs différents :

  • Worker dédié : exécute du JavaScript au nom d’une page ou d’un contexte spécifique. C’est le type utilisé dans tout ce guide.
  • Worker partagé : un seul Worker auquel plusieurs contextes de navigation (par exemple, plusieurs onglets de la même origine) peuvent se connecter et communiquer.
  • Service Worker : se situe entre la page et le réseau pour permettre des fonctionnalités telles que l’interception des requêtes, le cache et le mode hors ligne.

Pour décharger les tâches gourmandes en ressources CPU, c’est d’abord le Worker dédié qu’il faut comprendre. Un Service Worker n’en est pas un substitut direct ; les deux résolvent des problèmes différents. Pour une comparaison plus approfondie des trois types, consultez Workers dédiés, partagés et Service Workers : choisir le bon thread de navigateur.

Détails importants en environnement de production

L’exemple minimal est délibérément simple. Les applications réelles nécessitent une attention accrue en ce qui concerne les pannes, le nettoyage et la taille des données.

Surveiller les erreurs

Une exception non capturée à l’intérieur d’un Worker se manifeste sur la page sous forme d’événement error. Associez un gestionnaire afin que les pannes soient enregistrées au lieu de laisser l’interface utilisateur attendre indéfiniment un résultat :

worker.onerror = (error) => {
  console.error("Worker error:", error);
};

Terminer les Workers dont on n’a plus besoin

worker.terminate();

Cela est particulièrement important pour les Workers à durée de vie courte ; un Worker à longue durée de vie qui sert toute l’application peut simplement rester actif.

Le transfert de données n’est pas gratuit

Chaque message implique le transfert de données entre des contextes. Pour la plupart des valeurs JavaScript, le navigateur utilise l’algorithme de clonage structuré, qui crée une copie du côté destinataire. Pour certains envois binaires volumineux, tels que des instances ArrayBuffer, il est possible de les transmettre en tant qu’objets transférables, ce qui transfère la propriété à l’autre côté au lieu de faire une copie ; l’expéditeur ne peut plus utiliser ce buffer transféré par la suite. Vous n’avez pas besoin de connaître tous les détails immédiatement, mais gardez à l’esprit une règle : enlever les calculs du thread principal ne rend pas gratuit l’envoi des données vers celui-ci. Le clonage d’un objet très volumineux peut lui-même consommer beaucoup de temps sur le thread principal.

Workers et React

React ne modifie pas la manière dont le navigateur planifie l’exécution du JavaScript. Si un gestionnaire d’événement React effectue un calcul synchrone volumineux avant de mettre à jour l’état, ce calcul s’exécute toujours dans le contexte d’exécution principal de la page, exactement comme n’importe quel autre script :

function handleClick() {
  const result = expensiveCalculation();
  setResult(result);
}

Aucun framework ne peut faire disparaître les calculs JavaScript gourmands en ressources CPU. Ce que vous pouvez faire, c’est utiliser un Worker comme couche de calcul derrière vos composants : l’interface utilisateur envoie les données, le Worker les traite, et le résultat revient pour être stocké dans l’état et affiché :

React UI
   │
   │ send data
   ▼
Web Worker
   │
   │ process data
   ▼
React UI

Cela s’avère particulièrement utile dans les interfaces où les utilisateurs travaillent avec de grands ensembles de données, des éditeurs, des visualisations ou d’autres vues nécessitant beaucoup de calculs. Le framework n’est pas l’essentiel ; l’important, c’est de savoir quel thread effectue le travail coûteux. Dans un composant, cela signifie généralement créer le Worker au sein d’un effet, appeler setResult depuis son gestionnaire de messages, et le terminer lors du nettoyage de l’effet afin que les composants désinstallés ne laissent pas des Workers en cours d’exécution.

Les Workers ne représentent pas une amélioration générale de la vitesse

La plus grande erreur consiste à recourir aux Workers comme solution universelle pour améliorer les performances. Si une application est lente en raison de redessins inutiles, d’un trop grand nombre de requêtes réseau, d’assets trop volumineux, d’actualisations React inefficaces ou de code embrouillé, les Workers ne résoudront aucun de ces problèmes. Identifiez d’abord le véritable goulot d’étranglement, généralement à l’aide du profileur de performances du navigateur, avant de déterminer si les Workers constituent la solution adéquate.

Une courte liste de contrôle peut aider :

  1. La tâche est-elle réellement gourmande en ressources CPU ?
  2. Dure-t-elle suffisamment longtemps pour nuire à la réactivité ?
  3. A-t-elle besoin d’un accès direct au DOM ?
  4. Ses entrées et sorties peuvent-elles être transmises sous forme de messages ?
  5. La complexité supplémentaire en vaut-elle la peine ?

Lorsque les réponses indiquent que c’est le cas (gourmand en ressources, longue durée, pas de besoin de DOM, adaptée aux messages, la complexité en vaut la peine), les Workers sont probablement une solution appropriée.

Cela aide également à séparer deux problèmes qui sont facilement confondus. Dans le premier cas, JavaScript effectue plus de travail que nécessaire, et la solution consiste à utiliser un algorithme amélioré ou à réduire globalement la charge de travail. Dans le second cas, le travail est effectivement coûteux en ressources mais n’a aucune raison d’empêcher le fonctionnement de l’interface ; c’est là que les Workers se révèlent utiles. Un Worker ne transforme pas un code inefficace en code efficace ; il permet simplement à ce code coûteux de s’exécuter ailleurs.

Points clés

Vous n’avez pas besoin de mémoriser toute l’API des Workers. Le modèle essentiel se résume en une image :

MAIN THREAD
     │
     │ "Here is the work."
     ▼
  WORKER
     │
     │ "Here is the result."
     ▼
MAIN THREAD
  • Le thread principal gère la page ; toute tâche synchrone longue y exécutée retarde le traitement des entrées et les mises à jour de l’écran.
  • Un Worker dédié exécute du JavaScript isolé et communique uniquement via postMessage et onmessage ; il n’a pas d’accès au DOM.
  • Le déchargement permet au UI de rester réactif, mais il ne rend généralement pas le calcul lui-même plus rapide, et le transfert de grandes quantités de données comporte toujours des coûts.
  • Gérez l’événement onerror, appelez terminate() pour les Workers temporaires, et envisagez les objets Transferable pour de gros envois binaires.
  • Effectuez d’abord un profilage. Optimisez l’algorithme lorsque le travail est inutilement gourmand en ressources ; recourez à un Worker lorsque le travail est nécessaire mais ne doit pas s’exécuter sur le thread principal.
  • Lorsque une page bloque lors d’un calcul volumineux, demandez-vous non seulement comment rendre la fonction plus rapide, mais aussi si elle doit vraiment s’exécuter sur le thread principal.