Ottimizza le attività lunghe

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

Jeremy Wagner
Jeremy Wagner

Se leggi molte informazioni sulle prestazioni sul Web, i consigli per mantenere veloci le tue app JavaScript tendono a includere alcuni dei seguenti suggerimenti:

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

Che cosa significa? Pubblicare meno JavaScript è positivo, ma questo equivale automaticamente a interfacce utente più rapide per tutto il ciclo di vita della pagina? Forse, ma forse no.

Per capire perché è importante ottimizzare le attività in JavaScript, devi prima capire il loro ruolo e il modo in cui il browser le gestisce, il che inizia con la comprensione di un'attività.

Che cos'è un'attività?

Un'attività è qualsiasi attività distinta svolta dal browser. Le attività comportano attività come il rendering, l'analisi di HTML e CSS, l'esecuzione del codice JavaScript che scrivi e altre cose su cui potresti non avere il controllo diretto. Di tutto questo, il codice JavaScript che scrivi e di cui esegui il deployment sul web è una delle principali fonti di attività.

Uno screenshot di un'attività come illustrato nel profilo delle prestazioni dei DevTools di Chrome. L'attività si trova in cima a una pila, con un gestore di eventi click, una chiamata a funzione e altri elementi sottostanti. L'attività include anche alcune operazioni di rendering sul lato destro.
Una rappresentazione di un'attività avviata da un gestore di eventi click nel profiler delle prestazioni in Chrome DevTools.

Le attività influiscono sulle prestazioni in un paio di modi. Ad esempio, 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. Più avanti nel ciclo di vita della pagina, le attività vengono avviate quando JavaScript funziona, ad esempio guidando le interazioni tramite gestori di eventi, animazioni basate su JavaScript e attività in background come la raccolta di analisi. Tutti questi elementi, ad eccezione dei web worker e di API simili, avvengono nel thread principale.

Qual è il thread principale?

Il thread principale è il luogo in cui viene eseguita la maggior parte delle attività nel browser. Viene chiamato thread principale per un motivo: è l'unico thread in cui svolge il proprio lavoro quasi tutto il codice JavaScript che scrivi.

Il thread principale può elaborare una sola attività alla volta. Quando le attività si estendono oltre un certo punto, ovvero 50 millisecondi per la precisione, vengono classificate come attività lunghe. Se l'utente sta tentando di interagire con la pagina durante l'esecuzione di una lunga attività o se deve essere eseguito un aggiornamento del rendering importante, il browser subirà ritardi nella gestione di questo lavoro. Questo comporta una latenza dell'interazione o del rendering.

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

Devi suddividere le attività. Ciò significa prendere una singola attività lunga e suddividerla in attività più piccole che richiedono meno tempo per essere eseguite singolarmente.

Una singola attività lunga e la stessa attività suddivisa in attività più brevi. L'attività lunga è un rettangolo grande, mentre l'attività a blocchi è composta da cinque riquadri più piccoli che collettivamente 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 interrotte, il browser ha più opportunità di rispondere a lavori di priorità più elevata, incluse le interazioni degli utenti.

Una rappresentazione di come suddividere 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à. Nella parte inferiore, l'attività suddivisa consente al gestore di eventi di essere eseguita prima di quanto sarebbe altrimenti.
Visualizzazione di cosa succede alle interazioni quando le attività sono troppo lunghe e il browser non risponde abbastanza rapidamente alle interazioni, rispetto a quando le attività più lunghe vengono suddivise in attività più piccole.

All'inizio della figura precedente, un gestore di eventi in coda da un'interazione con un utente ha dovuto attendere una singola attività lunga prima di poter essere eseguita, questo ritarda l'esecuzione dell'interazione. In basso, il gestore di eventi ha la possibilità di essere eseguito prima. Poiché il gestore di eventi ha avuto l'opportunità di essere eseguito tra attività più piccole, viene eseguito prima del completamento di un'attività lunga. Nell'esempio in alto, l'utente potrebbe aver notato un ritardo, mentre in basso l'interazione poteva essere percepita istantanea.

Il problema, però, è che i consigli di "suddividere le attività lunghe" e "non bloccare il thread principale" non sono abbastanza specifici a meno che non si sappia già come svolgere queste operazioni. Ecco cosa spiegherà questa guida.

Strategie di gestione delle attività

Un consiglio comune nell'architettura software è suddividere il lavoro in funzioni più piccole. Questo ti offre i vantaggi di una migliore leggibilità del codice e di manutenibilità del progetto. In questo modo i test sono più facili da scrivere.

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

In questo esempio, è presente una funzione denominata saveSettings() che chiama cinque funzioni al suo interno per svolgere il lavoro, come la convalida di un modulo, la visualizzazione di una rotellina, l'invio di dati e così via. Concettualmente, l'architettura è ben progettata. Se devi eseguire il debug di una di queste funzioni, puoi attraversare la struttura del progetto per capire quale sia lo scopo di ciascuna funzione.

Il problema, tuttavia, è 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 vengono eseguite come un'unica attività.

La funzione salvaImpostazioni come illustrato nel profiler delle prestazioni di Chrome. Mentre la funzione di primo livello chiama altre cinque funzioni, tutto il lavoro si svolge in un'unica attività lunga che blocca il thread principale.
Una singola funzione saveSettings() che chiama cinque funzioni. Il lavoro viene eseguito come parte di una lunga attività monolitica.

Nel migliore dei casi, anche solo una di queste funzioni può contribuire per almeno 50 millisecondi alla durata totale dell'attività. Nel peggiore dei casi, una maggior parte di queste attività può durare un po' più a lungo, in particolare su dispositivi con risorse limitate. Di seguito è riportata una serie di strategie che puoi utilizzare per suddividere e assegnare le priorità alle attività.

Rimanda manualmente l'esecuzione del codice

Un metodo usato dagli sviluppatori per suddividere le attività in attività più piccole riguarda 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 metodo funziona bene se devi eseguire una serie di funzioni in sequenza, ma 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 hai milioni di elementi.

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

L'utilizzo di setTimeout() in questo caso è problematico, perché l'ergonomia lo rende difficile da implementare e l'elaborazione dell'intera gamma di dati potrebbe richiedere molto tempo, anche se ogni elemento può essere elaborato molto rapidamente. Tutto va a buon fine e setTimeout() non è lo strumento giusto per svolgere il lavoro, almeno non quando viene usato in questo modo.

Oltre a setTimeout(), esistono altre API che ti consentono di rimandare l'esecuzione del codice a un'attività successiva. Una prevede l'utilizzo di postMessage() per timeout più rapidi. Puoi anche interrompere il lavoro utilizzando requestIdleCallback(), ma fai attenzione: requestIdleCallback() pianifica le attività con la priorità più bassa possibile e solo durante il tempo di inattività del browser. Quando il thread principale è congestionato, le attività pianificate con requestIdleCallback() potrebbero non essere mai eseguite.

Utilizza async/await per creare punti di rendimento

Una frase che troverai nel resto della guida è "rendering al thread principale", ma che cosa significa? Perché dovresti farlo? Quando occorre farlo?

Quando le attività sono suddivise, le altre possono avere la priorità in base allo schema di priorità interno del browser. Un modo per cedere al thread principale prevede l'utilizzo di una combinazione di Promise che si risolve con una chiamata a setTimeout():

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

Nella funzione saveSettings(), puoi cedere al thread principale dopo ogni bit di lavoro se await la funzione yieldToMain() dopo ogni chiamata a funzione:

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à, che un tempo era monolitica, ora è suddivisa in attività separate.

La stessa funzione saveSettings illustrata nel profiler delle prestazioni di Chrome, ma con rendimento. Il risultato è che l'attività un tempo monolitica è ora suddivisa in cinque attività distinte, una per ogni funzione.
La funzione saveSettings() ora esegue le funzioni figlio come attività separate.

Il vantaggio di usare un approccio alla consegna di setTimeout() basato sulle promesse piuttosto che sull'uso manuale è una migliore ergonomia. I punti di rendimento diventano dichiarativi e quindi più facili da scrivere, leggere e comprendere.

Restituisci solo quando necessario

E se tu dovessi avere tante attività ma vorresti cedere soltanto se l'utente cercava di interagire con la pagina? È questo il genere di contenuti pensati per isInputPending().

isInputPending() è una funzione che puoi eseguire in qualsiasi momento per determinare se l'utente sta tentando di interagire con un elemento della pagina: una chiamata a isInputPending() restituirà true. In caso contrario, restituisce false.

Supponiamo che tu debba eseguire una coda di attività senza intralciare gli input. Questo codice, che utilizza sia isInputPending() sia la nostra funzione yieldToMain() personalizzata, garantisce che un input non venga ritardato mentre l'utente prova a interagire con la pagina:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

Durante l'esecuzione di saveSettings(), ripeterà il loop delle attività in coda. Se isInputPending() restituisce true durante il loop, saveSettings() chiamerà yieldToMain() per consentire la gestione dell'input utente. In caso contrario, l'attività successiva verrà spostata dalla coda all'inizio ed eseguita in modo continuo. Lo farà fino a quando non saranno rimaste altre attività.

Immagine della funzione saveSettings in esecuzione nel profiler delle prestazioni di Chrome. L'attività risultante blocca il thread principale finché isInputPending restituisce true. A quel punto, l'attività restituisce il thread principale.
saveSettings() esegue una coda di attività per cinque attività, ma l'utente ha fatto clic per aprire un menu mentre il secondo elemento di lavoro era in esecuzione. isInputPending() restituisce il thread principale per gestire l'interazione e riprendere l'esecuzione del resto delle attività.

L'utilizzo di isInputPending() in combinazione con un meccanismo di rendimento è un ottimo modo per fare in modo che il browser interrompa qualsiasi attività in fase di elaborazione, in modo che possa rispondere a interazioni critiche rivolte agli utenti. Ciò può contribuire a migliorare la capacità della tua pagina di rispondere all'utente in molte situazioni, quando sono in corso numerose attività.

Un altro modo per utilizzare isInputPending(), in particolare se temi di fornire un fallback per i browser che non lo supportano, consiste nell'utilizzare un approccio basato sul tempo in combinazione con l'operatore di concatenamento facoltativo:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

Con questo approccio, ottieni un fallback per i browser che non supportano isInputPending() utilizzando un approccio basato sul tempo che utilizza (e adatta) una scadenza in modo che il lavoro venga suddiviso, se necessario, in base all'input utente o in un determinato momento.

Lacune nelle API attuali

Le API menzionate finora possono aiutarti a suddividere le attività, ma hanno uno svantaggio significativo: quando cedi al thread principale rinviando il codice da eseguire in un'attività successiva, quel codice viene aggiunto alla fine della coda delle attività.

Se controlli tutto il codice della pagina, puoi creare un tuo scheduler con la possibilità di assegnare la priorità alle attività, ma gli script di terze parti non utilizzeranno il tuo scheduler. In pratica, non puoi assegnare una priorità al lavoro in questi ambienti. Puoi solo suddividerlo in blocchi o cedere esplicitamente alle interazioni degli utenti.

Per fortuna, esiste un'API scheduler dedicata che è attualmente in fase di sviluppo per risolvere questi problemi.

Un'API scheduler dedicata

L'API scheduler offre attualmente la funzione postTask() che, al momento della stesura di un documento, è disponibile nei browser Chromium e in Firefox con flag. postTask() 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à ricadano nel thread principale. postTask() utilizza le promesse e accetta un'impostazione di priority.

L'API postTask() ha tre priorità che puoi utilizzare:

  • 'background' per le attività con priorità più bassa.
  • 'user-visible' per attività con priorità media. Questa è l'impostazione predefinita se non è impostato alcun priority.
  • 'user-blocking' per le attività critiche che devono essere eseguite con priorità elevata.

Prendi come esempio il codice seguente, in cui l'API postTask() viene utilizzata per eseguire tre attività con la massima priorità possibile e le due attività rimanenti 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à prioritarie del browser, come le interazioni degli utenti, possano intervenire.

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

Questo è un esempio semplicistico di come è possibile utilizzare postTask(). È possibile creare istanze di oggetti TaskController diversi in grado di condividere priorità tra le attività, inclusa la possibilità di modificare le priorità per diverse istanze TaskController in base alle esigenze.

Rendimento integrato con continuazione tramite scheduler.yield

Una parte proposta dell'API scheduler è scheduler.yield, un'API specificamente progettata per passare al thread principale nel browser che è attualmente disponibile per essere provata come prova dell'origine. Il suo utilizzo è simile alla funzione yieldToMain() mostrata in precedenza in questo articolo:

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

Noterai che il codice riportato sopra è familiare, ma invece di usare yieldToMain(), chiami e await scheduler.yield().

Tre diagrammi che illustrano le attività senza cedere, produrre e con cedimento e continuazione. Senza cedere, ci sono attività lunghe. Con il rendimento, ci sono più attività che sono più brevi, ma potrebbero essere interrotte da altre attività non correlate. Con la resa e la continuazione, ci sono più attività che sono più brevi, ma il loro ordine di esecuzione viene mantenuto.
Una visualizzazione dell'esecuzione delle attività senza cedimento, con rendimento, con rendimento e continuazione. Quando viene utilizzato il criterio scheduler.yield(), l'esecuzione dell'attività riprende da dove era stata interrotta, anche dopo il punto di rendimento.

Il vantaggio di scheduler.yield() è la continuazione, il che significa che se registri 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 si evita che il codice di script di terze parti usurpa l'ordine di esecuzione del codice.

Conclusione

Gestire le attività è complesso, ma farlo aiuta la tua pagina a rispondere più rapidamente alle interazioni degli utenti. Non esiste un unico consiglio per gestire e assegnare la priorità alle attività. ma piuttosto una serie di tecniche diverse. Come ribadito, ecco gli aspetti principali da considerare quando gestisci le attività:

  • Rimanda al thread principale per le attività critiche rivolte agli utenti.
  • Usa isInputPending() per cedere al thread principale quando l'utente sta cercando di interagire con la pagina.
  • Dai priorità alle attività con postTask().
  • Infine, svolgi il minor lavoro possibile nelle tue funzioni.

Con uno o più di questi strumenti, dovresti essere in grado di strutturare il lavoro nell'applicazione in modo da dare priorità alle esigenze dell'utente, garantendo al contempo che venga comunque svolto meno il lavoro più critico. Ciò migliorerà l'esperienza utente, più reattiva e più piacevole da utilizzare.

Un ringraziamento speciale a Philip Walton per la verifica tecnica di questo articolo.

Immagine hero proveniente da Unsplash, gentilmente concessa da Amirali Mirhashemian.