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?

I consigli più comuni per mantenere veloci le app JavaScript si basano principalmente sui seguenti consigli:

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

Si tratta di un ottimo consiglio, ma come funziona? È 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 quali sono le attività e in che modo il browser le gestisce.

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 descritta nel profliler 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.
  • Altre volte durante il ciclo di vita della pagina, le attività vengono messe in coda quando JavaScript funziona, ad esempio guidando le interazioni tramite gestori di eventi, animazioni basate su JavaScript e attività in background come la raccolta di dati e analisi.

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 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, a condizione che le attività non vengano eseguite per troppo tempo. Quando un utente tenta di interagire con una pagina per cui sono previste molte attività lunghe, tuttavia, l'interfaccia utente non risponde e potrebbe persino non funzionare se il thread principale viene bloccato per periodi di tempo molto lunghi.

Un'attività lunga nel profiler delle prestazioni di DevTools di Chrome. La parte che blocca l'attività (superiore a 50 millisecondi) è rappresentata da un motivo di 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 aspetto è importante perché quando le attività vengono suddivise, il browser può rispondere molto prima alle attività prioritarie, comprese le interazioni degli utenti. In seguito, le attività rimanenti vengono eseguite fino al completamento, assicurando che il lavoro inizialmente messo in coda venga completato.

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 di 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 ciò che accade alle interazioni quando le attività sono troppo lunghe e il browser non può 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 una rotellina, inviare dati al backend dell'applicazione, aggiornare l'interfaccia utente e inviare dati e 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, tuttavia, è che JavaScript non esegue ognuna 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. Mentre la funzione di primo livello chiama altre cinque funzioni, tutto il lavoro viene svolto in un'unica lunga attività che blocca il thread principale.
Una singola funzione saveSettings() che chiama cinque funzioni. Il lavoro viene eseguito nell'ambito di un'unica attività monolitica lunga.

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, è possibile utilizzare setTimeout per accedere 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.

La stessa funzione saveSettings mostrata nel profiler delle prestazioni di Chrome, solo con rendimento. Il risultato è che l'attività una volta monolitica è ora suddivisa in cinque attività separate, una per ogni funzione.
Ora la funzione saveSettings() esegue le sue funzioni secondarie come 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 spezzarli o cedere esplicitamente alle interazioni degli utenti.

Supporto dei browser

  • Chrome: 94.
  • Edge: 94.
  • Firefox: dietro una bandiera.
  • Safari: non supportato.

Origine

L'API scheduler offre la funzione postTask() che consente una pianificazione più granulare delle attività ed è un modo per aiutare il browser a dare priorità al lavoro in modo che le attività a bassa priorità vengano trasferite 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 attività con priorità media. Questa è l'impostazione predefinita se non è impostato alcun valore priority.
  • 'user-blocking' per le attività critiche che devono essere eseguite con alta priorità.

Prendi ad esempio il codice seguente, 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.

La funzione saveSettings mostrata nel profiler delle prestazioni di Chrome, ma utilizzando postTask. postTask suddivide ogni esecuzione della funzione saveSettings e le assegna una priorità in modo che un'interazione utente abbia la possibilità di essere eseguita senza essere bloccata.
Quando viene eseguita saveSettings(), la funzione pianifica le singole funzioni utilizzando postTask(). Il lavoro critico rivolto agli utenti è pianificato con priorità elevata, mentre il lavoro di cui l'utente non è a conoscenza è pianificato per essere eseguito in background. In questo modo, le interazioni degli utenti vengono eseguite più rapidamente, poiché il lavoro viene suddiviso e dato la priorità in modo appropriato.

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()

Supporto dei browser

  • Chrome: 129.
  • Edge: 129.
  • Firefox: non supportato.
  • Safari: non supportato.

Origine

scheduler.yield() è un'API progettata appositamente per accedere al thread principale nel 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().

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 potrebbero 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(), l'esecuzione dell'attività riprende da dove si era interrotta anche dopo il punto di rendimento.

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()

Supporto dei browser

  • Chrome: 87.
  • Edge: 87.
  • Firefox: non supportato.
  • Safari: non supportato.

Origine

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

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. Piuttosto, consigliamo di generare 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 ugualmente importanti per fornire una pagina web reattiva.
  • 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 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 fare esperimenti con scheduler.yield().
  • Infine, svolgi il minor lavoro possibile nelle tue 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 che vengano comunque svolte le 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.