Thread sul Web con i worker dei moduli

Spostare il lavoro pesante nei thread in background è ora più facile con i moduli JavaScript nei web worker.

JavaScript è a thread singolo, il che significa che può eseguire una sola operazione alla volta. Si tratta di una procedura intuitiva e funziona bene per molti casi sul web, ma può diventare problematica quando abbiamo bisogno di svolgere attività gravose come l'elaborazione, l'analisi, il calcolo o l'analisi dei dati. Con la pubblicazione di un numero sempre maggiore di applicazioni complesse sul web, aumenta la necessità di elaborare l'elaborazione multi-thread.

Sulla piattaforma web, la primitiva principale per i thread e il parallelismo è l'API Web Workers. I worker sono un'astrazione leggera sopra i thread del sistema operativo che espongono un'API di trasmissione dei messaggi per le comunicazioni tra thread. Ciò può essere estremamente utile quando si eseguono calcoli costosi o si eseguono operazioni su set di dati di grandi dimensioni, consentendo al thread principale di funzionare senza problemi mentre si eseguono operazioni costose su uno o più thread in background.

Ecco un esempio tipico di utilizzo dei worker, in cui uno script worker rimane in ascolto dei messaggi del thread principale e risponde inviando i propri messaggi:

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 è disponibile nella maggior parte dei browser da oltre dieci anni. Sebbene ciò significa che i worker abbiano un eccellente supporto del browser e siano ben ottimizzati, significa anche che sono antecedenti a lungo i moduli JavaScript. Poiché al momento della progettazione dei worker non esisteva un sistema a moduli, l'API per il caricamento del codice in un worker e la scrittura degli script è rimasta simile agli approcci al caricamento sincrono degli script comuni nel 2009.

Cronologia: worker classici

Il costruttore worker accetta un URL di script classico relativo all'URL del documento. Restituisce immediatamente un riferimento alla nuova istanza worker, che espone un'interfaccia di messaggistica e un metodo terminate() che arresta immediatamente e elimina il worker.

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

All'interno dei web worker è disponibile una funzione importScripts() per caricare codice aggiuntivo, ma sospende l'esecuzione del worker per recuperare e valutare ogni script. Esegue inoltre script nell'ambito globale come un tag <script> classico, il che significa che le variabili in uno script possono essere sostituite dalle variabili in un altro.

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';
}

Per questo motivo, i worker web hanno storicamente imposto un effetto enorme sull'architettura di un'applicazione. Gli sviluppatori hanno dovuto creare strumenti e soluzioni alternative intelligenti per consentire di utilizzare i web worker senza rinunciare alle moderne pratiche di sviluppo. Ad esempio, i bundler come webpack incorporano una piccola implementazione del caricatore di moduli nel codice generato che utilizza importScripts() per il caricamento del codice, ma aggrega i moduli nelle funzioni per evitare collisioni variabili e simulare importazioni ed esportazioni delle dipendenze.

Inserisci i worker del modulo

Una nuova modalità per i lavoratori web con i vantaggi in termini di ergonomia e prestazioni dei moduli JavaScript sarà disponibile in Chrome 80, chiamata " worker del modulo". Il costruttore Worker ora accetta una nuova opzione {type:"module"}, che modifica il caricamento e l'esecuzione dello script in modo che corrisponda a <script type="module">.

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

Poiché i worker dei moduli sono moduli JavaScript standard, possono utilizzare istruzioni di importazione ed esportazione. Come con tutti i moduli JavaScript, le dipendenze vengono eseguite una sola volta in un determinato contesto (thread principale, worker e così via) e tutte le importazioni future fanno riferimento all'istanza del modulo già eseguita. Anche il caricamento e l'esecuzione dei moduli JavaScript viene ottimizzato dai browser. Le dipendenze di un modulo possono essere caricate prima dell'esecuzione del modulo, il che consente di caricare intere strutture di moduli in parallelo. Il caricamento del modulo memorizza inoltre nella cache il codice analizzato, il che significa che i moduli utilizzati nel thread principale e in un worker devono essere analizzati una sola volta.

Il passaggio ai moduli JavaScript consente inoltre l'utilizzo dell'importazione dinamica per il caricamento lento del codice senza bloccare l'esecuzione del worker. L'importazione dinamica è molto più esplicita rispetto all'utilizzo di importScripts() per caricare le dipendenze, poiché le esportazioni del modulo importato vengono restituite anziché fare affidamento su variabili globali.

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;
}

Per garantire prestazioni ottimali, il metodo importScripts() precedente non è disponibile nei worker del modulo. Il passaggio ai worker per l'utilizzo dei moduli JavaScript comporta il caricamento di tutto il codice in modalità restrittiva. Un'altra modifica significativa è che il valore di this nell'ambito di primo livello di un modulo JavaScript è undefined, mentre nei worker classici il valore è l'ambito globale del worker. Fortunatamente, c'è sempre stato un self globale che fornisce un riferimento all'ambito globale. È disponibile in tutti i tipi di worker, inclusi i service worker, nonché nel DOM.

Precarica i worker con modulepreload

Un miglioramento sostanziale delle prestazioni offerto dai worker dei moduli è la capacità di precaricare i worker e le loro dipendenze. Con i worker del modulo, gli script vengono caricati ed eseguiti come moduli JavaScript standard, il che significa che possono essere precaricati e persino pre-analizzati utilizzando 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>

I moduli precaricati possono essere utilizzati anche dal thread principale e dai worker del modulo. Questo è utile per i moduli importati in entrambi i contesti o nei casi in cui non sia possibile sapere in anticipo se un modulo verrà utilizzato nel thread principale o in un worker.

In precedenza, le opzioni disponibili per il precaricamento degli script dei worker web erano limitate e non necessariamente affidabili. I worker classici avevano il proprio tipo di risorsa "worker" per il precaricamento, ma nessun browser ha implementato <link rel="preload" as="worker">. Di conseguenza, la tecnica principale disponibile per il precaricamento dei web worker era l'utilizzo di <link rel="prefetch">, che si basava interamente sulla cache HTTP. Quando utilizzato in combinazione con le intestazioni di memorizzazione nella cache corrette, consentiva di evitare che l'istanza del worker dovesse attendere per scaricare lo script worker. Tuttavia, a differenza di modulepreload, questa tecnica non supportava il precaricamento o il pre-analisi.

E i lavoratori condivisi?

I lavoratori condivisi sono stati aggiornati con il supporto per i moduli JavaScript a partire da Chrome 83. Come per i worker dedicati, la creazione di un worker condiviso con l'opzione {type:"module"} ora carica lo script worker come modulo anziché come script classico:

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

Prima del supporto dei moduli JavaScript, il costruttore SharedWorker() prevedeva solo un URL e un argomento name facoltativo. Questa operazione continuerà a funzionare per l'utilizzo classico dei worker condivisi; tuttavia, la creazione di worker condivisi di moduli richiede l'utilizzo del nuovo argomento options. Le opzioni disponibili sono le stesse per un worker dedicato, inclusa l'opzione name che sostituisce l'argomento name precedente.

E il service worker?

La specifica del service worker è già stata aggiornata per supportare l'accettazione di un modulo JavaScript come punto di ingresso, utilizzando la stessa opzione {type:"module"} dei worker del modulo, ma questa modifica deve ancora essere implementata nei browser. Sarà possibile creare un'istanza di un service worker utilizzando un modulo JavaScript con il seguente codice:

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

Ora che la specifica è stata aggiornata, i browser stanno iniziando a implementare il nuovo comportamento. Questa operazione richiede tempo perché l'introduzione dei moduli JavaScript nel Service worker comporta alcune complicazioni aggiuntive. La registrazione dei service worker deve confrontare gli script importati con le versioni precedenti memorizzate nella cache per determinare se attivare un aggiornamento e questo aspetto deve essere implementato per i moduli JavaScript quando vengono utilizzati per i service worker. Inoltre, in alcuni casi i service worker devono essere in grado di aggirare la cache per gli script durante il controllo della disponibilità di aggiornamenti.

Risorse aggiuntive e ulteriori approfondimenti