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 drasticamente, passando da documenti statici con alcuni stili e immagini ad applicazioni complesse e dinamiche. Tuttavia, una cosa è rimasta pressoché invariata: abbiamo un solo thread per scheda del browser (con alcune eccezioni) per svolgere il lavoro di rendering dei nostri siti e di eseguire il nostro codice JavaScript.

Di conseguenza, il thread principale è diventato incredibilmente sovraccaricato. E man mano che le app web diventano più complesse, il thread principale diventa un importante collo di bottiglia 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 accedono 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 rispettino 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 escape hatch dal thread principale consentendo agli sviluppatori di creare thread separati per gestire il lavoro dal thread principale. Sebbene l'ambito dei web worker sia limitato e non offra accesso diretto al DOM, possono essere estremamente utili se c'è un lavoro considerevole che altrimenti andrebbe a sovraccaricare il thread principale.

Per quanto riguarda i Segnali web essenziali, può essere utile eseguire il lavoro dal 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.

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 comuni e frequenti. Inoltre, riducendo il lavoro complessivo del thread principale, puoi assicurarti che l'elemento LCP della pagina venga bloccato da operazioni costose che potrebbero essere gestite da un worker web.

Threading con 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 le racecondition.

In JavaScript, possiamo ottenere una funzionalità pressoché simile dai web worker, che sono in uso dal 2007 e sono supportate 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 le variabili.

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

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

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

È vero che questo approccio è un po' limitato. Originariamente, i web worker venivano utilizzati principalmente per spostare una singola parte del 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. Questa complessità è probabilmente il motivo per cui i web worker non sono stati adottati in modo più ampio.

Ma se riuscissimo a eliminare alcune delle difficoltà di comunicazione tra il thread principale e i web worker, questo modello potrebbe essere perfetto 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 doverti preoccupare dei dettagli di postMessage. Comlink consente di condividere variabili tra i web worker e il thread principale quasi come gli altri linguaggi di programmazione che supportano i thread.

Puoi configurare Comlink importandolo in un worker web e definendo un insieme di funzioni da esporre al thread principale. Quindi importi Comlink sul thread principale, esegui il wrapping del worker e ottieni 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 worker web, ad eccezione del fatto che ogni funzione restituisce una promessa per un valore anziché per il valore stesso.

Quale codice devi passare a un worker web?

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 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 di UI come Vue o React per orchestrare tutto nell'app; tutto è un componente del framework, di conseguenza è intrinsecamente legato al DOM. Ciò sembrerebbe difficile eseguire 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 abbastanza 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, ad esempio il funzionamento offline e un'esperienza utente coinvolgente. Sfortunatamente, le prime versioni del gioco non funzionavano bene su dispositivi soggetti a limitazioni come i feature phone, il che ha portato il team a capire che il filo conduttore 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.
di Gemini Advanced.

OMT ha avuto effetti interessanti sulle prestazioni dei feature phone di PROXX. Nella versione non OMT, la UI viene bloccata per sei secondi dopo l'interazione dell'utente. Non viene fornito alcun feedback e l'utente deve attendere tutti i sei secondi 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. Sebbene sembri una perdita del rendimento, in realtà porta a un aumento del feedback da parte dell'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 man mano che l'interfaccia utente si aggiorna, migliorando notevolmente l'esperienza di gioco.

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

Si tratta di un compromesso consapevole: offriamo agli utenti di dispositivi soggetti a limitazioni un'esperienza che mi senta meglio senza penalizzare gli utenti di dispositivi di fascia alta.

Implicazioni di un'architettura OMT

Come mostrato nell'esempio PROXX, OMT consente l'esecuzione affidabile della tua app 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

Poiché 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 aspettare un po' all'utente anziché eliminare un frame perché il margine di errore è inferiore per i frame interrotti: l'eliminazione di un frame avviene in millisecondi, mentre l'utente ha a disposizione centinaia di millisecondi prima che un utente percepisca il tempo di attesa.

A causa dell'imprevedibilità delle prestazioni su tutti i dispositivi, l'obiettivo dell'architettura OMT è proprio quello di ridurre i rischi, rendere la tua app più solida a fronte di condizioni di runtime altamente variabili, non dei vantaggi in termini di prestazioni del caricamento in contemporanea. L'aumento della resilienza e i miglioramenti dell'esperienza utente sono più che utili per scendere a compromessi in termini di velocità.

Una nota sugli strumenti

I web worker non sono ancora mainstream, quindi la maggior parte degli strumenti dei moduli, come webpack e Rollup, non li supporta subito. (Tuttavia, Parcel lo fa!) Fortunatamente, esistono plug-in per consentire ai web worker di funzionare con webpack e Rollup:

Riepilogo

Per assicurarci che le nostre app siano il più possibile affidabili e accessibili, soprattutto in un mercato sempre più globalizzato, dobbiamo supportare i dispositivi soggetti a limitazioni, 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 offre anche dei vantaggi secondari:

  • Sposta i costi di esecuzione di JavaScript in un thread separato.
  • Sposta l'analisi dei costi, il che significa che la UI potrebbe avviarsi più velocemente. Ciò potrebbe ridurre la metrica First Contentful Paint e anche Tempo all'interattività, il che può a sua volta aumentare Lighthouse.

I web worker non devono necessariamente spaventarsi. Strumenti come Comlink eliminano il lavoro dei lavoratori e li rendono una scelta attuabile per una vasta gamma di applicazioni web.

Immagine hero da Unsplash, di James Peacock.