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

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

Negli ultimi 20 anni, il web si è evoluto notevolmente 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 codice JavaScript.

Di conseguenza, il thread principale è diventato incredibilmente sovraccaricato. Inoltre, con l'aumentare della complessità delle app web, il thread principale diventa un collo di bottiglia significativo per le prestazioni. Come se non bastasse, 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à non potrà che crescere man mano che gli utenti accederanno al web da una gamma sempre più diversificata di dispositivi, dai feature phone iper-vincolati ai dispositivi più potenti e con una frequenza di aggiornamento elevata.

Se vogliamo che app web sofisticate soddisfino in modo affidabile le linee guida per le prestazioni come Core Web Vitals, che si basa su dati empirici sulla percezione e sulla psicologia umana, abbiamo bisogno di metodi 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 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 offra accesso diretto al DOM, possono essere di grande aiuto se è necessario eseguire un lavoro considerevole che altrimenti sovraccaricherà il thread principale.

Per quanto riguarda i Segnali web essenziali, può essere utile eseguire il lavoro al di fuori del thread principale. In particolare, trasferire il lavoro dal thread principale ai worker web può ridurre i conflitti per il thread principale, il che può migliorare la metrica di reattività Interazione con Next Paint (INP) di una pagina. Quando il thread principale ha meno lavoro da elaborare, può rispondere più rapidamente alle interazioni degli utenti.

Una minore attività nel thread principale, in particolare durante l'avvio, comporta anche un potenziale vantaggio per il Largest Contentful Paint (LCP), in quanto riduce le attività lunghe. Il rendering di un elemento LCP richiede tempo del thread principale, sia per il rendering di testo o immagini, che sono elementi LCP frequenti e comuni, sia per ridurre il lavoro complessivo del thread principale, puoi assicurarti che l'elemento LCP della tua pagina abbia meno probabilità di essere bloccato da un lavoro costoso che un web worker potrebbe gestire.

Thread con i worker web

Altre piattaforme in genere supportano il lavoro parallelo consentendoti di assegnare a un thread una funzione 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 condizioni di gara.

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

Per creare un web worker, passa un file al costruttore del worker, che avvia l'esecuzione del 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 a postMessage e poi aggiungi un gestore di eventi dei 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 gestore 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);
});

Bisogna ammettere che questo approccio è un po' limitato. Storicamente, i worker web sono stati utilizzati principalmente per spostare un singolo lavoro pesante dal thread principale. Cercare di gestire più operazioni con un singolo worker web diventa rapidamente difficile da gestire: è necessario codificare non solo i parametri, ma anche l'operazione nel messaggio, e occorre tenere i dati contabili per abbinare le risposte alle richieste. È probabile che questa complessità sia la ragione per cui i web worker non siano 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 ideale per molti casi d'uso. Fortunatamente, esiste 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 ti consente di condividere variabili tra i web worker e il thread principale quasi come altri linguaggi di programmazione che supportano la threading.

Configura Comlink importandolo in un worker web e definendo un insieme di funzioni da esporre al thread principale. Importa quindi Comlink nel thread principale, avvolgi il 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 worker web?

I worker web non hanno accesso al DOM e a molte API come WebUSB, WebRTC o Web Audio, pertanto non puoi inserire in un worker parti della tua app che si basano su questo accesso. Tuttavia, ogni piccolo frammento di codice spostato su un worker acquista più spazio sul thread principale per ciò che deve esserci, 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ò potrebbe rendere difficile la migrazione a un'architettura OMT.

Tuttavia, se passiamo a un modello in cui i problemi relativi all'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. Questo è esattamente l'approccio adottato con PROXX.

PROXX: un case study sull'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 non funzionavano bene su dispositivi con limitazioni come i cellulari, il che ha portato il team a capire 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 worker web gestisce la logica di gioco, che è puramente computazionale.

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

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

Nella versione OMT, invece, il gioco impiega dodici secondi per completare un aggiornamento dell'interfaccia utente. Sebbene possa sembrare una perdita di rendimento, 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, il che non prevede la spedizione di 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 dell'interfaccia utente nella versione OMT di PROXX.

Si tratta di un compromesso consapevole: offriamo agli utenti di dispositivi con limitazioni un'esperienza più piacevole senza penalizzare gli utenti di dispositivi di fascia alta.

Implicazioni di un'architettura OMT

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

  • Dovrai solo spostare il lavoro dal thread principale, non ridurre il lavoro.
  • L'overhead di comunicazione aggiuntivo tra il worker web e il thread principale a volte può rallentare le cose.

Valutare i compromessi

Dal momento che il thread principale è libero di elaborare le interazioni degli utenti come lo scorrimento mentre JavaScript è in esecuzione, ci sono meno frame interrotti anche se il tempo di attesa totale potrebbe essere leggermente più lungo. È preferibile far attendere un po' l'utente rispetto a eliminare un frame perché il margine di errore è inferiore 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 dispositivi, l'obiettivo dell'architettura OMT è in realtà ridurre i rischi, rendendo l'app più solida in presenza di condizioni di runtime molto variabili, non i vantaggi delle prestazioni della parallizzazione. 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 worker web non sono ancora mainstream, quindi la maggior parte degli strumenti per i moduli, come webpack e Rollup, non li supporta immediatamente. (Parcel invece sì). Fortunatamente, esistono plug-in per far funzionare i web worker con webpack e Rollup:

Riepilogo

Per garantire che le nostre app siano il più affidabili e accessibili possibile, soprattutto in un mercato sempre più globalizzato, dobbiamo supportare i dispositivi con limitazioni, che sono quelli tramite cui la maggior parte degli utenti accede al web a livello globale. L'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 anche dei vantaggi secondari:

  • Sposta 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ù velocemente. Ciò potrebbe ridurre il First Contentful Paint o anche il Time to Interactive, il che a sua volta può aumentare il punteggio di Lighthouse.

I web worker non devono per forza essere complicati. Strumenti come Comlink sgravano i lavoratori e rappresentano una scelta valida per un'ampia gamma di applicazioni web.