Articles

Le thread du web avec les modules workers

JavaScript est mono-thread, ce qui signifie qu’il ne peut effectuer qu’une seule opération à la fois. Ceci est intuitif et fonctionne bien pour de nombreux cas sur le Web, mais peut devenir problématique lorsque nous devons effectuer des tâches lourdes comme le traitement de données, l’analyse, le calcul ou l’analyse. À mesure que des applications de plus en plus complexes sont livrées sur le Web, il y a un besoin accru de traitement multithread.

Sur la plate-forme web, la principale primitive pour le threading et le parallélisme est l’API Web Workers. Les Workers sont une abstraction légère au-dessus des threads du système d’exploitation qui exposent une API de passage de message pour la communication entre threads. Cela peut être extrêmement utile lors de calculs coûteux ou d’opérations sur de grands ensembles de données, permettant au thread principal de fonctionner sans problème tout en effectuant les opérations coûteuses sur un ou plusieurs threads d’arrière-plan.

Voici un exemple typique d’utilisation de worker, où un script de worker écoute les messages du thread principal et répond en renvoyant ses propres messages :

page.js:

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

travailleur.js:

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

L’API Web Worker est disponible dans la plupart des navigateurs depuis plus de dix ans. Bien que cela signifie que les travailleurs ont un excellent support de navigateur et sont bien optimisés, cela signifie également qu’ils sont antérieurs depuis longtemps aux modules JavaScript. Comme il n’y avait pas de système de modules lors de la conception des workers, l’API permettant de charger du code dans un worker et de composer des scripts est restée similaire aux approches de chargement de scripts synchrones courantes en 2009.

Historique: workers classiques #

Le constructeur Worker prend une URL de script classique, qui est relative à l’URL du document. Il renvoie immédiatement une référence à la nouvelle instance de worker, qui expose une interface de messagerie ainsi qu’une méthode terminate() qui arrête et détruit immédiatement le worker.

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

Une fonction importScripts() est disponible dans les travailleurs Web pour charger du code supplémentaire, mais elle interrompt l’exécution du travailleur afin de récupérer et d’évaluer chaque script. Il exécute également des scripts dans la portée globale comme une balise classique <script>, ce qui signifie que les variables d’un script peuvent être écrasées par les variables d’un autre.

travailleur.js:

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

saluez.js:

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

Pour cette raison, les travailleurs du web ont historiquement imposé un effet démesuré sur l’architecture d’une application. Les développeurs ont dû créer des outils intelligents et des solutions de contournement pour permettre d’utiliser des travailleurs du Web sans abandonner les pratiques de développement modernes. Par exemple, des bundlers comme webpack intègrent une implémentation de chargeur de module de petite taille dans le code généré qui utilise importScripts() pour le chargement de code, mais encapsule les modules dans des fonctions pour éviter les collisions de variables et simuler les importations et les exportations de dépendances.

Entrez module workers #

Un nouveau mode pour les web workers avec l’ergonomie et les avantages de performance des modules JavaScript est livré dans Chrome 80, appelé module workers. 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'
});

Étant donné que les modules de travail sont des modules JavaScript standard, 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, worker, 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 arbres de modules entiers en parallèle. Le chargement de modules met également en cache le code analysé, ce qui signifie que les modules utilisés sur le thread principal et dans un worker ne doivent être analysés qu’une seule fois.

Le passage aux modules JavaScript permet également l’utilisation de l’importation dynamique pour le code à chargement paresseux sans bloquer l’exécution du travailleur. 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 plutôt que de s’appuyer sur des variables globales.

travailleur.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 assurer d’excellentes performances, l’ancienne méthode importScripts() n’est pas disponible dans les travailleurs de modules. Changer les travailleurs pour utiliser des modules JavaScript signifie que tout le code est chargé en mode strict. Un autre changement notable est que la valeur de this dans la portée de niveau supérieur d’un module JavaScript est undefined, alors que dans les travailleurs classiques, la valeur est la portée globale du travailleur. Heureusement, il y a toujours eu un self global qui fournit une référence à la portée globale. Il est disponible dans tous les types de travailleurs, y compris les travailleurs de service, ainsi que dans le DOM.

Les travailleurs de modules suppriment également la prise en charge des commentaires de style HTML. Saviez-vous que vous pouviez utiliser des commentaires HTML dans des scripts de travail Web?

Précharger les travailleurs avec modulepreload#

Une amélioration substantielle des performances des travailleurs de modules est la possibilité de précharger les travailleurs et leurs dépendances. Avec les modules workers, les scripts sont chargés et exécutés en tant que modules JavaScript standard, ce qui signifie qu’ils peuvent être préchargés et même pré-analysés en utilisant 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 par le thread principal et les modules workers. Ceci 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 worker.

Auparavant, les options disponibles pour le préchargement des scripts web worker étaient limitées et pas nécessairement fiables. Les travailleurs classiques avaient leur propre type de ressource « travailleur » pour le préchargement, mais aucun navigateur n’a implémenté <link rel="preload" as="worker">. En conséquence, la technique principale disponible pour le préchargement des web workers consistait à utiliser <link rel="prefetch">, qui reposait entièrement sur le cache HTTP. Lorsqu’il est utilisé en combinaison avec les en-têtes de mise en cache corrects, cela a permis d’éviter que l’instanciation du travailleur doive attendre pour télécharger le script de travail. Cependant, contrairement à modulepreload, cette technique ne prenait pas en charge les dépendances de préchargement ou la pré-analyse.

Qu’en est-il des travailleurs partagés ? #

Les travailleurs partagés ont été mis à jour avec la prise en charge des modules JavaScript à partir de Chrome 83. Comme les workers dédiés, la construction d’un worker partagé avec l’option {type:"module"} charge désormais le script worker en tant que module plutôt qu’un 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 name argument. Cela continuera de fonctionner pour l’utilisation classique des travailleurs partagés; cependant, la création de modules de travail partagés nécessite l’utilisation du nouvel argument options. Les options disponibles sont les mêmes que celles d’un travailleur dédié, y compris l’option name qui remplace l’argument précédent name.

Qu’en est-il du service worker? #

La spécification service worker a déjà été mise à jour pour prendre en charge l’acceptation d’un module JavaScript comme point d’entrée, en utilisant la même option {type:"module"} en tant que module workers, mais cette modification n’a pas encore été implémentée dans les navigateurs. Une fois que cela se produira, il sera possible d’instancier un service worker à l’aide d’un module JavaScript en utilisant le 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. Cela prend du temps car il y a des complications supplémentaires associées à l’apport de modules JavaScript à service worker. L’enregistrement des agents de service doit comparer les scripts importés avec leurs versions mises en cache précédentes lors de la détermination de l’opportunité de déclencher une mise à jour, et cela doit être implémenté pour les modules JavaScript lorsqu’ils sont utilisés pour les agents de service. En outre, les agents de service doivent pouvoir contourner le cache des scripts dans certains cas lors de la vérification des mises à jour.