Ti è stato detto di "non bloccare il thread principale" e di "suddividere le attività lunghe", ma cosa significa fare queste cose?
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 lavoro distinto svolto 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à.
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 gestire le 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.
Per evitare che il thread principale venga bloccato per troppo tempo, puoi suddividere un'attività lunga in più attività più piccole.
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 inserito inizialmente in coda venga svolto.
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à.
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.
Rimandare manualmente l'esecuzione del codice
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 l'elaborazione dell'intero array di dati potrebbe richiedere molto tempo, anche se ogni singola iterazione viene eseguita rapidamente. Tutto sommato, setTimeout()
non è lo strumento giusto per il compito, almeno non se utilizzato in questo modo.
Usa async
/await
per creare punti di rendimento
Per assicurarti che le attività importanti rivolte agli utenti vengano eseguite prima di quelle con priorità inferiore, puoi rinunciare al thread principale interrompendo brevemente la coda delle attività per dare al browser l'opportunità di eseguire attività più importanti.
Come spiegato in precedenza, setTimeout
può essere utilizzato per cedere il controllo al thread principale. Per praticità e una migliore leggibilità, puoi chiamare setTimeout
all'interno di un Promise
e passare il relativo metodo resolve
come callback.
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
Il vantaggio della funzione yieldToMain()
è che puoi await
in qualsiasi funzione async
. Partendo dall'esempio precedente, puoi creare un array di funzioni da eseguire e cedere il controllo al thread principale dopo l'esecuzione di ciascuna:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread:
await yieldToMain();
}
}
Il risultato è che l'attività un tempo monolitica è ora suddivisa in attività separate.
Un'API di pianificazione dedicata
setTimeout
è un modo efficace per suddividere le attività, ma può avere uno svantaggio: quando cedisci il controllo al thread principale rimandando l'esecuzione del codice a un'attività successiva, questa viene aggiunta alla fine della coda.
Se controlli tutto il codice della pagina, puoi creare il tuo programmatore con la possibilità di dare la priorità alle attività, ma gli script di terze parti non lo utilizzeranno. In pratica, non puoi assegnare la priorità al lavoro in questi ambienti. Puoi solo suddividerlo o cedere esplicitamente alle interazioni degli utenti.
L'API Scheduler offre la funzione postTask()
che consente una pianificazione più granulare delle attività ed è un modo per aiutare il browser a dare la priorità al lavoro in modo che le attività a bassa priorità cedano il posto al thread principale. postTask()
utilizza le promesse e accetta una delle tre impostazioni priority
:
'background'
per le attività con priorità più bassa.'user-visible'
per le attività con priorità media. Questo è il valore predefinito se non è impostatopriority
.'user-blocking'
per le attività critiche che devono essere eseguite con alta priorità.
Prendi ad esempio il seguente codice, in cui l'API postTask()
viene utilizzata per eseguire tre attività con la massima priorità possibile e le restanti due con la priorità più bassa possibile.
function saveSettings () {
// Validate the form at high priority
scheduler.postTask(validateForm, {priority: 'user-blocking'});
// Show the spinner at high priority:
scheduler.postTask(showSpinner, {priority: 'user-blocking'});
// Update the database in the background:
scheduler.postTask(saveToDatabase, {priority: 'background'});
// Update the user interface at high priority:
scheduler.postTask(updateUI, {priority: 'user-blocking'});
// Send analytics data in the background:
scheduler.postTask(sendAnalytics, {priority: 'background'});
};
Qui, la priorità delle attività è pianificata in modo che le attività con priorità assegnata dal browser, come le interazioni utente, possano essere inserite tra le altre, se necessario.
Questo è un esempio semplificato di come può essere utilizzato postTask()
. È possibile creare istanze di diversi oggetti TaskController
che possono condividere le priorità tra le attività, inclusa la possibilità di modificare le priorità per istanze TaskController
diverse in base alle esigenze.
Rendimento integrato con continuazione utilizzando l'API scheduler.yield()
scheduler.yield()
è un'API progettata specificamente per cedere il controllo al thread principale del browser. Il suo utilizzo è simile a quello della funzione yieldToMain()
mostrata in precedenza in questa guida:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread with the scheduler
// API's own yielding mechanism:
await scheduler.yield();
}
}
Questo codice è in gran parte familiare, ma anziché utilizzare yieldToMain()
, utilizza
await scheduler.yield()
.
Il vantaggio di scheduler.yield()
è la continuità, il che significa che se esegui il rendimento nel mezzo di un insieme di attività, le altre attività pianificate continueranno nello stesso ordine dopo il punto di rendimento. In questo modo, il codice degli script di terze parti non interrompe l'ordine di esecuzione del tuo codice.
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 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 erroneamentefalse
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()
escheduler.yield()
.
Conclusione
La gestione delle attività è impegnativa, ma ti assicura 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.
- Assegna una priorità alle attività con
postTask()
. - Valuta la possibilità di sperimentare con
scheduler.yield()
. - Infine, esegui il minor lavoro possibile nelle funzioni.
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.