Spostare le operazioni pesanti nei thread in background è ora più semplice con i moduli JavaScript nei web worker.
JavaScript è single-threaded, il che significa che può eseguire una sola operazione alla volta. Questo approccio è intuitivo e funziona bene per molti casi sul web, ma può diventare problematico quando dobbiamo svolgere attività pesanti come l'elaborazione, l'analisi, il calcolo o l'analisi dei dati. Man mano che sul web vengono distribuite applicazioni sempre più complesse, aumenta la necessità di elaborazione multithread.
Sulla piattaforma web, la primitiva principale per il threading e il parallelismo è l'API Web Workers. I worker sono un'astrazione leggera sopra i thread del sistema operativo che espongono un'API di passaggio di messaggi per la comunicazione tra thread. Ciò può essere estremamente utile quando si eseguono calcoli costosi o si lavora su set di dati di grandi dimensioni, consentendo al thread principale di funzionare senza problemi durante l'esecuzione delle operazioni costose su uno o più thread in background.
Ecco un esempio tipico 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. Ciò significa che i service worker hanno un eccellente supporto del browser e sono ben ottimizzati, ma anche che sono molto precedenti ai moduli JavaScript. Poiché non esisteva un sistema di moduli quando sono stati progettati i worker, l'API per il caricamento del codice in un worker e la composizione degli script è rimasta simile agli approcci di caricamento sincrono degli script comuni nel 2009.
Cronologia: lavoratori classici
Il costruttore Worker accetta un URL di script
classico, che è
relativo all'URL del documento. Restituisce immediatamente un riferimento alla nuova istanza del worker,
che espone un'interfaccia di messaggistica e un metodo terminate()
che arresta immediatamente e
distrugge il worker.
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. Esegue anche script
nell'ambito globale come un tag <script>
classico, 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 sempre avuto un effetto sproporzionato sull'architettura di un'applicazione. Gli sviluppatori hanno dovuto creare strumenti e soluzioni alternative intelligenti per poter
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 racchiudono i moduli in funzioni per evitare conflitti di variabili e simulare
importazioni ed esportazioni di dipendenze.
Inserisci i lavoratori del modulo
In Chrome 80 è disponibile una nuova modalità per i web worker con i vantaggi di ergonomia e prestazioni dei moduli
JavaScript, chiamati module worker. 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 dei moduli sono moduli JavaScript standard, possono utilizzare le 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, il che consente di caricare interi alberi di moduli in parallelo. Il caricamento dei moduli memorizza nella cache 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 anche l'utilizzo dell'importazione
dinamica per il caricamento differito del codice senza bloccare l'esecuzione del worker. L'importazione dinamica è molto più esplicita rispetto all'utilizzo di importScripts()
per caricare le dipendenze,
poiché vengono restituiti gli export del modulo importato 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 vecchio metodo importScripts()
non è disponibile nei service worker. Il passaggio dei worker all'utilizzo dei moduli JavaScript comporta il caricamento di tutto il codice in modalità
rigida. Un altro
cambiamento degno di nota è 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, esiste sempre una variabile globale self
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 significativo delle prestazioni offerto dai worker dei moduli è la possibilità di precaricare
i worker e le relative dipendenze. Con i service worker dei moduli, gli script vengono caricati ed eseguiti come moduli JavaScript standard, il che significa che possono essere precaricati e persino analizzati in anticipo 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 sia dal thread principale sia dai worker dei moduli. Ciò è utile per i moduli 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
necessariamente affidabili. I service worker classici avevano un 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. Se utilizzato in combinazione con le intestazioni di memorizzazione nella cache corrette, ciò ha reso possibile
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.
Che cosa succede ai lavoratori condivisi?
A partire da Chrome 83, i service worker condivisi sono stati aggiornati con il supporto dei moduli JavaScript. Come per i worker dedicati, la costruzione 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 dei worker condivisi classici; tuttavia,
la creazione di worker condivisi del modulo richiede l'utilizzo del nuovo argomento options
. Le opzioni
disponibili
sono le stesse di un lavoratore 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 module worker,
tuttavia questa modifica deve ancora essere implementata nei browser. Una volta eseguita questa operazione, 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. Ci vuole tempo perché ci sono alcune complicazioni aggiuntive associate all'introduzione dei moduli JavaScript nel service worker. La registrazione del service worker deve confrontare gli script importati con le versioni precedenti memorizzate nella cache per determinare se attivare un aggiornamento. Questa operazione deve essere implementata per i moduli JavaScript quando vengono utilizzati per i service worker. Inoltre, i service worker devono essere in grado di ignorare la cache per gli script in alcuni casi durante il controllo degli aggiornamenti.