Exécuter des threads Web avec des nœuds de calcul de module

Il est désormais plus facile d'effectuer des tâches lourdes dans des threads en arrière-plan grâce aux modules JavaScript des nœuds de calcul Web.

JavaScript est monothread, ce qui signifie qu'il ne peut effectuer qu'une seule opération à la fois. Cette approche intuitive fonctionne bien dans de nombreux cas sur le Web, mais elle peut s'avérer problématique lorsque nous devons effectuer des tâches lourdes telles que le traitement, l'analyse, le calcul ou l'analyse de données. À mesure que des applications de plus en plus complexes sont livrées sur le Web, le besoin de traitement multithread devient de plus en plus nécessaire.

Sur la plate-forme Web, la principale primitive du threading et du parallélisme est l'API Web Workers. Les nœuds de calcul constituent une abstraction légère au-dessus des threads du système d'exploitation et exposent une API de transmission de messages pour la communication interthread. Cela peut s'avérer extrêmement utile lors de l'exécution de calculs coûteux ou d'opérations sur des ensembles de données volumineux, permettant au thread principal de s'exécuter correctement tout en effectuant les opérations coûteuses sur un ou plusieurs threads en arrière-plan.

Voici un exemple typique d'utilisation d'un nœud de calcul, où un script de nœud de calcul écoute les messages du thread principal et répond en renvoyant ses propres messages:

page.js:

const worker = new Worker('worker.js');
worker.addEventListener('message', e => {
  console.log(e.data);
});
worker.postMessage('hello');

worker.js:

addEventListener('message', e => {
  if (e.data === 'hello') {
    postMessage('world');
  }
});

L'API Web Worker est disponible dans la plupart des navigateurs depuis plus de 10 ans. Bien que les nœuds de calcul bénéficient d'une excellente prise en charge des navigateurs et sont bien optimisés, cela signifie également qu'ils sont antérieurs aux modules JavaScript. Étant donné qu'il n'existait pas de système de modules lors de la conception des nœuds de calcul, l'API permettant de charger du code dans un nœud de calcul et de rédiger des scripts est restée semblable aux approches de chargement synchrone de scripts courantes en 2009.

Historique: nœuds de calcul classiques

Le constructeur de nœud de calcul utilise une URL de script classique, qui est relative à l'URL du document. Elle renvoie immédiatement une référence à la nouvelle instance de nœud de calcul, qui expose une interface de messagerie ainsi qu'une méthode terminate() qui arrête et détruit immédiatement le nœud de calcul.

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

Une fonction importScripts() est disponible au sein des nœuds de calcul Web pour charger du code supplémentaire, mais elle met en pause l'exécution du nœud de calcul afin d'extraire et d'évaluer chaque script. Il exécute également des scripts dans le champ d'application global comme une balise <script> classique, ce qui signifie que les variables d'un script peuvent être remplacées par celles d'un autre.

worker.js:

importScripts('greet.js');
// ^ could block for seconds
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

// global to the whole worker
function sayHello() {
  return 'world';
}

Pour cette raison, les nœuds de calcul Web ont toujours imposé un effet considérable sur l'architecture d'une application. Les développeurs ont dû créer des outils et des solutions de contournement astucieux pour permettre l'utilisation de nœuds de calcul Web sans renoncer aux pratiques de développement modernes. Par exemple, les bundlers comme Webpack intègrent une petite implémentation de chargeur de module dans le code généré qui utilise importScripts() pour charger le code, mais encapsule les modules dans des fonctions pour éviter les conflits de variables et simuler des importations et des exportations de dépendances.

Saisir les nœuds de calcul de module

Un nouveau mode pour les travailleurs Web, qui offre les avantages des modules JavaScript en termes d'ergonomie et de performances, est disponible dans Chrome 80, appelé "nœuds de calcul de module". Le constructeur Worker accepte désormais une nouvelle option {type:"module"}, qui modifie le chargement et l'exécution du script pour qu'ils correspondent à <script type="module">.

const worker = new Worker('worker.js', {
  type: 'module'
});

Les nœuds de calcul de module étant des modules JavaScript standards, ils peuvent utiliser des instructions d'importation et d'exportation. Comme pour tous les modules JavaScript, les dépendances ne sont exécutées qu'une seule fois dans un contexte donné (thread principal, nœud de calcul, etc.), et toutes les importations futures font référence à l'instance de module déjà exécutée. Le chargement et l'exécution des modules JavaScript sont également optimisés par les navigateurs. Les dépendances d'un module peuvent être chargées avant l'exécution du module, ce qui permet de charger des arborescences de modules entiers en parallèle. Le chargement du module met également en cache le code analysé, ce qui signifie que les modules utilisés sur le thread principal et dans un nœud de calcul n'ont besoin d'être analysés qu'une seule fois.

Le passage aux modules JavaScript permet également d'utiliser l'importation dynamique pour le code à chargement différé sans bloquer l'exécution du nœud de calcul. L'importation dynamique est beaucoup plus explicite que l'utilisation de importScripts() pour charger les dépendances, car les exportations du module importé sont renvoyées au lieu d'utiliser des variables globales.

worker.js:

import { sayHello } from './greet.js';
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

import greetings from './data.js';
export function sayHello() {
  return greetings.hello;
}

Pour garantir d'excellentes performances, l'ancienne méthode importScripts() n'est pas disponible dans les nœuds de calcul de module. Lorsque les nœuds de calcul utilisent des modules JavaScript, tout le code est chargé dans le mode strict. Autre changement notable : la valeur de this dans le champ d'application de premier niveau d'un module JavaScript est undefined, tandis que dans les nœuds de calcul classiques, la valeur correspond au champ d'application global du nœud de calcul. Heureusement, il y a toujours eu un self global qui fait référence au champ d'application global. Elle est disponible dans tous les types de nœuds de calcul, y compris les service workers, ainsi que dans le DOM.

Précharger les nœuds de calcul avec modulepreload

Les nœuds de calcul de module offrent une amélioration significative des performances liée à la possibilité de précharger les nœuds de calcul et leurs dépendances. Avec les nœuds de calcul de module, les scripts sont chargés et exécutés en tant que modules JavaScript standards, ce qui signifie qu'ils peuvent être préchargés et même analysés à l'aide de modulepreload:

<!-- preloads worker.js and its dependencies: -->
<link rel="modulepreload" href="worker.js">

<script>
  addEventListener('load', () => {
    // our worker code is likely already parsed and ready to execute!
    const worker = new Worker('worker.js', { type: 'module' });
  });
</script>

Les modules préchargés peuvent également être utilisés à la fois par le thread principal et les nœuds de calcul de module. Cela est utile pour les modules importés dans les deux contextes ou lorsqu'il n'est pas possible de savoir à l'avance si un module sera utilisé dans le thread principal ou dans un nœud de calcul.

Auparavant, les options disponibles pour le préchargement des scripts de nœud de calcul Web étaient limitées et pas nécessairement fiables. Les nœuds de calcul classiques disposaient de leur propre type de ressource "worker" pour le préchargement, mais aucun navigateur n'intégrait <link rel="preload" as="worker">. Par conséquent, la principale technique disponible pour le préchargement des nœuds de calcul Web consistait à utiliser <link rel="prefetch">, qui s'appuyait entièrement sur le cache HTTP. Utilisée conjointement avec des en-têtes de mise en cache appropriés, elle permet d'éviter que l'instanciation des nœuds de calcul doive attendre avant de télécharger le script de nœud de calcul. Cependant, contrairement à modulepreload, cette technique ne permettait ni le préchargement des dépendances, ni l'analyse préalable.

Qu'en est-il des nœuds de calcul partagés ?

Les nœuds de calcul partagés ont été mis à jour pour être compatibles avec les modules JavaScript à partir de Chrome 83. Comme les nœuds de calcul dédiés, la construction d'un nœud de calcul partagé avec l'option {type:"module"} charge désormais le script du nœud de calcul en tant que module plutôt qu'en tant que script classique:

const worker = new SharedWorker('/worker.js', {
  type: 'module'
});

Avant la prise en charge des modules JavaScript, le constructeur SharedWorker() n'attendait qu'une URL et un argument name facultatif. Cette méthode continuera de fonctionner pour l'utilisation classique des nœuds de calcul partagés. Cependant, la création de nœuds de calcul partagés de module nécessite l'utilisation du nouvel argument options. Les options disponibles sont les mêmes que celles d'un nœud de calcul dédié, y compris l'option name qui remplace l'argument name précédent.

Qu'en est-il du service worker ?

La spécification du service worker a déjà été mise à jour pour permettre l'acceptation d'un module JavaScript comme point d'entrée, à l'aide de la même option {type:"module"} que les nœuds de calcul de module. Toutefois, cette modification n'a pas encore été implémentée dans les navigateurs. Vous pourrez alors instancier un service worker à l'aide d'un module JavaScript à l'aide du code suivant:

navigator.serviceWorker.register('/sw.js', {
  type: 'module'
});

Maintenant que la spécification a été mise à jour, les navigateurs commencent à implémenter le nouveau comportement. Cette opération prend du temps, car l'importation de modules JavaScript sur un service worker entraîne des complications supplémentaires. L'enregistrement d'un service worker doit comparer les scripts importés avec leurs versions mises en cache précédentes pour déterminer s'il faut déclencher une mise à jour. Cette opération doit être mise en œuvre pour les modules JavaScript lorsqu'ils sont utilisés pour des service workers. En outre, dans certains cas, les service workers doivent pouvoir contourner le cache pour les scripts lorsqu'ils recherchent des mises à jour.

Autres ressources et articles complémentaires