Tecniche per velocizzare il caricamento di un'app web, anche su un feature phone

Come abbiamo utilizzato la suddivisione del codice, l'incorporamento del codice e il rendering lato server in PROXX.

Alla conferenza Google I/O 2019, io e Jake abbiamo spedito PROXX, un moderno clone di Campo minato per il web. Un elemento che contraddistingue PROXX è l'attenzione all'accessibilità (si può usare con uno screen reader) e la possibilità di funzionare sia su feature phone sia su un computer di fascia alta. I Feature phone sono limitati in diversi modi:

  • CPU deboli
  • GPU deboli o inesistenti
  • Schermi piccoli senza input tocco
  • Quantità di memoria molto limitata

ma eseguono un browser moderno e sono molto convenienti. Per questo motivo, i feature phone stanno crescendo nei mercati emergenti. Il loro prezzo consente a un pubblico completamente nuovo, che prima non poteva permettersi di farlo, di accedere online e utilizzare il web moderno. Per il 2019 si prevede che nella sola India verranno venduti circa 400 milioni di feature phone, perciò gli utenti che usano i feature phone potrebbero diventare una parte significativa del tuo pubblico. Inoltre, velocità di connessione simili a quelle del 2G sono la norma nei mercati emergenti. Come siamo riusciti a far funzionare bene PROXX in condizioni per i feature phone?

Gameplay PROXX.

Le prestazioni sono importanti e includono sia le prestazioni di caricamento sia le prestazioni di runtime. Ha dimostrato che un buon rendimento è correlato a una maggiore fidelizzazione degli utenti, a un aumento delle conversioni e, soprattutto, a una maggiore inclusività. Jeremy Wagner dispone di molti più dati e approfondimenti sull'importanza delle prestazioni.

Questa è la prima parte di una serie in due parti. La parte 1 è incentrata sulle prestazioni di caricamento, mentre la parte 2 è incentrata sulle prestazioni di runtime.

Catturare lo status quo

Verificare le prestazioni di caricamento su un dispositivo reale è fondamentale. Se non disponi di un dispositivo reale a portata di mano, ti consiglio di WebPageTest, in particolare la configurazione "semplice". Il protocollo WPT esegue una batteria di test di caricamento su un dispositivo reale con una connessione 3G emulata.

Il 3G è un'ottima velocità da misurare. Sebbene tu conosca già il 4G, l'LTE o presto anche il 5G, la realtà di internet mobile ha un aspetto piuttosto diverso. Magari sei su un treno, a una conferenza, a un concerto o in aereo. È molto probabile che la tecnologia 3G sia più vicina al segnale 3G, e a volte anche peggio.

Detto questo, in questo articolo ci concentreremo sul 2G perché PROXX ha come target esplicito i feature phone e i mercati emergenti nel suo pubblico di destinazione. Dopo che WebPageTest ha eseguito il test, ottieni una struttura a cascata (simile a quella visualizzata in DevTools) e una pellicola nella parte superiore. Il rullino mostra ciò che l'utente vede durante il caricamento dell'app. Su 2G, l'esperienza di caricamento della versione non ottimizzata di PROXX è piuttosto scadente:

Il video della pellicola mostra ciò che l'utente vede quando PROXX viene caricato su un dispositivo reale di fascia bassa su una connessione 2G emulata.

Quando un caricamento avviene tramite 3G, l'utente vede 4 secondi di niente bianco. Con il 2G l'utente non vede assolutamente nulla per oltre 8 secondi. Se leggi perché il rendimento è importante, saprai che ora abbiamo perso una buona parte dei nostri potenziali utenti a causa dell'impazienza. L'utente deve scaricare tutti i 62 kB di JavaScript affinché qualsiasi cosa venga visualizzata sullo schermo. Il lato positivo di questo scenario è che il secondo elemento che appare sullo schermo è anch'esso interattivo. O no?

[First Meaningful Paint][FMP] nella versione non ottimizzata di PROXX è _tecnicamente_ [interattiva][TTI] ma inutile per l'utente.

Dopo aver scaricato circa 62 kB di file JS gzip e generato il DOM, l'utente può vedere la nostra app, che è tecnicamente interattiva. Osservare questo aspetto, invece, mostra una realtà diversa. I caratteri web vengono ancora caricati in background e finché non sono pronti, l'utente non può vedere alcun testo. Anche se questo stato si qualifica come First Meaningful Paint (FMP), non può essere considerato correttamente interattivo, in quanto l'utente non può capire di cosa trattano gli input. Occorre un altro secondo con il 3G e 3 secondi con il 2G finché l'app non è pronta all'uso. Complessivamente, l'app impiega 6 secondi su 3G e 11 secondi su 2G per diventare interattiva.

Analisi a cascata

Ora che sappiamo cosa vede l'utente, dobbiamo capire il perché. Per questo possiamo osservare la struttura a cascata e analizzare il motivo per cui le risorse vengono caricate troppo tardi. Nella nostra traccia 2G per PROXX possiamo osservare due importanti segnali d'allarme:

  1. Sono presenti diverse linee sottili di diversi colori.
  2. I file JavaScript formano una catena. Ad esempio, il caricamento della seconda risorsa inizia solo al termine della prima, mentre la terza inizia solo al termine della seconda.
La struttura a cascata fornisce insight su quali risorse vengono caricate, quando e per quanto tempo.

Riduzione del numero di connessioni

Ogni linea sottile (dns, connect, ssl) indica la creazione di una nuova connessione HTTP. La configurazione di una nuova connessione è costosa poiché richiede circa 1 secondo su 3G e circa 2,5 secondi su 2G. Nella nostra struttura a cascata vediamo una nuova connessione per:

  • Richiesta n. 1: index.html
  • Richiesta n. 5: gli stili dei caratteri di fonts.googleapis.com
  • Richiesta n. 8: Google Analytics
  • Richiesta 9: un file dei caratteri di fonts.gstatic.com
  • Richiesta 14: manifest dell'app web

La nuova connessione per index.html è inevitabile. Il browser deve creare una connessione al nostro server per ottenere i contenuti. La nuova connessione per Google Analytics potrebbe essere evitata incorporando qualcosa come Analisi minima, ma Google Analytics non blocca il rendering dell'app e non diventa interattiva, quindi non ci interessa davvero la velocità di caricamento. Idealmente, Google Analytics dovrebbe essere caricato in tempo di inattività, quando tutto il resto è già stato caricato. In questo modo, il dispositivo non consuma larghezza di banda o potenza di elaborazione durante il caricamento iniziale. La nuova connessione per il manifest dell'app web è prescritta dalla specifica di recupero, poiché il manifest deve essere caricato tramite una connessione non protetta. Anche in questo caso, il file manifest dell'app web non impedisce alla nostra app di eseguire il rendering o di diventare interattiva, quindi non dobbiamo preoccuparci di tanto.

I due caratteri e i relativi stili, tuttavia, bloccano il rendering e anche l'interattività. Se diamo un'occhiata al CSS pubblicato da fonts.googleapis.com, ci sono solo due regole @font-face, una per ogni carattere. Gli stili dei caratteri sono così piccoli che abbiamo deciso di incorporarli nel nostro codice HTML rimuovendo una connessione non necessaria. Per evitare i costi di configurazione della connessione per i file di caratteri, possiamo copiarli sul nostro server.

Caricamento in contemporanea dei caricamenti

Dando un'occhiata alla struttura a cascata, possiamo notare che una volta completato il caricamento del primo file JavaScript, il caricamento dei nuovi file inizia immediatamente. Questo è tipico per le dipendenze dei moduli. Il nostro modulo principale probabilmente contiene importazioni statiche, quindi JavaScript non può essere eseguito fino al caricamento di queste importazioni. È importante capire che questo tipo di dipendenze è noto al momento della creazione. Possiamo utilizzare i tag <link rel="preload"> per assicurarci che il caricamento di tutte le dipendenze inizi non appena riceviamo il codice HTML.

Risultati

Diamo un'occhiata ai risultati delle nostre modifiche. È importante non modificare altre variabili della configurazione del test che potrebbero alterare i risultati, pertanto utilizzeremo la semplice configurazione di WebPageTest per il resto di questo articolo e esamineremo la sequenza:

Utilizziamo la sequenza di WebPageTest per vedere i risultati delle nostre modifiche.

Queste modifiche hanno ridotto il nostro TTI da 11 a 8,5, che corrisponde a circa 2,5 secondi del tempo di configurazione della connessione che volevamo rimuovere. Complimenti.

Prerendering

Anche se abbiamo appena ridotto il nostro TTI, non abbiamo davvero influenzato l'eterna lunga schermata bianca che l'utente deve sopportare per 8,5 secondi. Probabilmente i maggiori miglioramenti per FMP possono essere ottenuti inviando il markup con stile in index.html. Le tecniche comuni per raggiungere questo obiettivo sono il prerendering e il rendering lato server, che sono strettamente correlati e sono spiegati nella sezione Rendering sul web. Entrambe le tecniche eseguono l'app web in Node e serializzano il DOM risultante in HTML. Il rendering lato server lo fa per richiesta sul lato server, mentre il prerendering lo fa al momento della creazione e archivia l'output come nuovo index.html. Poiché PROXX è un'app JAMStack e non ha lato server, abbiamo deciso di implementare il prerendering.

Esistono molti modi per implementare uno strumento di prerendering. In PROXX abbiamo scelto di usare Puppeteer, che avvia Chrome senza UI e consente di controllare a distanza l'istanza con un'API Node. Lo utilizziamo per inserire il markup e il codice JavaScript, quindi leggiamo il DOM come stringa di HTML. Dato che stiamo utilizzando i moduli CSS, riceviamo senza costi gli stili CSS incorporati.

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(rawIndexHTML);
  await page.evaluate(codeToRun);
  const renderedHTML = await page.content();
  browser.close();
  await writeFile("index.html", renderedHTML);

Con questa impostazione, possiamo aspettarci un miglioramento per il nostro FMP. Dobbiamo comunque caricare ed eseguire la stessa quantità di JavaScript di prima, quindi non dobbiamo aspettarci che il servizio TTI cambi molto. Se qualcosa, il nostro index.html è diventato più grande e potrebbe respingere un po' il nostro TTI. C'è solo un modo per scoprirlo: eseguire WebPageTest.

La pellicola mostra un netto miglioramento per la nostra metrica FMP. Il framework TTI rimane per lo più inalterato.

La nostra First Meaningful Paint è passata da 8,5 a 4,9 secondi, un enorme miglioramento. Il nostro TTI avviene ancora a circa 8,5 secondi, quindi non è stato in gran parte influenzato da questa modifica. Quello che abbiamo fatto in questo caso è una modifica percettiva. Per alcuni è un gioco di prestigio. Rendendo un'immagine intermedia del gioco, modificheremo in meglio le prestazioni di caricamento percepite.

In linea

Un'altra metrica fornita sia da DevTools che da WebPageTest è Time To First Byte (TTFB). Si tratta del tempo che intercorre tra il primo byte della richiesta inviata e il primo byte della risposta ricevuta. Questo tempo viene spesso chiamato anche Round Trip Time (RTT), anche se tecnicamente esiste una differenza tra questi due numeri: RTT non include il tempo di elaborazione della richiesta lato server. DevTools e WebPageTest visualizzano il TTFB con un colore chiaro all'interno del blocco di richiesta/risposta.

La sezione leggera di una richiesta indica che la richiesta è in attesa di ricevere il primo byte della risposta.

Osservando la nostra struttura a cascata, possiamo vedere che tutte le richieste trascorrono la maggior parte del loro tempo ad attendere l'arrivo del primo byte della risposta.

Questo problema era lo scopo per cui il push HTTP/2 era stato originariamente concepito. Lo sviluppatore dell'app sa che determinate risorse sono necessarie e può spingerle in un secondo momento. Quando il client si rende conto che deve recuperare risorse aggiuntive, queste si trovano già nelle cache del browser. Il comando push HTTP/2 si è rivelato troppo difficile da eseguire correttamente ed è considerato sconsigliato. Questo spazio problematico verrà riesaminato durante la standardizzazione di HTTP/3. Per ora, la soluzione più semplice è incorporare tutte le risorse critiche a scapito dell'efficienza della memorizzazione nella cache.

Il nostro CSS è già integrato grazie ai moduli CSS e al nostro prerendering basato su Puppeteer. Per quanto riguarda JavaScript, dobbiamo incorporare i nostri moduli critici e le loro dipendenze. Questa attività ha difficoltà diverse a seconda del bundler che stai utilizzando.

Con l'incorporamento di JavaScript abbiamo ridotto il TTI da 8,5 a 7,2 secondi.

Questo ha permesso di ridurre di 1 secondo il nostro TTI. Abbiamo raggiunto il punto in cui index.html contiene tutto il necessario per il rendering iniziale e per l'interattività. Il codice HTML può essere visualizzato mentre è ancora in fase di download, creando il nostro file FMP. Nel momento in cui l'analisi e l'esecuzione del codice HTML è completata, l'app è interattiva.

Suddivisione aggressiva del codice

Sì, il nostro index.html contiene tutto ciò che serve per diventare interattiva. Ma a un'analisi più approfondita si è scoperto che contiene anche tutto il resto. La dimensione di index.html è di circa 43 kB. Mettiamolo in relazione a ciò con cui l'utente può interagire all'inizio: abbiamo un modulo per configurare il gioco contenente un paio di componenti, un pulsante di avvio e probabilmente del codice da mantenere e caricare le impostazioni utente. Ci siamo quasi. 43 kB sembrano tanti.

La pagina di destinazione di PROXX. Qui vengono utilizzati solo i componenti critici.

Per capire da dove proviene la dimensione del bundle, possiamo utilizzare un Explorer mappa di origine o uno strumento simile per capire in cosa consiste il bundle. Come previsto, il nostro bundle contiene la logica del gioco, il motore di rendering, la schermata di vincita, la schermata persa e una serie di utilità. È necessario solo un piccolo sottoinsieme di questi moduli per la pagina di destinazione. Lo spostamento di tutto ciò che non è strettamente necessario per l'interattività in un modulo caricato lentamente ridurrà il TTI in modo significativo.

L'analisi dei contenuti di "index.html" di PROXX mostra molte risorse non necessarie. Le risorse critiche sono evidenziate.

Dobbiamo invece eseguire la suddivisione del codice. La suddivisione del codice suddivide il bundle monolitico in parti più piccole che possono essere caricate tramite caricamento lento on demand. I bundle noti come Webpack, Rollup e Parcel supportano la suddivisione del codice tramite l'utilizzo dinamico di import(). Il bundler analizzerà il tuo codice e in linea tutti i moduli importati in modo statico. Tutto ciò che importi dinamicamente verrà inserito nel proprio file e verrà recuperato dalla rete solo dopo l'esecuzione della chiamata import(). Ovviamente contattare la rete ha un costo e dovrebbe essere fatto solo se si ha il tempo a disposizione. Il mantra qui è importare in modo statico i moduli criticamente necessari al momento del caricamento e caricare dinamicamente tutto il resto. Ma non aspettare fino all'ultimo momento per utilizzare il caricamento lento dei moduli che sicuramente verranno utilizzati. Inattivo fino al momento urgente di Phil Walton è un ottimo modello per trovare un mezzo per trovare un mezzo tra il caricamento lento e il caricamento impaziente.

Nel file PROXX abbiamo creato un file lazy.js che importa in modo statico tutto ciò di cui non abbiamo bisogno. Nel nostro file principale, possiamo importare dinamicamente lazy.js. Tuttavia, alcuni dei nostri componenti Preact sono finiti in lazy.js, il che si è rivelato una complicazione, in quanto Preact non è in grado di gestire immediatamente i componenti caricati tramite caricamento lento. Per questo motivo abbiamo scritto un piccolo wrapper del componente deferred che ci consente di eseguire il rendering di un segnaposto fino al caricamento del componente effettivo.

export default function deferred(componentPromise) {
  return class Deferred extends Component {
    constructor(props) {
      super(props);
      this.state = {
        LoadedComponent: undefined
      };
      componentPromise.then(component => {
        this.setState({ LoadedComponent: component });
      });
    }

    render({ loaded, loading }, { LoadedComponent }) {
      if (LoadedComponent) {
        return loaded(LoadedComponent);
      }
      return loading();
    }
  };
}

Una volta impostata questa impostazione, possiamo usare la Promessa di un componente nelle nostre funzioni render(). Ad esempio, il componente <Nebula>, che esegue il rendering dell'immagine di sfondo animata, verrà sostituito da un elemento <div> vuoto durante il caricamento del componente. Una volta che il componente è stato caricato e pronto per l'uso, <div> verrà sostituito con il componente effettivo.

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

Con tutto questo, abbiamo ridotto il nostro index.html a soli 20 kB, meno della metà delle dimensioni originali. Quali sono gli effetti su FMP e TTI? WebPageTest lo dirà.

La sequenza conferma: il nostro TTI ora è a 5,4 secondi. Un miglioramento drastico rispetto ai nostri 11 originali.

FMP e TTI si trovano a soli 100 ms di distanza l'uno dall'altro, poiché si tratta solo di una questione di analisi ed esecuzione del codice JavaScript incorporato. Dopo soli 5,4 secondi su 2G, l'app è completamente interattiva. Tutti gli altri moduli meno essenziali vengono caricati in background.

Più alta libertà di mano

Se esamini l'elenco dei moduli critici sopra riportato, potrai vedere che il motore di rendering non fa parte di questi. Ovviamente, il gioco non può iniziare finché non abbiamo il nostro motore di rendering per eseguire il rendering del gioco. Potremmo disattivare il pulsante "Avvia" fino a quando il nostro motore di rendering non è pronto per iniziare il gioco, ma secondo la nostra esperienza l'utente di solito impiega abbastanza tempo per configurare le impostazioni di gioco che non è necessario. La maggior parte delle volte il motore di rendering e gli altri moduli rimanenti completano il caricamento quando l'utente preme "Avvia". Nel raro caso in cui l'utente sia più veloce della connessione di rete, mostriamo una schermata di caricamento semplice che attende il completamento dei moduli rimanenti.

Conclusione

La misurazione è importante. Per evitare di perdere tempo in problemi reali, ti consigliamo di effettuare le misurazioni prima di implementare le ottimizzazioni. Inoltre, le misurazioni devono essere eseguite su dispositivi reali con connessione 3G o su WebPageTest se non sono presenti dispositivi reali.

La sequenza può fornire informazioni sull'senso di caricamento della tua app per l'utente. La struttura a cascata può indicare le risorse che causano tempi di caricamento potenzialmente lunghi. Ecco un elenco di controllo di ciò che puoi fare per migliorare le prestazioni di caricamento:

  • Pubblica il maggior numero possibile di asset su una singola connessione.
  • Precarica le risorse o addirittura in linea necessarie per la prima interazione e il primo rendering.
  • Esegui il prerendering della tua app per migliorare le prestazioni di caricamento percepite.
  • Utilizza una suddivisione del codice aggressiva per ridurre la quantità di codice necessaria per l'interattività.

Continua a seguirci per la seconda parte, in cui parleremo di come ottimizzare le prestazioni di runtime sui dispositivi iper vincolati.