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?

Pubblicato: 30 settembre 2022, ultimo aggiornamento: 19 dicembre 2024

I consigli comuni per mantenere veloci le app JavaScript tendono a riassumersi nel seguente:

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

Ottimo consiglio, ma che tipo di lavoro comporta? È bene utilizzare meno JavaScript, ma questo equivale automaticamente a interfacce utente più reattive? Forse, ma forse no.

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

Che cos'è un'attività?

Un'attività è qualsiasi attività distinta eseguita dal browser. Questo lavoro include il rendering, l'analisi di HTML e CSS, l'esecuzione di JavaScript e altri tipi di attività su cui potresti non avere il controllo diretto. Di tutto questo, il codice JavaScript che scrivi è forse la fonte più grande di attività.

Una visualizzazione di un'attività, come mostrato nel profilo delle prestazioni di DevTools di Chrome. L'attività si trova nella parte superiore di una serie, con un gestore di eventi di clic, una chiamata di funzione e altri elementi sottostanti. L'attività include anche alcuni lavori di rendering sul lato destro.
Un'attività avviata da un gestore eventi click, mostrata nel profiler delle prestazioni di Chrome DevTools.

Le attività associate a JavaScript influiscono sulle prestazioni in due modi:

  • Quando un browser scarica un file JavaScript durante l'avvio, mette in coda le attività per analizzarlo e compilarlo in modo che possa essere eseguito in un secondo momento.
  • In altri momenti durante la vita della pagina, le attività vengono messe in coda quando JavaScript funziona, ad esempio per rispondere alle interazioni tramite gestori eventi, animazioni basate su JavaScript e attività in background come la raccolta dei dati.

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

Che cos'è il thread principale?

Il thread principale è il luogo in cui vengono eseguite 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 durante l'esecuzione di un'attività di qualsiasi durata, ma questo non è percepibile dall'utente, a condizione 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 essere interrotta 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 motivo a strisce diagonali rosse.
Un'attività lunga, come mostrato nel profiler delle prestazioni di Chrome. Le attività lunghe sono indicate da un triangolo rosso nell'angolo dell'attività, con la parte di blocco dell'attività riempita da un motivo di strisce rosse diagonali.

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

Una singola 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, nel loro insieme, hanno la stessa larghezza dell'attività lunga.
Una 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ù elevata, incluse le interazioni degli utenti. Successivamente, le attività rimanenti vengono eseguite fino al completamento, garantendo che il lavoro messo in coda inizialmente venga svolto.

Una rappresentazione di come suddividere un'attività può facilitare l'interazione con l'utente. In alto, un'attività lunga impedisce l'esecuzione di un gestore eventi fino al termine dell'attività. In basso, l'attività suddivisa in blocchi consente all'handler dell'evento di essere eseguito prima.
Una visualizzazione di cosa succede 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 sono suddivise in attività più piccole.

Nella parte superiore della figura precedente, un gestore di eventi messo in coda da un'interazione utente doveva attendere una singola attività lunga prima di poter iniziare. Ciò ritarda l'interazione. In questo caso, l'utente potrebbe aver notato un ritardo. In basso, il gestore degli eventi può iniziare a funzionare prima e l'interazione potrebbe sembrare 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 è suddividere il lavoro in funzioni più piccole:

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

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

A livello concettuale, saveSettings() è ben strutturato. Se devi eseguire il debug di una di queste funzioni, puoi esaminare l'albero del progetto per capire cosa fa ciascuna funzione. Suddividere il lavoro in questo modo semplifica la navigazione e la gestione dei progetti.

Un potenziale problema, però, è 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 descritta nel profiler delle prestazioni di Chrome. Sebbene la funzione di primo livello chiami altre cinque funzioni, tutto il lavoro viene svolto in un'unica attività lunga che fa in modo che il risultato visibile all'utente dell'esecuzione della funzione non sia visibile finché non sono state completate tutte.
Una singola funzione saveSettings() che chiama cinque funzioni. Il lavoro viene eseguito nell'ambito di un'unica lunga attività monolitica, bloccando qualsiasi risposta visiva fino al completamento di tutte e cinque le funzioni.

Nel migliore dei casi, anche una sola di queste funzioni può contribuire con 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 su 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 finché l'intera funzione non è stata eseguita, il risultato di questa lunga attività è un'interfaccia utente lenta e non reattiva e verrà misurato come un valore basso di Interaction to Next Paint (INP).

Rimandare manualmente l'esecuzione del codice

Per assicurarti che le attività importanti rivolte agli utenti e le risposte dell'interfaccia utente vengano eseguite prima delle attività con priorità inferiore, puoi cedere il controllo al thread principale interrompendo brevemente il tuo lavoro per dare al browser l'opportunità di eseguire attività più importanti.

Uno dei metodi utilizzati dagli sviluppatori per suddividere le attività in altre più piccole prevede l'uso 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);
}

Questa operazione è nota come yield 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 da elaborare in un ciclo 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() qui è problematico per l'ergonomia dello sviluppatore e, dopo cinque round di setTimeout() nidificati, il browser inizierà a imporre un ritardo minimo di 5 millisecondi per ogni setTimeout() aggiuntivo.

setTimeout presenta anche un altro svantaggio per quanto riguarda la cessione: quando cedi il controllo al thread principale rimandando l'esecuzione del codice a 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 rendimento dedicata: scheduler.yield()

Browser Support

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

Source

scheduler.yield() è un'API progettata specificamente per cedere il controllo al thread principale del browser.

Non si tratta di 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 incatenato per l'esecuzione dopo la risoluzione di Promise (in una catena .then() esplicita o dopo averlo await in una funzione asincrona) verrà eseguito in quell'attività futura.

In pratica: inserisci un await scheduler.yield() e la funzione interromperà l'esecuzione in quel punto e cederà il controllo al thread principale. L'esecuzione del resto della funzione, chiamata continuazione della funzione, verrà pianificata per l'esecuzione in una nuova attività di loop di eventi. Quando l'attività inizia, la promessa attesa viene risolta e la funzione continua a essere eseguita da dove si era 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 descritta nel profiler delle prestazioni di Chrome, ora è suddivisa in due attività. La prima attività chiama due funzioni, quindi restituisce, consentendo il completamento del layout e della visualizzazione e fornendo all'utente una risposta visibile. Di conseguenza, l'evento di clic viene completato in 64 millisecondi, molto più velocemente. La seconda attività chiama le ultime tre funzioni.
Ora l'esecuzione della funzione saveSettings() è suddivisa in due attività. Di conseguenza, il layout e la pittura possono essere eseguiti tra le attività, offrendo all'utente una risposta visiva più rapida, misurata dall'interazione del cursore ora molto più breve.

Il vero vantaggio di scheduler.yield() rispetto ad altri approcci che richiedono di cedere il controllo è che la sua continuazione ha la priorità, il che significa che se esci nel mezzo di un'attività, la continuazione dell'attività corrente verrà eseguita prima dell'avvio di altre attività simili.

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

Tre diagrammi che mostrano attività senza cedimenti, con cedimenti e con cedimenti e continuazione. Senza cedimenti, ci sono attività lunghe. Con il rendimento, ci sono più attività più brevi, ma che possono essere interrotte da altre attività non correlate. Con il rendimento e la continuazione, ci sono più attività più brevi, ma il loro ordine di esecuzione viene mantenuto.
Quando utilizzi scheduler.yield(), la continuazione riprende da dove avevi interrotto prima di passare ad altre attività.

Supporto su più browser

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

Una soluzione è inserire scheduler-polyfill nella build, in modo da poter utilizzare direttamente scheduler.yield(). 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 racchiuso in una promessa come opzione di 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);
  });
}

Sebbene i browser senza supporto di scheduler.yield() non ricevano la continuazione con priorità, continueranno a consentire al browser di rimanere reattivo.

Infine, potrebbero verificarsi 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 considerato una sorta di miglioramento progressivo: genera nel browser in cui scheduler.yield() è disponibile, altrimenti continua.

Questo può essere fatto sia rilevando le funzionalità sia tornando all'attesa di una singola microtask in un pratico comando:

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

Suddividi i lavori di lunga durata con scheduler.yield()

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

Ad esempio, se hai un array di job da eseguire che spesso si sommano a un'attività lunga, puoi inserire i risultati 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ù elevata, come la risposta visiva all'input dell'utente, senza dover attendere il completamento dell'elenco potenzialmente lungo di job.

Tuttavia, non si tratta di un utilizzo efficiente del rendimento. scheduler.yield() è veloce ed efficiente, ma presenta un certo overhead. Se alcuni dei job in jobQueue sono molto brevi, il sovraccarico potrebbe accumularsi rapidamente e farti spendere più tempo per l'interruzione e la ripresa rispetto all'esecuzione del lavoro effettivo.

Un approccio è raggruppare i job, generando un nuovo job solo se è trascorso un tempo sufficiente dall'ultimo. Una scadenza comune è di 50 millisecondi per evitare che le attività diventino lunghe, ma può essere modificata in base al compromesso tra reattività e 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 che non richiedano mai troppo tempo per l'esecuzione, ma il runner cede il controllo al thread principale solo ogni 50 millisecondi circa.

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

Non utilizzare isInputPending()

Browser Support

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

Source

L'API isInputPending() fornisce un modo per verificare se un utente ha tentato di interagire con una pagina e restituisce un valore solo se è in attesa un input.

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

Tuttavia, dal lancio di questa API, la nostra comprensione del rendimento è aumentata, in particolare con l'introduzione dell'INP. Non consigliamo più di utilizzare questa API e ti consigliamo di eseguire il rendimento 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 produrre risultati. Le animazioni e altri aggiornamenti regolari dell'interfaccia utente possono essere altrettanto importanti per fornire una pagina web adattabile.
  • Da allora sono state introdotte API di rendimento più complete che risolvono i problemi relativi al rendimento, come scheduler.postTask() e scheduler.yield().

Conclusione

La gestione delle attività è impegnativa, ma ti garantisce che la tua pagina risponda più rapidamente alle interazioni degli utenti. Non esiste un unico consiglio per gestire e dare la priorità alle attività, ma una serie di tecniche diverse. Ti ricordiamo che, quando gestisci le attività, devi tenere presenti i seguenti aspetti principali:

  • Rinuncia al thread principale per attività critiche rivolte agli utenti.
  • Utilizza scheduler.yield() (con un'opzione di riserva cross-browser) per cedere in modo ergonomico e ricevere le continue con priorità
  • Infine, esegui il minor lavoro possibile nelle funzioni.

Per scoprire di più su scheduler.yield(), sulla relativa pianificazione delle attività esplicita scheduler.postTask() e sulla definizione delle 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 la priorità alle esigenze dell'utente, garantendo al contempo l'esecuzione di attività meno critiche. In questo modo, creerai un'esperienza utente migliore, più reattiva e piacevole da usare.

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

Immagine in miniatura tratta da Unsplash, per gentile concessione di Amirali Mirhashemian.