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

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

In occasione della conferenza Google I/O 2019, Mariko, Jake e io abbiamo rilasciato PROXX, un clone moderno di Campo Minato per il web. ProXX si distingue per l'accessibilità (puoi giocare 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 limitati in diversi modi:

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

Tuttavia, utilizzano un browser moderno e sono molto convenienti. Per questo motivo, i cellulari stanno vivendo una rinascita nei mercati emergenti. Il loro prezzo consente a un pubblico completamente nuovo, che in precedenza non poteva permetterselo, di accedere a internet 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 telefoni con funzionalità di base?

Gameplay di PROXX.

Le prestazioni sono importanti, sia quelle in fase di caricamento che quelle 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 altri dati e approfondimenti su perché il rendimento è importante.

Questa è la prima parte di una serie in due parti. La parte 1 si concentra sul rendimento in fase di caricamento, mentre la parte 2 si concentra sul rendimento in fase di esecuzione.

Catturare 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 configurazione "semplice". WPT esegue una serie di test di carica su un dispositivo reale con una connessione 3G emulata.

La rete 3G è una buona velocità da misurare. Anche se potresti essere abituato al 4G, all'LTE o, a breve, anche al 5G, la realtà di internet mobile è molto diversa. Ad esempio, potresti essere su un treno, a una conferenza, a un concerto o su un volo. Molto probabilmente, la velocità che otterrai sarà simile a quella del 3G e, a volte, anche peggiore.

Detto questo, in questo articolo ci concentreremo sul 2G perché PROXX ha scelto come target esplicito i feature phone e i mercati emergenti. Una volta eseguito il test, WebPageTest mostra una visualizzazione a cascata (simile a quella che vedi in DevTools) e una sequenza di immagini in alto. La sequenza di immagini mostra ciò che l'utente vede durante il caricamento dell'app. Con la rete 2G, l'esperienza di caricamento della versione non ottimizzata di PROXX è piuttosto negativa:

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

Quando viene caricato tramite 3G, l'utente vede 4 secondi di vuoto bianco. Oltre 2G, l'utente non vede assolutamente nulla per oltre 8 secondi. Se hai letto l'articolo Perché le prestazioni sono importanti, sai che abbiamo perso una buona parte dei nostri potenziali utenti a causa dell'impazienza. L'utente deve scaricare tutti i 62 KB di JavaScript affinché qualcosa venga visualizzato sullo schermo. Il lato positivo di questo scenario è che, non appena qualcosa viene visualizzato sullo schermo, diventa anche interattivo. O no?

La [prima visualizzazione significativa][FMP] nella versione non ottimizzata di PROXX è _tecnicamente_ [interattiva][TTI], ma inutile per l'utente.

Dopo aver scaricato circa 62 KB di codice JS compresso con gzip e aver generato il DOM, l'utente può vedere la nostra app. L'app è tecnicamente interattiva. Tuttavia, l'immagine mostra una realtà diversa. I caratteri web sono ancora in fase di caricamento in background e, finché non sono pronti, l'utente non può vedere alcun testo. Sebbene questo stato sia considerato un First Meaningful Paint (FMP), non è sicuramente interattivo, in quanto l'utente non può capire a cosa si riferiscono gli input. Sono necessari un altro secondo su 3G e 3 secondi su 2G prima che l'app sia pronta all'uso. In totale, l'app impiega 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 capire il perché. Per farlo, possiamo esaminare la struttura a cascata e analizzare il motivo per cui le risorse vengono caricate troppo tardi. Nella traccia 2G di PROXX possiamo notare due importanti segnali di allarme:

  1. Sono presenti più linee sottili multicolore.
  2. I file JavaScript formano una catena. Ad esempio, il caricamento della seconda risorsa inizia solo al termine della prima e la terza risorsa inizia solo al termine della seconda.
La visualizzazione a cascata fornisce informazioni sulle risorse in fase di caricamento, sui tempi di caricamento e su quando vengono caricate.

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 è onerosa 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 1: il nostro index.html
  • Richiesta 5: gli stili dei caratteri di fonts.googleapis.com
  • Richiesta 8: Google Analytics
  • Richiesta 9: un file del carattere da fonts.gstatic.com
  • Richiesta 14: il file 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 durante i tempi di inattività, quando tutto il resto è già stato caricato. In questo modo, non occuperà larghezza di banda o potenza di elaborazione durante il caricamento iniziale. La nuova connessione per il file manifest dell'app web è prescritta dalla specifica 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, rappresentano 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 così piccoli che abbiamo deciso di inserirli in linea nel codice HTML, rimuovendo una connessione non necessaria. Per evitare il costo della configurazione della connessione per i file dei caratteri, possiamo copiarli sul nostro server.

Caricamenti paralleli

Osservando la struttura a cascata, possiamo vedere che, una volta completato il caricamento del primo file JavaScript, iniziano immediatamente a caricarsi i nuovi file. Questo è tipico delle dipendenze dei moduli. Il nostro modulo principale probabilmente contiene importazioni statiche, quindi il codice JavaScript non può essere eseguito finché queste importazioni non vengono caricate. È importante capire che questo tipo di dipendenze è noto in fase di compilazione. Possiamo utilizzare i tag <link rel="preload"> per assicurarci che tutte le dipendenze inizino a caricarsi nel momento in cui riceviamo il codice HTML.

Risultati

Vediamo cosa abbiamo ottenuto con le nostre modifiche. È 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 immagini di WebPageTest per vedere i risultati delle nostre modifiche.

Grazie a queste modifiche, il TTI è passato da 11 a 8,5, ovvero circa 2,5 secondi del tempo di configurazione della connessione che volevamo rimuovere. Ottimo lavoro.

Prerendering

Anche se abbiamo appena ridotto il TTI, non abbiamo influito molto sulla schermata bianca eterna 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 ogni richiesta sul lato server, mentre il prerendering lo fa in fase di compilazione e memorizza 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 pre-renderizzatore. In PROXX abbiamo scelto di utilizzare Puppeteer, che avvia Chrome senza interfaccia utente e consente di controllare da remoto l'istanza con un'API Node. Lo utilizziamo per iniettare il nostro markup e il nostro codice JavaScript e poi leggere il DOM come stringa di 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. Se possibile, il nostro index.html è aumentato e potrebbe posticipare un po' il TTI. C'è un solo modo per scoprirlo: esegui WebPageTest.

La sequenza di immagini mostra un netto miglioramento per la nostra metrica FMP. Il TTI non è quasi interessato.

La nostra First Meaningful Paint è passata da 8,5 a 4,9 secondi, un enorme miglioramento. Il nostro TTI si verifica ancora a circa 8,5 secondi, quindi non è stato influenzato in modo significativo da questa modifica. Abbiamo apportato una modifica percettivo. Qualcuno potrebbe persino chiamarlo "sfida di controllo". Con il rendering di un'immagine intermedia del gioco, stiamo migliorando le prestazioni di caricamento percepite.

Inlining

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

La sezione luminosa 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 passano la maggioranza del tempo in attesa dell'arrivo del primo byte della risposta.

Questo problema è stato il motivo per cui HTTP/2 Push è stato concepito originariamente. Lo sviluppatore dell'app sa che alcune risorse sono necessarie e può respingere le risorse. Quando il client si rende conto di dover recuperare risorse aggiuntive, queste sono già nelle cache del browser. La funzionalità Push HTTP/2 si è rivelata troppo difficile da implementare correttamente e non è consigliata. Questo spazio di problemi verrà esaminato di nuovo durante la standardizzazione di HTTP/3. Per il momento, la soluzione più semplice è inserire in linea tutte le risorse critiche a scapito dell'efficienza della cache.

Il nostro CSS fondamentale è già incorporato grazie a CSS Modules e al nostro pre-renderizzatore basato su Puppeteer. Per JavaScript dobbiamo incorporare i moduli critici e le loro dipendenze. La difficoltà di questa attività varia in base al bundler utilizzato.

Grazie all'integrazione di JavaScript, abbiamo ridotto il TTI da 8,5 a 7,2 secondi.

Questo ha tagliato 1 secondo dal nostro TTI. Ora abbiamo raggiunto il punto in cui index.html contiene tutto ciò che è necessario per il rendering iniziale e per diventare interattivo. Il codice HTML può essere visualizzato durante il download, creando il nostro 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. Il nostro index.html è di 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. È tutto. 43 KB mi sembrano tanti.

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

Per capire da dove provengono le dimensioni del bundle, possiamo utilizzare un esploratore di mappe di origine o uno strumento simile per analizzare i componenti 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 a livello di lazy comporterà una riduzione significativa del TTI.

Dall'analisi dei contenuti di "index.html" di PROXX emergono 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 in modo lazy on demand. I bundler più utilizzati come Webpack, Rollup e Parcel supportano la suddivisione del codice utilizzando import() dinamico. Il bundler analizzerà il codice e inserirà in linea tutti i moduli importati staticamente. Tutto ciò che importi in modo dinamico viene inserito in un file separato e recuperato dalla rete solo dopo l'esecuzione della chiamata import(). Ovviamente, la pubblicazione sulla rete ha un costo e deve essere eseguita solo se hai tempo a disposizione. Il mantra è importare in modo statico i moduli fondamentali al momento del caricamento e caricare dinamicamente tutto il resto. Tuttavia, non dovresti aspettare l'ultimo momento per eseguire il caricamento lento dei moduli che verranno sicuramente utilizzati. Idle Until Urgent di Phil Walton è un ottimo pattern per trovare una via di mezzo tra il caricamento lazy e il caricamento eager.

In PROXX abbiamo creato un file lazy.js che importa in modo statico tutto ciò che non ci serve. Nel nostro file principale, possiamo quindi importare lazy.js in modo dinamico. Tuttavia, alcuni dei nostri componenti Preact sono finiti in lazy.js, il che si è rivelato un po' complicato perché Preact non è in grado di gestire i componenti caricati a livello di latenza out of the box. Per questo motivo abbiamo scritto un piccolo wrapper del componente deferred che ci consente di visualizzare 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();
    }
  };
}

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. Una volta caricato e pronto per l'uso, il componente <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 misure, abbiamo ridotto il nostro index.html a soli 20 KB, meno della metà delle dimensioni originali. Che impatto ha questo su FMP e TTI? WebPageTest ti dirà.

La sequenza di immagini conferma: il nostro TTI ora è di 5,4 secondi. Un miglioramento drastico rispetto alle nostre 11 originali.

Il nostro FMP e il nostro TTI sono separati da soli 100 ms, poiché si tratta solo di analizzare ed eseguire il codice JavaScript incorporato. Dopo soli 5,4 secondi su 2G, l'app è completamente interattiva. Tutti gli altri moduli meno essenziali vengono caricati in background.

Altro inganno visivo

Se dai un'occhiata all'elenco dei moduli critici riportato sopra, noterai che il motore di rendering non fa parte di questi moduli. Ovviamente, il gioco non può essere avviato finché non avremo il nostro motore di rendering. Potremmo disattivare il pulsante "Avvia" finché il nostro motore di rendering non sarà pronto per avviare il gioco, ma in base alla nostra esperienza in genere l'utente impiega abbastanza tempo per configurare le impostazioni del gioco da non rendere necessario tale operazione. 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 connessione di rete, viene mostrata una semplice schermata di caricamento che attende il completamento dei moduli rimanenti.

Conclusione

La misurazione è importante. Per evitare di perdere tempo su problemi non reali, ti consigliamo di eseguire sempre una misurazione prima di implementare le ottimizzazioni. Inoltre, le misurazioni devono essere eseguite su dispositivi reali con una connessione 3G o su WebPageTest se non è disponibile un 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.
  • Precarica o anche risorse in linea necessarie per il primo rendering e l'interattività.
  • Esegui il prerendering dell'app per migliorare il rendimento percepito del caricamento.
  • Utilizza una 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 su dispositivi con vincoli molto rigidi.