Utilizza i web worker per eseguire JavaScript dal thread principale del browser

Un'architettura off-main-thread può migliorare significativamente l'affidabilità e l'esperienza utente della tua app.

Negli ultimi 20 anni, il web si è evoluto in modo significativo, passando da documenti statici con pochi stili e immagini ad applicazioni complesse e dinamiche. Tuttavia, una cosa è rimasta in gran parte invariata: abbiamo un solo thread per scheda del browser (con alcune eccezioni) per eseguire il rendering dei nostri siti ed eseguire il nostro JavaScript.

Di conseguenza, il thread principale è diventato incredibilmente sovraccarico. Man mano che le app web diventano più complesse, il thread principale diventa un collo di bottiglia significativo per le prestazioni. A peggiorare le cose, il tempo necessario per eseguire il codice sul thread principale per un determinato utente è quasi completamente imprevedibile perché le funzionalità del dispositivo hanno un impatto enorme sulle prestazioni. Questa imprevedibilità aumenterà man mano che gli utenti accederanno al web da un insieme sempre più diversificato di dispositivi, dai feature phone con risorse limitate ai dispositivi di punta ad alte prestazioni e con alta frequenza di aggiornamento.

Se vogliamo che le app web sofisticate rispettino in modo affidabile le linee guida sul rendimento, come Core Web Vitals, che si basa su dati empirici sulla percezione e la psicologia umana, abbiamo bisogno di modi per eseguire il nostro codice al di fuori del thread principale (OMT).

Perché usare i web worker?

JavaScript è, per impostazione predefinita, un linguaggio single-thread che esegue le attività sul thread principale. Tuttavia, i web worker forniscono una sorta di via di fuga dal thread principale consentendo agli sviluppatori di creare thread separati per gestire il lavoro al di fuori del thread principale. Sebbene l'ambito dei web worker sia limitato e non offrano l'accesso diretto al DOM, possono essere estremamente utili se è necessario svolgere un lavoro considerevole che altrimenti sovraccaricherebbe il thread principale.

Per quanto riguarda i Segnali web essenziali, l'esecuzione del lavoro al di fuori del thread principale può essere vantaggiosa. In particolare, lo scaricamento del lavoro dal thread principale ai web worker può ridurre la contesa per il thread principale, il che può migliorare la metrica di reattività Interaction to Next Paint (INP) di una pagina. Quando il thread principale ha meno lavoro da elaborare, può rispondere più rapidamente alle interazioni degli utenti.

Un minor lavoro del thread principale, soprattutto durante l'avvio, comporta anche un potenziale vantaggio per il Largest Contentful Paint (LCP) riducendo le attività lunghe. Il rendering di un elemento LCP richiede tempo del thread principale, per il rendering di testo o immagini, che sono elementi LCP frequenti e comuni. Se riduci il lavoro del thread principale complessivo, puoi assicurarti che l'elemento LCP della pagina abbia meno probabilità di essere bloccato da un lavoro costoso che potrebbe essere gestito da un web worker.

Threading con i web worker

Altre piattaforme in genere supportano il lavoro parallelo consentendoti di assegnare una funzione a un thread, che viene eseguita in parallelo con il resto del programma. Puoi accedere alle stesse variabili da entrambi i thread e l'accesso a queste risorse condivise può essere sincronizzato con mutex e semafori per evitare race condition.

In JavaScript, possiamo ottenere funzionalità simili dai web worker, disponibili dal 2007 e supportati da tutti i principali browser dal 2012. I web worker vengono eseguiti in parallelo con il thread principale, ma a differenza dei thread del sistema operativo, non possono condividere variabili.

Per creare un web worker, passa un file al costruttore del worker, che inizia a eseguire il file in un thread separato:

const worker = new Worker("./worker.js");

Comunica con il web worker inviando messaggi utilizzando l'API postMessage. Passa il valore del messaggio come parametro nella chiamata postMessage e poi aggiungi un listener di eventi di messaggi al worker:

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

Per inviare un messaggio al thread principale, utilizza la stessa API postMessage nel web worker e configura un listener di eventi nel thread principale:

main.js

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

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

A dire il vero, questo approccio è un po' limitato. Storicamente, i web worker sono stati utilizzati principalmente per spostare un singolo elemento di lavoro pesante dal thread principale. Gestire più operazioni con un singolo web worker diventa rapidamente complicato: devi codificare non solo i parametri, ma anche l'operazione nel messaggio e devi tenere traccia delle corrispondenze tra risposte e richieste. Questa complessità è probabilmente il motivo per cui i web worker non sono stati adottati più ampiamente.

Tuttavia, se potessimo rimuovere alcune delle difficoltà di comunicazione tra il thread principale e i web worker, questo modello potrebbe essere perfetto per molti casi d'uso. Fortunatamente, esiste una libreria che fa proprio questo.

Comlink è una libreria il cui scopo è consentirti di utilizzare i web worker senza dover pensare ai dettagli di postMessage. Comlink ti consente di condividere variabili tra i web worker e il thread principale quasi come altri linguaggi di programmazione che supportano i thread.

Configura Comlink importandolo in un web worker e definendo un insieme di funzioni da esporre al thread principale. A questo punto, importa Comlink nel thread principale, esegui il wrapping del worker e accedi alle funzioni esposte:

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

La variabile api nel thread principale si comporta come quella nel web worker, tranne per il fatto che ogni funzione restituisce una promessa per un valore anziché il valore stesso.

Quale codice devi spostare in un web worker?

I web worker non hanno accesso al DOM e a molte API come WebUSB, WebRTC o Web Audio, quindi non puoi inserire in un worker parti della tua app che si basano su questo accesso. Tuttavia, ogni piccolo frammento di codice spostato in un worker offre più spazio sul thread principale per gli elementi che devono essere presenti, come l'aggiornamento dell'interfaccia utente.

Un problema per gli sviluppatori web è che la maggior parte delle app web si basa su un framework UI come Vue o React per orchestrare tutto nell'app; tutto è un componente del framework e quindi è intrinsecamente legato al DOM. Ciò renderebbe difficile la migrazione a un'architettura OMT.

Tuttavia, se passiamo a un modello in cui i problemi dell'interfaccia utente sono separati da altri problemi, come la gestione dello stato, i web worker possono essere molto utili anche con le app basate su framework. È esattamente l'approccio adottato con PROXX.

PROXX: un case study di OMT

Il team di Google Chrome ha sviluppato PROXX come clone di Campo Minato che soddisfa i requisiti delle app web progressive, tra cui il funzionamento offline e un'esperienza utente coinvolgente. Purtroppo, le prime versioni del gioco avevano prestazioni scarse su dispositivi con risorse limitate come i feature phone, il che ha portato il team a rendersi conto che il thread principale era un collo di bottiglia.

Il team ha deciso di utilizzare i web worker per separare lo stato visivo del gioco dalla sua logica:

  • Il thread principale gestisce il rendering di animazioni e transizioni.
  • Un web worker gestisce la logica di gioco, che è puramente computazionale.

OMT ha avuto effetti interessanti sulle prestazioni del feature phone PROXX. Nella versione non OMT, la UI viene bloccata per sei secondi dopo l'interazione dell'utente. Non c'è alcun feedback e l'utente deve attendere sei secondi prima di poter fare altro.

Tempo di risposta della UI nella versione non OMT di PROXX.

Nella versione OMT, invece, il gioco impiega dodici secondi per completare un aggiornamento dell'interfaccia utente. Anche se sembra una perdita di prestazioni, in realtà porta a un aumento del feedback all'utente. Il rallentamento si verifica perché l'app invia più frame rispetto alla versione non OMT, che non invia alcun frame. L'utente sa quindi che sta succedendo qualcosa e può continuare a giocare mentre l'interfaccia utente si aggiorna, migliorando notevolmente l'esperienza di gioco.

Tempo di risposta della UI nella versione OMT di PROXX.

Si tratta di un compromesso consapevole: offriamo agli utenti di dispositivi con risorse limitate un'esperienza che sembra migliore senza penalizzare gli utenti di dispositivi di fascia alta.

Implicazioni di un'architettura OMT

Come mostra l'esempio di PROXX, OMT consente alla tua app di funzionare in modo affidabile su una gamma più ampia di dispositivi, ma non la rende più veloce:

  • Stai solo spostando il lavoro dal thread principale, non riducendolo.
  • Il sovraccarico di comunicazione aggiuntivo tra il web worker e il thread principale a volte può rallentare leggermente le operazioni.

Valuta i compromessi

Poiché il thread principale è libero di elaborare le interazioni degli utenti, come lo scorrimento, durante l'esecuzione di JavaScript, il numero di frame eliminati è inferiore, anche se il tempo di attesa totale potrebbe essere leggermente più lungo. Far attendere un po' l'utente è preferibile a eliminare un frame, perché il margine di errore è inferiore per i frame eliminati: l'eliminazione di un frame avviene in millisecondi, mentre hai centinaia di millisecondi prima che un utente percepisca il tempo di attesa.

A causa dell'imprevedibilità delle prestazioni sui vari dispositivi, lo scopo dell'architettura OMT è in realtà quello di ridurre il rischio, rendendo la tua app più solida di fronte a condizioni di runtime altamente variabili, non quello di ottenere vantaggi in termini di prestazioni dalla parallelizzazione. L'aumento della resilienza e i miglioramenti all'esperienza utente valgono più di qualsiasi piccolo compromesso in termini di velocità.

Una nota sugli strumenti

I web worker non sono ancora mainstream, quindi la maggior parte degli strumenti per moduli, come webpack e Rollup, non li supporta immediatamente. (Parcel sì). Fortunatamente, esistono plug-in per far funzionare i web worker con webpack e Rollup:

Riepilogo

Per assicurarci che le nostre app siano il più affidabili e accessibili possibile, soprattutto in un mercato sempre più globalizzato, dobbiamo supportare i dispositivi con risorse limitate, che sono il modo in cui la maggior parte degli utenti accede al web a livello globale. OMT offre un modo promettente per aumentare le prestazioni su questi dispositivi senza influire negativamente sugli utenti di dispositivi di fascia alta.

Inoltre, OMT offre vantaggi secondari:

  • Trasferisce i costi di esecuzione di JavaScript in un thread separato.
  • Sposta i costi di analisi, il che significa che l'interfaccia utente potrebbe avviarsi più rapidamente. In questo modo, potresti ridurre il valore di First Contentful Paint o persino di Time to Interactive, il che a sua volta può aumentare il punteggio di Lighthouse.

I web worker non devono essere spaventosi. Strumenti come Comlink semplificano il lavoro dei worker e li rendono una scelta valida per un'ampia gamma di applicazioni web.