Utiliser des workers Web pour exécuter JavaScript en dehors du thread principal du navigateur

Une architecture hors thread principal peut améliorer considérablement la fiabilité et l'expérience utilisateur de votre application.

Au cours des 20 dernières années, le Web a considérablement évolué, passant de documents statiques avec quelques styles et images à des applications complexes et dynamiques. Cependant, une chose est restée pratiquement inchangée : nous n'avons qu'un seul thread par onglet de navigateur (à quelques exceptions près) pour afficher nos sites et exécuter notre code JavaScript.

Par conséquent, le thread principal est devenu incroyablement surchargé. À mesure que les applications Web gagnent en complexité, le thread principal devient un goulot d'étranglement important pour les performances. Pour ne rien arranger, le temps nécessaire pour exécuter du code sur le thread principal pour un utilisateur donné est presque totalement imprévisible, car les capacités de l'appareil ont un impact considérable sur les performances. Cette imprévisibilité ne fera que croître à mesure que les utilisateurs accéderont au Web à partir d'un ensemble d'appareils de plus en plus diversifié, des feature phones hyper-limités aux machines phares à haute puissance et à fréquence d'actualisation élevée.

Si nous voulons que les applications Web sophistiquées respectent de manière fiable les consignes de performances telles que les Core Web Vitals, qui sont basées sur des données empiriques sur la perception et la psychologie humaines, nous devons trouver des moyens d'exécuter notre code en dehors du thread principal (OMT).

Pourquoi utiliser des Web Workers ?

Par défaut, JavaScript est un langage monothread qui exécute des tâches sur le thread principal. Cependant, les web workers fournissent une sorte d'échappatoire au thread principal en permettant aux développeurs de créer des threads distincts pour gérer le travail en dehors du thread principal. Bien que le champ d'application des Web Workers soit limité et qu'ils n'offrent pas d'accès direct au DOM, ils peuvent être extrêmement utiles si une quantité considérable de travail doit être effectuée, ce qui submergerait le thread principal.

En ce qui concerne les Core Web Vitals, il peut être utile d'exécuter des tâches en dehors du thread principal. En particulier, le déchargement du travail du thread principal vers les Web Workers peut réduire les conflits pour le thread principal, ce qui peut améliorer la métrique de réactivité Interaction to Next Paint (INP) d'une page. Lorsque le thread principal a moins de travail à traiter, il peut répondre plus rapidement aux interactions des utilisateurs.

Réduire le travail du thread principal, en particulier au démarrage, peut également améliorer le Largest Contentful Paint (LCP) en réduisant les tâches longues. Le rendu d'un élément LCP nécessite du temps sur le thread principal, que ce soit pour le rendu de texte ou d'images, qui sont des éléments LCP fréquents et courants. En réduisant le travail du thread principal dans son ensemble, vous pouvez vous assurer que l'élément LCP de votre page est moins susceptible d'être bloqué par un travail coûteux qu'un web worker pourrait gérer à la place.

Exécuter des threads avec des workers Web

Les autres plates-formes sont généralement compatibles avec le travail en parallèle en vous permettant de donner une fonction à un thread, qui s'exécute en parallèle avec le reste de votre programme. Vous pouvez accéder aux mêmes variables à partir des deux threads, et l'accès à ces ressources partagées peut être synchronisé avec des mutex et des sémaphores pour éviter les conditions de concurrence.

En JavaScript, nous pouvons obtenir des fonctionnalités à peu près similaires avec les Web Workers, qui existent depuis 2007 et sont compatibles avec tous les principaux navigateurs depuis 2012. Les Web Workers s'exécutent en parallèle du thread principal, mais contrairement aux threads de l'OS, ils ne peuvent pas partager de variables.

Pour créer un web worker, transmettez un fichier au constructeur du worker, qui commence à exécuter ce fichier dans un thread distinct :

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

Communiquez avec le web worker en envoyant des messages à l'aide de l'API postMessage. Transmettez la valeur du message en tant que paramètre dans l'appel postMessage, puis ajoutez un écouteur d'événements de message au worker :

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

Pour renvoyer un message au thread principal, utilisez la même API postMessage dans le Web Worker et configurez un écouteur d'événements sur le thread principal :

main.js

const worker = new Worker('./worker.js');

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

Certes, cette approche est quelque peu limitée. Historiquement, les web workers ont principalement été utilisés pour déplacer une seule tâche lourde hors du thread principal. Essayer de gérer plusieurs opérations avec un seul Web Worker devient rapidement fastidieux : vous devez encoder non seulement les paramètres, mais aussi l'opération dans le message, et vous devez effectuer un suivi pour faire correspondre les réponses aux requêtes. Cette complexité explique probablement pourquoi les Web Workers n'ont pas été adoptés plus largement.

Mais si nous pouvions éliminer une partie des difficultés de communication entre le thread principal et les Web Workers, ce modèle pourrait être parfaitement adapté à de nombreux cas d'utilisation. Heureusement, il existe une bibliothèque qui fait exactement cela.

Comlink est une bibliothèque dont l'objectif est de vous permettre d'utiliser des Web Workers sans avoir à vous soucier des détails de postMessage. Comlink vous permet de partager des variables entre les Web Workers et le thread principal, presque comme d'autres langages de programmation compatibles avec les threads.

Vous configurez Comlink en l'important dans un Web Worker et en définissant un ensemble de fonctions à exposer au thread principal. Vous importez ensuite Comlink sur le thread principal, encapsulez le worker et accédez aux fonctions exposées :

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

La variable api sur le thread principal se comporte de la même manière que celle du worker Web, sauf que chaque fonction renvoie une promesse pour une valeur plutôt que la valeur elle-même.

Quel code devez-vous déplacer vers un Web Worker ?

Les Web Workers n'ont pas accès au DOM ni à de nombreuses API telles que WebUSB, WebRTC ou Web Audio. Vous ne pouvez donc pas placer dans un worker les parties de votre application qui dépendent de cet accès. Cependant, chaque petit morceau de code déplacé vers un worker libère de l'espace sur le thread principal pour les éléments qui doivent s'y trouver, comme la mise à jour de l'interface utilisateur.

L'un des problèmes des développeurs Web est que la plupart des applications Web s'appuient sur un framework d'interface utilisateur comme Vue ou React pour tout orchestrer dans l'application. Tout est un composant du framework et est donc intrinsèquement lié au DOM. Cela semble rendre difficile la migration vers une architecture OMT.

Toutefois, si nous passons à un modèle dans lequel les problèmes d'UI sont séparés des autres problèmes, comme la gestion de l'état, les Web Workers peuvent être très utiles, même avec des applications basées sur un framework. C'est exactement l'approche adoptée avec PROXX.

PROXX : étude de cas OMT

L'équipe Google Chrome a développé PROXX, un clone du Démineur qui répond aux exigences des progressive web apps, y compris le fonctionnement hors connexion et une expérience utilisateur attrayante. Malheureusement, les premières versions du jeu n'étaient pas très performantes sur les appareils limités comme les feature phones, ce qui a permis à l'équipe de se rendre compte que le thread principal était un goulot d'étranglement.

L'équipe a décidé d'utiliser des Web Workers pour séparer l'état visuel du jeu de sa logique :

  • Le thread principal gère l'affichage des animations et des transitions.
  • Un Web Worker gère la logique du jeu, qui est purement calculatoire.

L'OMT a eu des effets intéressants sur les performances de PROXX sur les feature phones. Dans la version sans OMT, l'interface utilisateur est figée pendant six secondes après l'interaction de l'utilisateur. Il n'y a pas de retour d'information, et l'utilisateur doit attendre six secondes avant de pouvoir faire autre chose.

Temps de réponse de l'interface utilisateur dans la version non OMT de PROXX.

Toutefois, dans la version OMT, le jeu met douze secondes à mettre à jour l'interface utilisateur. Bien que cela puisse sembler être une perte de performances, cela permet en réalité d'augmenter les commentaires adressés à l'utilisateur. Le ralentissement se produit, car l'application envoie plus d'images que la version non OMT, qui n'envoie aucune image. L'utilisateur sait donc que quelque chose se passe et peut continuer à jouer pendant que l'UI se met à jour, ce qui améliore considérablement l'expérience de jeu.

Temps de réponse de l'interface utilisateur dans la version OMT de PROXX.

Il s'agit d'un compromis conscient : nous offrons aux utilisateurs d'appareils limités une expérience qui semble meilleure sans pénaliser les utilisateurs d'appareils haut de gamme.

Implications d'une architecture OMT

Comme le montre l'exemple PROXX, l'OMT permet à votre application de s'exécuter de manière fiable sur un plus grand nombre d'appareils, mais elle ne la rend pas plus rapide :

  • Vous déplacez simplement le travail du thread principal, sans le réduire.
  • La surcharge de communication supplémentaire entre le web worker et le thread principal peut parfois ralentir légèrement les choses.

Peser le pour et le contre

Comme le thread principal est libre de traiter les interactions utilisateur telles que le défilement pendant l'exécution du code JavaScript, il y a moins d'images perdues, même si le temps d'attente total peut être légèrement plus long. Il est préférable de faire attendre un peu l'utilisateur plutôt que de supprimer une image, car la marge d'erreur est plus faible pour les images supprimées : la suppression d'une image se produit en quelques millisecondes, tandis que vous disposez de centaines de millisecondes avant qu'un utilisateur ne perçoive le temps d'attente.

En raison de l'imprévisibilité des performances sur les différents appareils, l'objectif de l'architecture OMT est de réduire les risques (rendre votre application plus robuste face à des conditions d'exécution très variables), et non d'améliorer les performances de la parallélisation. L'amélioration de la résilience et de l'expérience utilisateur compense largement la légère baisse de vitesse.

Remarque sur les outils

Les Web Workers ne sont pas encore très répandus. Par conséquent, la plupart des outils de module, comme webpack et Rollup, ne les prennent pas en charge par défaut. (Parcel, oui !) Heureusement, il existe des plug-ins pour faire fonctionner les Web Workers avec webpack et Rollup :

Récapitulatif

Pour que nos applications soient aussi fiables et accessibles que possible, en particulier sur un marché de plus en plus mondialisé, nous devons prendre en charge les appareils limités, car c'est ainsi que la plupart des utilisateurs accèdent au Web dans le monde. L'OMT offre un moyen prometteur d'améliorer les performances sur ces appareils sans affecter les utilisateurs d'appareils haut de gamme.

De plus, l'OMT présente des avantages secondaires :

  • Il déplace les coûts d'exécution JavaScript vers un thread distinct.
  • Il déplace les coûts d'analyse, ce qui signifie que l'UI peut démarrer plus rapidement. Cela peut réduire le First Contentful Paint, voire le Time to Interactive, ce qui peut à son tour augmenter votre score Lighthouse.

Les Web Workers ne sont pas forcément effrayants. Des outils comme Comlink facilitent l'utilisation des workers et en font un choix viable pour un large éventail d'applications Web.