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 Mariko, Jake e io abbiamo spedito PROXX, un moderno clone di campo minato per il web. Alcuni aspetti che contraddistinguono PROXX sono la particolare attenzione all'accessibilità (puoi giocarci con uno screen reader) e la possibilità di funzionare bene su un feature phone come su un computer di fascia alta. I feature phone sono vincolati in diversi modi:

  • CPU deboli
  • GPU deboli o inesistenti
  • Schermi piccoli senza input tocco
  • quantità di memoria molto limitate

Tuttavia, eseguono un browser moderno e sono molto convenienti. Per questo motivo, i feature phone stanno prendendo piede nei mercati emergenti. Il loro prezzo consente a un pubblico completamente nuovo, che prima non poteva permetterselo, di entrare 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 di feature phone potrebbero diventare una parte significativa del tuo pubblico. Inoltre, nei mercati emergenti la velocità di connessione è simile a quella del 2G. Come siamo riusciti a far funzionare bene PROXX in condizioni di feature phone?

Gameplay PROXX.

Le prestazioni sono importanti e includono quelle di caricamento e di runtime. È stato dimostrato che un buon rendimento è correlato a un aumento della fidelizzazione degli utenti, al miglioramento delle conversioni e, soprattutto, all'aumento dell'inclusività. Jeremy Wagner ha molti più dati e insight sul motivo per cui le prestazioni sono importanti.

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 del runtime.

Acquisisci lo status quo

È fondamentale testare le prestazioni di caricamento su un dispositivo reale. Se non hai un dispositivo reale a portata di mano, ti consiglio WebPageTest, in particolare la "semplice" configurazione. WPT esegue una batteria per i test di caricamento su un dispositivo reale con una connessione 3G emulata.

Il 3G è una buona velocità da misurare. Sebbene siate abituati al 4G, LTE o presto anche al 5G, la realtà di internet mobile sembra molto diversa. Forse sei in treno, a una conferenza, a un concerto o in aereo. In questo caso probabilmente ti troverai più vicino al 3G, e a volte anche peggio.

Detto questo, in questo articolo ci concentreremo sul 2G, perché PROXX sceglie esplicitamente come target i feature phone e i mercati emergenti nel suo pubblico di destinazione. Una volta che WebPageTest ha eseguito il test, ottieni una struttura a cascata (simile a quella visualizzata in DevTools) e una sequenza in alto. La pellicola 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 sequenza mostra ciò che l'utente vede quando PROXX viene caricato su un dispositivo reale di fascia bassa tramite una connessione 2G emulata.

Quando viene caricato tramite 3G, l'utente vede 4 secondi di vuoto bianco. Con il 2G l'utente non vede assolutamente nulla per oltre 8 secondi. Se leggi perché le prestazioni sono importanti, sai 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 elemento venga visualizzato sullo schermo. Il lato positivo di questo scenario è che anche il secondo elemento visualizzato sullo schermo è 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 gzip'd JS e generato il DOM, l'utente può vedere la nostra app. L'app è tecnicamente interattiva. Guardare l'immagine, però, mostra una realtà diversa. I caratteri web vengono ancora caricati in background e l'utente non può vedere alcun testo finché non sono pronti. Sebbene questo stato sia qualificato come First Meaningful Paint (FMP), di sicuro non può essere considerato correttamente interattivo, in quanto l'utente non è in grado di indicare l'argomento degli input. Ci vuole un altro secondo su 3G e 3 secondi su 2G fino a quando l'app è pronta. Nel complesso, l'app richiede 6 secondi su 3G e 11 secondi su 2G per diventare interattiva.

Analisi della struttura a cascata

Ora che sappiamo cosa vede l'utente, dobbiamo capirne il perché. A questo scopo, possiamo esaminare la struttura a cascata e analizzare perché le risorse vengono caricate troppo tardi. Nella traccia 2G di PROXX possiamo notare due importanti segnali di allarme:

  1. Ci sono più 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 risorsa, mentre la terza si avvia solo quando la seconda risorsa termina.
di Gemini Advanced.
La struttura a cascata offre informazioni su quali risorse vengono caricate, quando e quanto tempo richiedono.

Riduzione del numero di connessioni in corso...

Ogni riga sottile (dns, connect, ssl) indica la creazione di una nuova connessione HTTP. La configurazione di una nuova connessione è costosa, in quanto richiede circa 1 secondo su 3G e circa 2,5 secondi su 2G. Nella struttura a cascata vediamo un nuovo collegamento per:

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

La nuova connessione per index.html è inevitabile. Il browser deve creare una connessione al nostro server per recuperare i contenuti. La nuova connessione a Google Analytics potrebbe essere evitata inserendo qualcosa come Minimal Analytics, ma Google Analytics non blocca il rendering della nostra app o diventa interattiva, quindi non ci interessa molto la velocità di caricamento. Idealmente, Google Analytics dovrebbe essere caricato in tempo di inattività, quando tutti gli altri sono già stati caricati. In questo modo non assorbirà larghezza di banda o potenza di elaborazione durante il caricamento iniziale. La nuova connessione per il file manifest dell'app web è prescritta dalle specifiche di recupero, poiché il manifest deve essere caricato tramite una connessione non accreditata. Anche in questo caso, il file manifest dell'app web non impedisce alla nostra app di essere visualizzata o di diventare interattiva, quindi non ci interessa molto.

I due caratteri e i relativi stili, tuttavia, costituiscono un problema in quanto bloccano il rendering e anche l'interattività. Se osserviamo il CSS pubblicato da fonts.googleapis.com, si tratta solo di due regole @font-face, una per ogni carattere. Gli stili dei caratteri sono di fatto talmente ridotti che abbiamo deciso di incorporarli nel nostro codice HTML, rimuovendo così una connessione non necessaria. Per evitare il costo della configurazione della connessione per i file dei caratteri, possiamo copiarli sul nostro server.

Collegamento in parallelo dei carichi

Osservando la struttura a cascata, possiamo notare che, una volta completato il caricamento del primo file JavaScript, i nuovi file iniziano immediatamente. Questo è tipico per le dipendenze dei moduli. È probabile che il nostro modulo principale contenga importazioni statiche, quindi JavaScript non può essere eseguito fino a quando queste importazioni non vengono caricate. Qui è importante capire che questi tipi di dipendenze sono noti in fase di creazione. Possiamo usare i tag <link rel="preload"> per assicurarci che tutte le dipendenze inizino a caricarsi nel momento in cui riceviamo il nostro codice HTML.

Risultati

Diamo un'occhiata ai risultati dei nostri cambiamenti. È importante non modificare altre variabili nella configurazione del test che potrebbero alterare i risultati, quindi utilizzeremo la semplice configurazione di WebPageTest per il resto di questo articolo e osserveremo 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, ovvero approssimativamente i 2,5 secondi di tempo di configurazione della connessione che volevamo rimuovere. Complimenti.

Prerendering

Anche se abbiamo solo ridotto il nostro TTI, non abbiamo realmente influito sulla schermata bianca infinitamente lunga che l'utente deve sopportare per 8,5 secondi. Probabilmente, i maggiori miglioramenti a FMP possono essere ottenuti inviando markup con stili nel tuo index.html. Le tecniche comuni per raggiungere questo obiettivo sono il prerendering e il rendering lato server, che sono strettamente correlate e sono spiegate in Rendering sul web. Entrambe le tecniche eseguono l'app web in Node e serializzano il DOM risultante in HTML. Il rendering lato server esegue questa operazione per richiesta sul lato server, mentre il prerendering esegue questa operazione in fase di build e archivia l'output come nuovo index.html. Poiché PROXX è un'app JAMStack e non ha un lato server, abbiamo deciso di implementare il prerendering.

Esistono molti modi per implementare un prerendering. In PROXX abbiamo scelto di utilizzare Puppeteer, che avvia Chrome senza UI e ti consente di controllare l'istanza da remoto con un'API Node. Utilizziamo questo metodo per inserire il nostro markup e il nostro codice JavaScript e quindi rileggere il DOM come stringa di codice HTML. Poiché utilizziamo i moduli CSS, riusciamo a incorporare gli stili di cui abbiamo bisogno senza costi.

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

Dopodiché, possiamo aspettarci un miglioramento del nostro FMP. Dobbiamo ancora caricare ed eseguire la stessa quantità di JavaScript di prima, quindi non dovremmo aspettarci che il TTI cambi molto. Al contrario, il nostro index.html è diventato più grande e potrebbe respingere un po' il nostro TTI. C'è solo un modo per scoprirlo: eseguire WebPageTest.

La sequenza mostra un netto miglioramento per la metrica FMP. Il TTI per lo più non è interessato.

La nostra First Meaningful Paint è passata da 8,5 a 4,9 secondi, un enorme miglioramento. Il nostro TTI si svolge ancora a circa 8,5 secondi, quindi non è stato in gran parte influenzato da questa modifica. Qui abbiamo fatto un cambiamento percettivo. Qualcuno potrebbe persino chiamarlo "sfida di controllo". Con il rendering di un'immagine intermedia del gioco, modifichiamo in meglio le prestazioni di caricamento percepite.

Incorporato

Un'altra metrica fornita da DevTools e 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 RTT (Round Trip Time), anche se tecnicamente esiste una differenza tra questi due numeri: RTT non include il tempo di elaborazione della richiesta sul lato server. DevTools e WebPageTest visualizzano il TTFB con un colore chiaro all'interno del blocco 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 notare che tutte le richieste passano la maggior parte del loro tempo ad attendere l'arrivo del primo byte della risposta.

Si tratta del problema per cui era stato inizialmente concepito il push HTTP/2. Lo sviluppatore dell'app sa che alcune risorse sono necessarie e può respingere le risorse. Quando il client si rende conto che deve recuperare ulteriori risorse, queste si trovano già nelle cache del browser. HTTP/2 Push si è rivelato troppo difficile da ottenere ed è considerato scoraggiata. Questo spazio 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 critico è già incorporato grazie ai moduli CSS e al nostro prerendering basato su Puppeteer. Per JavaScript dobbiamo incorporare i nostri moduli critici e le loro dipendenze. Questa attività presenta difficoltà diverse a seconda del bundler utilizzato.

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

Questo ha tagliato 1 secondo dal nostro TTI. Abbiamo raggiunto il punto in cui index.html contiene tutto ciò che serve per il rendering iniziale e l'interazione. Il codice HTML può essere visualizzato mentre è ancora in fase di download, creando il nostro file FMP. Al termine dell'analisi e dell'esecuzione del codice HTML, l'app diventa interattiva.

Suddivisione aggressiva del codice

Sì, il nostro index.html contiene tutto ciò che serve per diventare interattivo. Ma a un'analisi più approfondita è emerso che contiene anche tutto il resto. Le dimensioni del nostro index.html sono circa 43 kB. Mettiamolo in relazione con 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 per mantenere e caricare le impostazioni utente. Questo è praticamente tutto. 43 kB sembrano molti.

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

Per capire da dove provengono le dimensioni del nostro bundle, possiamo utilizzare uno strumento di esplorazione delle mappe di origine o uno strumento simile per suddividere la struttura del bundle. Come previsto, il nostro bundle contiene la logica di gioco, il motore di rendering, la schermata di vincita, la schermata di perdita e una serie di utilità. È necessario solo un piccolo sottoinsieme di questi moduli per la pagina di destinazione. Spostare tutto ciò che non è strettamente necessario per l'interattività in un modulo caricato tramite caricamento lento ridurrà il TTI in modo significativo.

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

Dobbiamo eseguire la suddivisione del codice. La suddivisione del codice suddivide il bundle monolitico in parti più piccole che possono essere caricate on demand con il caricamento lento. I bundler più noti come Webpack, Rollup e Parcel supportano la suddivisione del codice tramite import() dinamico. Il bundler analizza il codice e incorpora tutti i moduli importati in modo statico. Tutto ciò che importi in modo dinamico viene inserito in un file separato e recuperato dalla rete solo dopo l'esecuzione della chiamata import(). Naturalmente raggiungere la rete ha un costo e dovrebbe essere fatto solo se si ha tempo libero. Il mantra in questo caso è importare in modo statico i moduli fondamentali necessari al momento del caricamento e caricare dinamicamente tutto il resto. Non aspettare, però, fino all'ultimo momento per caricare i moduli con il caricamento lento che sicuramente verranno utilizzati. Idle up Urgent di Phil Walton è un ottimo esempio per stabilire una soluzione sana tra il caricamento lento e quello "eager".

In PROXX abbiamo creato un file lazy.js che importa in modo statico tutto ciò di cui non abbiamo bisogno. Nel file principale, possiamo importare in modo dinamico 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 da subito i componenti caricati tramite caricamento lento. Per questo motivo abbiamo scritto un piccolo wrapper deferred che ci permette di eseguire il rendering di un segnaposto fino a quando il componente effettivo non è stato caricato.

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

In questo modo, possiamo utilizzare una 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 uno <div> vuoto durante il caricamento del componente. Quando il componente è stato caricato ed è 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 tutte queste funzionalità, abbiamo ridotto le dimensioni di index.html a soli 20 kB, meno della metà delle dimensioni originali. Che effetto ha tutto ciò su FMP e TTI? WebPageTest lo dirà!

La sequenza conferma: il nostro TTI ora è a 5,4 secondi. Un netto miglioramento rispetto agli 11 iniziali.

I nostri FMP e TTI sono a distanza di soli 100 ms l'uno dall'altro, poiché è solo 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ù giochi perfetti

Se esamini l'elenco dei moduli critici riportato sopra, noterai che il motore di rendering non fa parte dei moduli critici. Ovviamente, il gioco non può iniziare finché non abbiamo il nostro motore di rendering per eseguire il rendering del gioco. Potremmo disattivare "Avvio" finché il nostro motore di rendering non è pronto per avviare il gioco, ma in base alla nostra esperienza di solito l'utente impiega abbastanza tempo per configurare le impostazioni di gioco da non rendere necessario il gioco. La maggior parte delle volte il motore di rendering e gli altri moduli rimanenti vengono caricati prima del momento in cui l'utente preme "Avvia". Nel raro caso in cui l'utente sia più veloce della sua connessione di rete, viene visualizzata una semplice schermata di caricamento che attende il completamento dei moduli rimanenti.

Conclusione

La misurazione è importante. Per evitare di perdere tempo su problemi che non sono reali, consigliamo di effettuare le misurazioni sempre prima di implementare le ottimizzazioni. Inoltre, le misurazioni devono essere effettuate su dispositivi reali con una connessione 3G o su WebPageTest se non è presente alcun dispositivo reale.

La sequenza può fornire informazioni sull'esperienza di caricamento della tua app per l'utente. La struttura a cascata può indicare quali risorse sono responsabili di tempi di caricamento potenzialmente lunghi. Ecco un elenco di controllo delle azioni che puoi intraprendere per migliorare le prestazioni di caricamento:

  • Pubblica il maggior numero possibile di asset su una singola connessione.
  • Puoi precaricare le risorse in linea o anche quelle in linea necessarie per la prima visualizzazione e interattività.
  • Esegui il prerendering dell'app per migliorare le prestazioni di caricamento percepite.
  • Sfrutta la suddivisione del codice aggressiva per ridurre la quantità di codice necessaria per l'interattività.

Continua a seguirci per la parte 2, in cui parleremo di come ottimizzare le prestazioni di runtime sui dispositivi con vincoli iperlimitati.