Ti è stato detto di "non bloccare il thread principale" e di "dividere le attività lunghe", ma cosa significa fare queste cose?
Pubblicato: 30 settembre 2022, ultimo aggiornamento: 19 dicembre 2024
I consigli più comuni per mantenere veloci le app JavaScript tendono a riassumersi in quanto segue:
- "Non bloccare il thread principale."
- "Suddividi le attività lunghe".
È un ottimo consiglio, ma cosa comporta? La spedizione di meno JavaScript è una buona cosa, ma si traduce automaticamente in interfacce utente più reattive? Forse, 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 è qualsiasi parte discreta di lavoro svolta 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. Di tutto questo, il codice JavaScript che scrivi è forse la fonte più grande di attività.

click
in, 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 all'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 del ciclo di vita della pagina, le attività vengono messe in coda quando JavaScript esegue operazioni come la risposta alle interazioni tramite i gestori di eventi, le animazioni basate su JavaScript e le attività in background come la raccolta di Analytics.
Tutto questo, ad eccezione dei web worker e delle API simili, avviene nel thread principale.
Che cos'è il thread principale?
Il thread principale è il punto in cui la maggior parte delle attività viene eseguita nel browser e dove viene eseguito 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 finché le attività non vengono eseguite per troppo tempo. Quando un utente tenta di interagire con una pagina in cui sono presenti molte attività lunghe, l'interfaccia utente non risponde e potrebbe persino sembrare danneggiata se il thread principale viene bloccato per periodi di tempo molto lunghi.

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

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

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. Ciò 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 indicatore di caricamento, inviare dati al backend dell'applicazione, aggiornare l'interfaccia utente e inviare Analytics.
Dal punto di vista concettuale, saveSettings()
è ben strutturato. Se devi eseguire il debug di una di queste funzioni, puoi attraversare l'albero del progetto per capire cosa fa ogni funzione. Dividere il lavoro in questo modo rende i progetti più facili da navigare e gestire.
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à.

saveSettings()
che chiama cinque funzioni. Il lavoro viene eseguito nell'ambito di un'unica attività monolitica di lunga durata, 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ò essere eseguito molto più a lungo, 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 finché l'intera funzione non è stata eseguita, il risultato di questa attività lunga è un'interfaccia utente lenta e non reattiva, che verrà misurata come un Interaction to Next Paint (INP) scarso.
Posticipare 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à a priorità inferiore, 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 altre più piccole prevede l'setTimeout()
. Con questa tecnica, passi la funzione a setTimeout()
. Ciò posticipa l'esecuzione del callback 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 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 da elaborare in un ciclo e questa attività potrebbe richiedere molto tempo se ci sono 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 dello sviluppatore 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 in termini di rendimento: quando cedi 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 posticipato.
Un'API di yielding dedicata: scheduler.yield()
scheduler.yield()
è un'API progettata appositamente per cedere il controllo al thread principale del 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 da eseguire dopo la risoluzione di Promise
(in una catena .then()
esplicita o dopo l'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 a 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à del ciclo di eventi. Quando l'attività inizia, la promessa attesa viene risolta e la funzione continua l'esecuzione 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();
}

saveSettings()
è ora suddivisa in due attività. Di conseguenza, il layout e il rendering possono essere eseguiti tra le attività, offrendo all'utente una risposta visiva più rapida, misurata dall'interazione del puntatore 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 il yield a metà di un'attività, la continuazione dell'attività corrente verrà eseguita prima di qualsiasi altra attività simile.
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.

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, pertanto è necessario un fallback.
Una soluzione è inserire scheduler-polyfill
nella build, in modo che scheduler.yield()
possa 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
racchiuso in una promessa 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 riceveranno la continuazione con priorità, continueranno a cedere il passo al browser per mantenerlo reattivo.
Infine, potrebbero verificarsi casi in cui il codice non può cedere il controllo al thread principale se la sua continuazione non è prioritaria (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 miglioramento progressivo: viene restituito nei browser in cui scheduler.yield()
è disponibile, altrimenti si continua.
Questa operazione può essere eseguita rilevando le funzionalità e tornando all'attesa di una singola microattività in un comodo comando di una riga:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Interrompi il lavoro a lunga esecuzione con scheduler.yield()
Il vantaggio di utilizzare uno di questi metodi di 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 a un'attività lunga, puoi inserire 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ù 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()
è rapido ed efficiente, ma comporta un overhead. Se alcuni dei job in jobQueue
sono molto brevi, l'overhead potrebbe accumularsi rapidamente fino a richiedere più tempo per la generazione e la ripresa che per l'esecuzione del lavoro effettivo.
Un approccio consiste nel raggruppare i job, eseguendo lo yield solo se è passato abbastanza tempo dall'ultimo yield. Una scadenza comune è di 50 millisecondi per evitare che le attività diventino lunghe, ma può essere modificata come compromesso tra reattività e tempo per completare la coda di lavoro.
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 il runner cede il controllo al thread principale solo ogni 50 millisecondi circa.

Non utilizzare isInputPending()
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 sono presenti input in attesa, anziché cedere il controllo e finire in fondo alla coda delle attività. Ciò può comportare miglioramenti impressionanti delle prestazioni, come descritto nell'Intent to Ship, per i siti che altrimenti non tornerebbero al thread principale.
Tuttavia, dal lancio di questa API, la nostra comprensione del rendimento è aumentata, in particolare con l'introduzione di INP. Non consigliamo più di utilizzare questa API e, per diversi motivi, consigliamo invece di cedere il controllo indipendentemente dal fatto che l'input sia in attesa o meno:
isInputPending()
potrebbe restituire erroneamentefalse
nonostante l'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 reattiva.
- Da allora sono state introdotte API di rendimento più complete che risolvono i problemi di rendimento, come
scheduler.postTask()
escheduler.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 gestire e dare la priorità alle attività, ma piuttosto una serie di tecniche diverse. Per ricapitolare, 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 ottenere in modo ergonomico continuazioni con priorità - Infine, svolgi il minor lavoro possibile nelle funzioni.
Per saperne di più su scheduler.yield()
, sulla pianificazione esplicita delle attività relativa a 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 che il lavoro meno critico venga comunque svolto. In questo modo si crea 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.