Thread sul Web con i worker dei moduli

Spostare le attività più complesse 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 funzionalità intuitiva e funziona bene per molti casi sul web, ma può diventare problematico quando dobbiamo svolgere attività molto impegnative come l'elaborazione dei dati, l'analisi, il calcolo o l'analisi. Man mano che vengono distribuite sempre più applicazioni sul web, aumenta la necessità di elaborazione multi-thread.

Nella piattaforma web, la primitiva principale per il threading e il parallelismo è l'API WebWorkers. I worker sono una leggera astrazione superiore ai thread del sistema operativo che espongono un'API per la trasmissione dei messaggi per la comunicazione tra thread. Ciò può essere immensamente utile quando si eseguono calcoli costosi o si opera su grandi set di dati, in quanto consente un'esecuzione ottimale del thread principale mentre esegui le operazioni costose su uno o più thread in background.

Ecco un tipico esempio di utilizzo dei worker, in cui uno script worker ascolta i 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. Anche se ciò significa che i worker hanno un eccellente supporto del browser e sono ben ottimizzati, ciò significa anche che i moduli JavaScript sono già aggiornati da molto tempo. Poiché non esisteva un sistema di moduli al momento della progettazione dei worker, l'API per il caricamento del codice in un worker e la scrittura di script è rimasta simile agli approcci al caricamento degli script sincrono comuni nel 2009.

Cronologia: worker classici

Il costruttore del worker utilizza un URL 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 ed elimina il worker immediatamente.

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

All'interno dei web worker è disponibile una funzione importScripts() per caricare codice aggiuntivo, ma mette in pausa l'esecuzione del worker per recuperare e valutare ogni script. Inoltre, esegue gli script nell'ambito globale come un classico tag <script>, il che significa che le variabili di uno script possono essere sovrascritte da quelle di 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 web worker hanno da sempre imposto un effetto enorme sull'architettura di un'applicazione. Gli sviluppatori hanno dovuto creare strumenti e soluzioni alternative intelligenti per consentire l'utilizzo dei web worker senza rinunciare alle moderne pratiche di sviluppo. Ad esempio, i bundler come webpack incorporano un'implementazione del caricatore di moduli di piccole dimensioni nel codice generato che utilizza importScripts() per il caricamento del codice, ma aggrega i moduli nelle funzioni per evitare conflitti tra le variabili e simulare importazioni ed esportazioni delle dipendenze.

Inserisci worker modulo

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

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

Poiché i worker di modulo sono moduli JavaScript standard, possono utilizzare istruzioni di importazione ed esportazione. Come per 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 sono ottimizzati dai browser. Le dipendenze di un modulo possono essere caricate prima dell'esecuzione del modulo, in modo da poter caricare intere strutture di moduli in parallelo. Il caricamento dei moduli memorizza anche 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 di utilizzare l'importazione dinamica per il codice di caricamento lento senza bloccare l'esecuzione del worker. L'importazione dinamica è molto più esplicita dell'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 di moduli. Il passaggio dei worker a utilizzare i moduli JavaScript significa che tutto il codice viene caricato in modalità rigida. 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 e nel DOM.

Precarica i worker con modulepreload

Un sostanziale miglioramento delle prestazioni offerto dai worker di moduli è la capacità di precaricare i worker e le loro dipendenze. Con i worker di moduli, gli script vengono caricati ed eseguiti come moduli JavaScript standard, il che significa che possono essere precaricati e persino pre-analisi 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 dai worker del thread principale e del modulo. Ciò è utile per i moduli che vengono importati in entrambi i contesti o nei casi in cui non è 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 web worker erano limitate e non erano necessarie. I worker classici avevano il proprio tipo di risorsa "worker" per il precaricamento, ma nessun browser 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. Se utilizzata in combinazione con le intestazioni di memorizzazione nella cache corrette, ha consentito di evitare che l'istanza del worker debba attendere il download dello script worker. Tuttavia, a differenza di modulepreload, questa tecnica non supportava il precaricamento delle dipendenze o l'analisi preliminare.

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 di supportare i 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 di un worker dedicato, inclusa l'opzione name che sostituisce l'argomento name precedente.

E il service worker?

La specifica dei service worker è già stata aggiornata in modo da supportare l'accettazione di un modulo JavaScript come punto di ingresso, utilizzando la stessa opzione {type:"module"} dei worker di moduli, tuttavia questa modifica non è ancora stata implementata nei browser. A quel punto, sarà possibile creare un'istanza di un service worker utilizzando un modulo JavaScript utilizzando il seguente codice:

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

Ora che la specifica è stata aggiornata, i browser iniziano a implementare il nuovo comportamento. Ciò richiede tempo, perché ci sono alcune complicazioni aggiuntive associate all'introduzione dei moduli JavaScript per il service worker. La registrazione dei service worker deve confrontare gli script importati con le precedenti versioni memorizzate nella cache per determinare se attivare un aggiornamento e questa operazione deve essere implementata per i moduli JavaScript quando vengono utilizzati per i service worker. Inoltre, in certi 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 letture