Ottimizza le attività lunghe

Ti è stato detto di "non bloccare il thread principale" e di "suddividere le attività lunghe", ma cosa significa fare queste cose?

Data di pubblicazione: 30 settembre 2022, Ultimo aggiornamento: 19 dicembre 2024

I consigli comuni per mantenere veloci le app JavaScript tendono a ridursi ai seguenti:

  • "Non bloccare il thread principale."
  • "Suddividi le attività lunghe."

Questo è un ottimo consiglio, ma che cosa comporta? Inviare meno JavaScript è un'ottima cosa, ma questo si traduce automaticamente in interfacce utente più reattive? Forse sì, ma forse no.

Per capire come ottimizzare le attività in JavaScript, devi prima sapere cosa sono e come vengono gestite dal browser.

Che cos'è un'attività?

Un task è un qualsiasi lavoro discreto svolto dal browser. Questo lavoro include il rendering, l'analisi di HTML e CSS, l'esecuzione di JavaScript e altri tipi di lavoro su cui potresti non avere il controllo diretto. Tra tutti questi elementi, il codice JavaScript che scrivi è forse la fonte più grande di attività.

Una visualizzazione di un'attività come mostrato nel profiler del rendimento di Chrome DevTools. L'attività si trova in cima a uno stack, con un gestore di eventi di clic, una chiamata di funzione e altri elementi sottostanti. L'attività include anche un lavoro di rendering sul lato destro.
Un'attività avviata da un gestore di eventi click, mostrata nel profiler delle prestazioni di Chrome DevTools.

Le attività associate a JavaScript influiscono sulle prestazioni in un paio di modi:

  • Quando un browser scarica un file JavaScript durante l'avvio, mette in coda le attività per analizzare e compilare il codice JavaScript in modo che possa essere eseguito in un secondo momento.
  • In altri momenti della vita della pagina, le attività vengono messe in coda quando JavaScript esegue operazioni come la risposta alle interazioni tramite gestori di eventi, animazioni basate su JavaScript e attività in background come la raccolta di dati di analisi.

Tutto questo, ad eccezione dei web worker e di API simili, avviene nel thread principale.

Che cos'è il thread principale?

Il thread principale è il punto in cui viene eseguita la maggior parte delle attività nel browser e quasi tutto il codice JavaScript che scrivi.

Il thread principale può elaborare una sola attività alla volta. Qualsiasi attività che richiede più di 50 millisecondi è un'attività lunga. Per le attività che superano i 50 millisecondi, il tempo totale dell'attività meno 50 millisecondi è noto come periodo di blocco dell'attività.

Il browser blocca le interazioni mentre è in esecuzione un'attività di qualsiasi durata, ma questo non è percepibile dall'utente, a meno che le attività non vengano eseguite per troppo tempo. Tuttavia, quando un utente tenta di interagire con una pagina in cui sono presenti molte attività lunghe, l'interfaccia utente non risponde e potrebbe persino non funzionare se il thread principale è bloccato per periodi di tempo molto lunghi.

Un'attività lunga nel profiler delle prestazioni di DevTools di Chrome. La parte di blocco dell'attività (superiore a 50 millisecondi) è rappresentata da un pattern di strisce diagonali rosse.
Un'attività lunga come mostrato nel profiler del rendimento di Chrome. Le attività lunghe sono indicate da un triangolo rosso nell'angolo dell'attività, con la parte di blocco dell'attività riempita con un motivo di strisce rosse diagonali.

Per evitare che il thread principale venga bloccato per troppo tempo, puoi suddividere un'attività lunga in diverse attività più piccole.

Un'unica attività lunga rispetto alla stessa attività suddivisa in attività più brevi. L'attività lunga è un rettangolo grande, mentre l'attività suddivisa in blocchi è composta da cinque riquadri più piccoli che insieme hanno la stessa larghezza dell'attività lunga.
Visualizzazione di una singola attività lunga rispetto alla stessa attività suddivisa in cinque attività più brevi.

Questo è importante perché, quando le attività vengono suddivise, il browser può rispondere molto prima alle attività con priorità più alta, incluse le interazioni degli utenti. In seguito, le attività rimanenti vengono eseguite fino al completamento, garantendo che il lavoro inizialmente messo in coda venga completato.

Una rappresentazione di come la suddivisione di un'attività può facilitare l'interazione dell'utente. In alto, un'attività lunga impedisce l'esecuzione di un gestore di eventi fino al completamento dell'attività. In basso, l'attività suddivisa in blocchi consente al gestore di eventi di essere eseguito prima di quanto avrebbe fatto altrimenti.
Una visualizzazione di ciò che accade alle interazioni quando le attività sono troppo lunghe e il browser non riesce a rispondere abbastanza rapidamente alle interazioni, rispetto a quando le attività più lunghe vengono suddivise in attività più piccole.

Nella parte superiore della figura precedente, un gestore di eventi messo in coda da un'interazione utente ha dovuto attendere una singola attività lunga prima di poter iniziare, il che ritarda l'interazione. In questo scenario, l'utente potrebbe aver notato un ritardo. In basso, il gestore di eventi può iniziare a essere eseguito prima e l'interazione potrebbe essere sembrata istantanea.

Ora che sai perché è importante suddividere le attività, puoi scoprire come farlo in JavaScript.

Strategie di gestione delle attività

Un consiglio comune nell'architettura software è quello di suddividere il lavoro in funzioni più piccole:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

In questo esempio, esiste una funzione denominata saveSettings() che chiama cinque funzioni per convalidare un modulo, mostrare un'icona di selezione, inviare dati al backend dell'applicazione, aggiornare l'interfaccia utente e inviare dati di analisi.

Concettualmente, saveSettings() è ben progettato. Se devi eseguire il debug di una di queste funzioni, puoi attraversare l'albero del progetto per capire cosa fa ogni funzione. Suddividere il lavoro in questo modo semplifica la navigazione e la manutenzione dei progetti.

Tuttavia, un potenziale problema è che JavaScript non esegue ciascuna di queste funzioni come attività separate perché vengono eseguite all'interno della funzione saveSettings(). Ciò significa che tutte e cinque le funzioni verranno eseguite come un'unica attività.

La funzione saveSettings come mostrata nel profiler delle prestazioni di Chrome. Sebbene la funzione di primo livello chiami altre cinque funzioni, tutto il lavoro viene eseguito in un'unica attività lunga, per cui il risultato visibile all'utente dell'esecuzione della funzione non è visibile finché non sono state completate tutte.
Una singola funzione saveSettings() che chiama cinque funzioni. Il lavoro viene eseguito come parte di un'unica attività monolitica lunga, bloccando qualsiasi risposta visiva fino al completamento di tutte e cinque le funzioni.

Nel migliore dei casi, anche solo una di queste funzioni può aggiungere 50 millisecondi o più alla durata totale dell'attività. Nel peggiore dei casi, un numero maggiore di queste attività può richiedere molto più tempo, soprattutto sui dispositivi con risorse limitate.

In questo caso, saveSettings() viene attivato da un clic dell'utente e, poiché il browser non è in grado di mostrare una risposta fino al termine dell'esecuzione dell'intera funzione, il risultato di questa attività lunga è una UI lenta e non reattiva, che verrà misurata come un valore Interaction to Next Paint (INP) scadente.

Rimanda manualmente l'esecuzione del codice

Per assicurarti che le attività importanti rivolte agli utenti e le risposte della UI vengano eseguite prima delle attività con priorità più bassa, puoi cedere il controllo al thread principale interrompendo brevemente il tuo lavoro per dare al browser la possibilità di eseguire attività più importanti.

Un metodo utilizzato dagli sviluppatori per suddividere le attività in attività più piccole prevede l'utilizzo di setTimeout(). Con questa tecnica, passi la funzione a setTimeout(). In questo modo, l'esecuzione del callback viene posticipata in un'attività separata, anche se specifichi un timeout di 0.

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

Questo processo è noto come yielding e funziona meglio per una serie di funzioni che devono essere eseguite in sequenza.

Tuttavia, il codice potrebbe non essere sempre organizzato in questo modo. Ad esempio, potresti avere una grande quantità di dati che devono essere elaborati in un loop e questa attività potrebbe richiedere molto tempo se sono presenti molte iterazioni.

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

L'utilizzo di setTimeout() in questo caso è problematico a causa dell'ergonomia degli sviluppatori e, dopo cinque cicli di setTimeout() nidificati, il browser inizierà a imporre un ritardo minimo di 5 millisecondi per ogni setTimeout() aggiuntivo.

setTimeout presenta anche un altro svantaggio quando si tratta di concedere il controllo: quando concedi il controllo al thread principale posticipando l'esecuzione del codice in un'attività successiva utilizzando setTimeout, l'attività viene aggiunta alla fine della coda. Se ci sono altre attività in attesa, verranno eseguite prima del codice differito.

Un'API di rilascio dedicata: scheduler.yield()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: 142.
  • Safari: not supported.

Source

scheduler.yield() è un'API progettata specificamente per l'interruzione del thread principale nel browser.

Non si tratta di una sintassi a livello di linguaggio o di un costrutto speciale; scheduler.yield() è solo una funzione che restituisce un Promise che verrà risolto in un'attività futura. Qualsiasi codice concatenato per l'esecuzione dopo la risoluzione di Promise (in una catena .then() esplicita o dopo l'utilizzo di await in una funzione asincrona) verrà eseguito in quell'attività futura.

In pratica: inserisci un await scheduler.yield() e la funzione metterà in pausa l'esecuzione in quel punto e passerà al thread principale. L'esecuzione del resto della funzione, chiamata continuazione della funzione, verrà pianificata per l'esecuzione in una nuova attività del loop di eventi. Quando l'attività viene avviata, la promessa attesa viene risolta e la funzione continua l'esecuzione da dove era stata interrotta.

async function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Yield to the main thread:
  await scheduler.yield()

  // Work that isn't user-visible, continued in a separate task:
  saveToDatabase();
  sendAnalytics();
}
La funzione saveSettings, come mostrata nel profiler del rendimento di Chrome, ora è suddivisa in due attività. La prima attività chiama due funzioni, poi restituisce il controllo, consentendo l'esecuzione del lavoro di layout e visualizzazione e fornendo all'utente una risposta visibile. Di conseguenza, l'evento di clic viene completato in un tempo molto più rapido di 64 millisecondi. La seconda attività chiama le ultime tre funzioni.
L'esecuzione della funzione saveSettings() è ora suddivisa in due attività. Di conseguenza, il layout e il paint possono essere eseguiti tra le attività, offrendo all'utente una risposta visiva più rapida, misurata dall'interazione con il puntatore, che ora è molto più breve.

Il vero vantaggio di scheduler.yield() rispetto ad altri approcci di yielding, tuttavia, è che la sua continuazione ha la priorità, il che significa che se esegui lo yield nel mezzo di un'attività, la continuazione dell'attività corrente verrà eseguita prima dell'avvio di qualsiasi altra attività simile.

In questo modo, il codice di altre origini di attività non interrompe l'ordine di esecuzione del codice, ad esempio le attività di script di terze parti.

Tre diagrammi che mostrano le attività senza yielding, con yielding e con yielding e continuazione. Senza l'interruzione, ci sono attività lunghe. Con l'yielding, ci sono più attività che sono più brevi, ma che possono essere interrotte da altre attività non correlate. Con l'interruzione e la continuazione, ci sono più attività più brevi, ma il loro ordine di esecuzione viene preservato.
Quando utilizzi scheduler.yield(), la continuazione riprende da dove era stata interrotta prima di passare ad altre attività.

Supporto cross-browser

scheduler.yield() non è ancora supportato in tutti i browser, quindi è necessario un fallback.

Una soluzione è inserire scheduler-polyfill nella build, quindi scheduler.yield() può essere utilizzato direttamente; il polyfill gestirà il fallback ad altre funzioni di pianificazione delle attività, in modo che funzioni in modo simile su tutti i browser.

In alternativa, è possibile scrivere una versione meno sofisticata in poche righe, utilizzando solo setTimeout aggregato in una Promise come riserva se scheduler.yield() non è disponibile.

function yieldToMain () {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

  // Fall back to yielding with setTimeout.
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

Anche se i browser senza supporto di scheduler.yield() non otterranno la continuazione con priorità, continueranno a cedere il controllo per mantenere la reattività del browser.

Infine, potrebbero esserci casi in cui il codice non può permettersi di cedere il controllo al thread principale se la sua continuazione non ha la priorità (ad esempio, una pagina nota per essere occupata in cui la cessione del controllo rischia di non completare il lavoro per un po' di tempo). In questo caso, scheduler.yield() potrebbe essere trattato come una sorta di potenziamento progressivo: yield nei browser in cui scheduler.yield() è disponibile, altrimenti continua.

Per farlo, puoi utilizzare il rilevamento delle funzionalità e l'attesa di un singolo microtask in un'unica riga:

// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

Suddividi le attività di lunga durata con scheduler.yield()

Il vantaggio di utilizzare uno di questi metodi per l'utilizzo di scheduler.yield() è che puoi await in qualsiasi funzione async.

Ad esempio, se hai un array di job da eseguire che spesso finiscono per sommarsi in un'attività lunga, puoi inserire dei yield per suddividere l'attività.

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

    // Yield to the main thread:
    await yieldToMain();
  }
}

La continuazione di runJobs() avrà la priorità, ma consentirà comunque l'esecuzione di attività con priorità più alta, come la risposta visiva all'input dell'utente, senza dover attendere il completamento dell'elenco di job potenzialmente lungo.

Tuttavia, questo non è un utilizzo efficiente della sospensione. scheduler.yield() è rapido ed efficiente, ma comporta un certo overhead. Se alcuni dei job in jobQueue sono molto brevi, l'overhead potrebbe rapidamente sommarsi a un tempo maggiore dedicato alla sospensione e alla ripresa rispetto all'esecuzione del lavoro effettivo.

Un approccio consiste nel raggruppare i job, cedendo il controllo tra di loro solo se è trascorso un tempo sufficiente dall'ultima cessione. Una scadenza comune è di 50 millisecondi per cercare di evitare che le attività diventino attività lunghe, ma può essere modificata come compromesso tra la reattività e il tempo necessario per completare la coda di job.

async function runJobs(jobQueue, deadline=50) {
  let lastYield = performance.now();

  for (const job of jobQueue) {
    // Run the job:
    job();

    // If it's been longer than the deadline, yield to the main thread:
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

Il risultato è che i job vengono suddivisi in modo da non richiedere mai troppo tempo per l'esecuzione, ma l'esecutore cede il controllo al thread principale solo ogni 50 millisecondi circa.

Una serie di funzioni di job, mostrate nel riquadro Prestazioni di Chrome DevTools, con l'esecuzione suddivisa in più attività
Job raggruppati in più attività.

Non utilizzare isInputPending()

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

L'API isInputPending() consente di verificare se un utente ha tentato di interagire con una pagina e di restituire un risultato solo se un input è in attesa.

In questo modo, JavaScript può continuare se non sono presenti input in attesa, anziché cedere il controllo e finire in fondo alla coda di attività. Ciò può portare a notevoli miglioramenti delle prestazioni, come descritto in dettaglio in Intent to Ship, per i siti che altrimenti non restituirebbero il controllo al thread principale.

Tuttavia, dal lancio di questa API, la nostra comprensione della cessione è aumentata, in particolare con l'introduzione di INP. Non consigliamo più di utilizzare questa API, ma di eseguire lo yield indipendentemente dal fatto che l'input sia in attesa o meno per una serie di motivi:

  • isInputPending() potrebbe restituire erroneamente false nonostante un utente abbia interagito in alcune circostanze.
  • L'input non è l'unico caso in cui le attività devono essere messe in pausa. Le animazioni e altri aggiornamenti regolari dell'interfaccia utente possono essere altrettanto importanti per fornire una pagina web reattiva.
  • Da allora sono state introdotte API di yielding più complete che risolvono i problemi di yielding, come scheduler.postTask() e scheduler.yield().

Conclusione

La gestione delle attività è impegnativa, ma garantisce che la pagina risponda più rapidamente alle interazioni degli utenti. Non esiste un unico consiglio per la gestione e la definizione delle priorità delle attività, ma piuttosto una serie di tecniche diverse. Per riassumere, ecco gli aspetti principali da considerare quando gestisci le attività:

  • Cedi il controllo al thread principale per le attività critiche rivolte agli utenti.
  • Utilizza scheduler.yield() (con un fallback cross-browser) per eseguire l'yield in modo ergonomico e ottenere continuazioni con priorità
  • Infine, esegui il minor numero possibile di operazioni nelle tue funzioni.

Per scoprire di più su scheduler.yield(), sulla relativa scheduler.postTask() di pianificazione esplicita delle attività e sulla definizione della priorità delle attività, consulta la documentazione dell'API Prioritized Task Scheduling.

Con uno o più di questi strumenti, dovresti essere in grado di strutturare il lavoro nella tua applicazione in modo da dare priorità alle esigenze dell'utente, garantendo al contempo che il lavoro meno critico venga comunque svolto. In questo modo, l'esperienza utente sarà migliore, più reattiva e più piacevole.

Un ringraziamento speciale a Philip Walton per la sua revisione tecnica di questa guida.