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

Il est désormais plus facile de déplacer les charges lourdes dans des threads en arrière-plan grâce aux modules JavaScript dans les workers Web.

JavaScript est à thread unique, ce qui signifie qu'il ne peut effectuer qu'une seule opération à la fois. Cette méthode est intuitive et 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 ou le calcul de données. À mesure que des applications de plus en plus complexes sont mises à disposition sur le Web, le traitement multithread s'impose de plus en plus.

Sur la plate-forme Web, la principale primitive pour les threads et le parallélisme est l'API Web Workers. Les nœuds de calcul constituent une abstraction légère s'appuyant sur les threads du système d'exploitation, qui exposent une API de transmission de messages pour la communication inter-thread. Cela peut être très utile lors de calculs coûteux ou d'opérations sur des ensembles de données volumineux, ce qui permet au thread principal de fonctionner 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, dans lequel 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 compatibilité avec les navigateurs et soient bien optimisés, cela signifie également qu'ils sont obsolètes en ce qui concerne les 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 synchrones de scripts courantes en 2009.

Historique: workers classiques

Le constructeur de nœud de calcul utilise une URL de script classique, qui est relative à l'URL du document. Il 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 immédiatement le nœud de calcul et le détruit.

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

Une fonction importScripts() est disponible dans les nœuds de calcul Web pour charger du code supplémentaire, mais elle suspend l'exécution du nœud de calcul afin de récupérer et d'évaluer chaque script. Il exécute également des scripts dans un champ d'application global comme une balise <script> classique, ce qui signifie que les variables d'un script peuvent être écrasées par les variables 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 workers Web ont toujours eu un effet considérable sur l'architecture d'une application. Les développeurs ont dû créer des outils et des solutions de contournement intelligents pour permettre l'utilisation des workers Web sans renoncer aux pratiques de développement modernes. Par exemple, les bundlers tels que 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 du module

Un nouveau mode pour les workers Web offrant l'ergonomie et les performances des modules JavaScript est disponible dans Chrome 80. Il s'agit des workers 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'il corresponde à <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 son exécution, ce qui permet de charger des arborescences de modules entières 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 ne doivent être analysés qu'une seule fois.

Le passage aux modules JavaScript permet également d'utiliser l'importation dynamique pour le chargement différé du code sans bloquer l'exécution du nœud de calcul. L'importation dynamique est beaucoup plus explicite que l'utilisation de importScripts() pour charger des dépendances, car les exportations du module importé sont renvoyées au lieu de s'appuyer sur 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 des modules. Si vous basculez les nœuds de calcul de façon à utiliser des modules JavaScript, tout le code est chargé en mode strict. Un autre changement notable est que la valeur de this dans le champ d'application de premier niveau d'un module JavaScript est undefined, tandis que pour les nœuds de calcul classiques, cette valeur correspond au champ d'application global du nœud de calcul. Heureusement, il existe toujours un global self qui fournit une référence au champ d'application global. Il est disponible pour tous les types de workers, y compris les service workers, ainsi que dans le DOM.

Précharger les nœuds de calcul avec modulepreload

L'une des améliorations substantielles des performances offertes par les nœuds de calcul de module est 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 pré-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 du module. Cela est utile pour les modules importés dans les deux contextes, ou dans les cas où il n'est pas possible de savoir à l'avance si un module sera utilisé sur le thread principal ou dans un nœud de calcul.

Auparavant, les options disponibles pour le préchargement des scripts de nœuds de calcul Web étaient limitées et pas nécessairement fiables. Les nœuds de calcul de la version classique disposaient de leur propre type de ressource "worker" pour le préchargement, mais aucun navigateur n'a implémenté <link rel="preload" as="worker">. Par conséquent, la principale technique disponible pour le préchargement des workers Web consistait à utiliser <link rel="prefetch">, qui reposait entièrement sur le cache HTTP. Lorsqu'elle était utilisée avec les en-têtes de mise en cache appropriés, cela a permis d'éviter que l'instanciation des nœuds de calcul n'ait à attendre avant de télécharger le script de nœud de calcul. Cependant, contrairement à modulepreload, cette technique ne permettait pas de précharger les dépendances ni d'effectuer un pré-analyse.

Qu'en est-il des travailleurs partagés ?

Les nœuds de calcul partagés sont compatibles avec les modules JavaScript à partir de Chrome 83. Comme pour les nœuds de calcul dédiés, la création d'un nœud de calcul partagé avec l'option {type:"module"} charge désormais le script de 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. Cela continuera de fonctionner pour l'utilisation classique des nœuds de calcul partagés. Toutefois, 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 des 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, avec la même option {type:"module"} que les workers de module. Toutefois, cette modification n'a pas encore été mise en œuvre dans les navigateurs. Il sera ensuite possible d'instancier un service worker à l'aide d'un module JavaScript à l'aide du code suivant:

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

Maintenant que les spécifications ont été mises à jour, les navigateurs commencent à implémenter le nouveau comportement. Cela prend du temps, car des complications supplémentaires sont associées à l'importation de modules JavaScript dans le service worker. L'enregistrement des service workers doit comparer les scripts importés avec leurs versions précédentes mises en cache pour déterminer s'il faut déclencher une mise à jour. Cette opération doit être implémentée pour les modules JavaScript lorsqu'ils sont utilisés par des service workers. En outre, dans certains cas, les service workers doivent pouvoir contourner le cache pour les scripts lors de la recherche de mises à jour.

Autres ressources et documents complémentaires