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

Un'architettura al di fuori del thread principale può migliorare notevolmente l'affidabilità e l'esperienza utente della tua app.

Surma
Surma

Negli ultimi 20 anni, il web si è evoluto drasticamente da documenti statici con pochi stili e immagini ad applicazioni complesse e dinamiche. Tuttavia, una cosa è rimasta sostanzialmente invariata: abbiamo un solo thread per scheda del browser (con alcune eccezioni) per il rendering dei nostri siti e per l'esecuzione di JavaScript.

Di conseguenza, il thread principale è diventato incredibilmente sovraccarico. Man mano che le app web diventano più complesse, il thread principale si trasforma in un collo di bottiglia significativo per le prestazioni. A peggiorare le cose, il tempo necessario per eseguire il codice nel thread principale di un determinato utente è quasi completamente imprevedibile perché le funzionalità del dispositivo hanno un effetto enorme sulle prestazioni. Questa imprevedibilità crescerà solo quando gli utenti accedono al Web da una serie di dispositivi sempre più diversificata, dai feature phone iper-limitati ai dispositivi di punta con potenza elevata e frequenza di aggiornamento elevata.

Se vogliamo che le app web sofisticate soddisfino in modo affidabile linee guida sulle prestazioni come i Segnali web essenziali, che si basano su dati empirici relativi alla percezione umana e alla psicologia, abbiamo bisogno di modi per eseguire il nostro codice dal thread principale (OMT).

Perché i web worker?

Per impostazione predefinita, JavaScript è un linguaggio a thread singolo che esegue attività sul thread principale. Tuttavia, i web worker offrono una sorta di escape dal thread principale consentendo agli sviluppatori di avviare thread separati per gestire il lavoro dal thread principale. Sebbene l'ambito dei web worker sia limitato e non offra accesso diretto al DOM, questi possono essere estremamente vantaggiosi se è necessario fare molto lavoro che altrimenti trasformerebbe il thread principale.

Per quanto riguarda i Segnali web essenziali, può essere utile eseguire il lavoro a partire dal thread principale. In particolare, trasferire il lavoro dal thread principale ai web worker può ridurre la contesa per il thread principale, il che può migliorare importanti metriche di reattività come Interaction to Next Paint (INP) e First Input Delay (FID). Quando il thread principale ha meno lavoro da elaborare, può rispondere più rapidamente alle interazioni degli utenti.

Un minore lavoro del thread principale, soprattutto durante l'avvio, comporta anche un potenziale vantaggio per la Largest Contentful Paint (LCP), in quanto riduce le attività lunghe. Il rendering di un elemento LCP richiede un tempo di thread principale per il rendering di testo o immagini, che sono elementi LCP frequenti e comuni. Inoltre, riducendo il lavoro complessivo del thread principale, puoi assicurarti che l'elemento LCP della tua pagina abbia meno probabilità di essere bloccato a causa di operazioni costose che un web worker potrebbe gestire.

Thread con i web worker

Altre piattaforme in genere supportano il lavoro parallelo consentendo 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à quasi simili dai web worker, che sono 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 del threading del sistema operativo, non possono condividere variabili.

Per creare un web worker, passa un file al costruttore worker, che avvia l'esecuzione del file in un thread separato:

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

Comunica con il web worker inviando messaggi tramite l'API postMessage. Passa il valore del messaggio come parametro nella chiamata postMessage, quindi aggiungi un listener di eventi del messaggio 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 worker web e imposta 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);
});

Devo ammettere che questo approccio è alquanto limitato. Storicamente, i web worker sono stati utilizzati principalmente per spostare un singolo pezzo di lavoro pesante dal thread principale. Cercare di gestire più operazioni con un singolo web worker diventa subito ingombrante: devi codificare non solo i parametri ma anche l'operazione nel messaggio e fare i conti per abbinare le risposte alle richieste. Questa complessità è probabilmente il motivo per cui i lavoratori web non sono stati adottati su scala più ampia.

Ma se riuscissimo a eliminare alcune delle difficoltà di comunicazione tra il thread principale e i web worker, questo modello potrebbe essere un'ottima soluzione per molti casi d'uso. Per fortuna c'è una libreria che fa proprio questo!

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

Puoi configurare Comlink importandolo in un web worker e definendo un insieme di funzioni da esporre nel thread principale. Successivamente potrai importare Comlink nel thread principale, eseguire il wrapping del worker e ottenere l'accesso 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é per il valore stesso.

Quale codice dovresti passare a 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 parti della tua app che dipendono da questo accesso in un worker. Tuttavia, ogni piccola porzione di codice trasferita a un worker acquista più margine di miglioramento nel thread principale per gli elementi che deve essere lì, come l'aggiornamento dell'interfaccia utente.

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

Tuttavia, se passiamo a un modello in cui i problemi dell'interfaccia utente sono separati da altri, come la gestione dello stato, i web worker possono essere molto utili anche con le app basate su framework. Questo è 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 lavoro offline e un'esperienza utente coinvolgente. Sfortunatamente, le prime versioni del gioco avevano un rendimento scarso sui dispositivi limitati come i feature phone, cosa che ha portato il team a rendersi conto che il problema principale era un collo di bottiglia.

Il team ha deciso di utilizzare 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 dei feature phone di PROXX. Nella versione non OMT, l'interfaccia utente viene bloccata per sei secondi dopo l'interazione dell'utente. Non ci sono feedback e l'utente deve attendere sei secondi completi prima di poter fare qualcos'altro.

Tempo di risposta dell'interfaccia utente nella versione non OMT di PROXX.

Nella versione OMT, tuttavia, il gioco richiede dodici secondi per completare un aggiornamento dell'interfaccia utente. Anche se questa sembrerebbe una perdita di prestazioni, in realtà porta a un maggiore feedback per l'utente. Il rallentamento si verifica perché l'app sta inviando più frame rispetto alla versione non OMT, che non invia alcun frame. L'utente, quindi, sa che sta accadendo qualcosa e può continuare a giocare man mano che l'interfaccia utente si aggiorna, migliorando notevolmente il gioco.

Tempo di risposta dell'interfaccia utente nella versione OMT di PROXX.

Si tratta di un compromesso consapevole: offriamo agli utenti di dispositivi limitati un'esperienza percepita meglio senza penalizzare gli utenti dei dispositivi di fascia alta.

Implicazioni di un'architettura OMT

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

  • Stai semplicemente spostando il lavoro dal thread principale, non riducendo il lavoro.
  • L'overhead di comunicazione supplementare tra il worker web e il thread principale a volte può rallentare in modo leggermente le cose.

Pensare ai compromessi

Poiché il thread principale è libero di elaborare le interazioni degli utenti, ad esempio lo scorrimento mentre JavaScript è in esecuzione, ci sono meno frame ignorati anche se il tempo di attesa totale potrebbe essere leggermente più lungo. È preferibile far sì che l'utente attenda un po' prima che un frame venga eliminato perché il margine di errore è minore per i frame persi: 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, l'obiettivo dell'architettura OMT è davvero ridurre i rischi, rendendo la tua app più solida di fronte a condizioni di runtime altamente variabili, e non sui vantaggi in termini di prestazioni del caricamento in contemporanea. L'aumento della resilienza e i miglioramenti dell'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 i moduli, come webpack e Rollup, non li supporta da subito. (ma la soluzione Parcel lo fa!) Fortunatamente esistono dei plug-in che consentono ai web worker di lavorare 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 dispositivi limitati, 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 dei dispositivi di fascia alta.

Inoltre, OMT ha dei vantaggi secondari:

  • Sposta i costi di esecuzione di JavaScript in un thread separato.
  • Sposta i costi di analisi, il che significa che la UI potrebbe avviarsi più velocemente. Ciò potrebbe ridurre il livello di First Contentful Paint o persino il tempo all'interattività, che a sua volta può aumentare il punteggio di Lighthouse.

I web worker non devono necessariamente spaventare. Strumenti come Comlink stanno eliminando il lavoro dei lavoratori e rendendoli una scelta pratica per un'ampia gamma di applicazioni web.

Immagine hero di Unsplash, di James Peacock.