Come abbiamo utilizzato la suddivisione del codice, l'incorporamento del codice e il rendering lato server in PROXX.
In occasione di Google I/O 2019, Mariko, Jake e io abbiamo lanciato PROXX, un clone moderno di Prato fiorito per il web. Ciò che distingue PROXX è l'attenzione all'accessibilità (puoi giocarci con uno screen reader) e la capacità di funzionare sia su un feature phone sia su un computer di fascia alta. I feature phone sono limitati in diversi modi:
- CPU deboli
- GPU deboli o inesistenti
- Schermi di piccole dimensioni senza input tocco
- Quantità di memoria molto limitata
Tuttavia, eseguono un browser moderno e sono molto convenienti. Per questo motivo, i feature phone stanno tornando in auge nei mercati emergenti. Il loro prezzo consigliato consente a un pubblico completamente nuovo, che in precedenza non poteva permetterselo, di connettersi a internet e utilizzare il web moderno. Per il 2019 si prevede che verranno venduti circa 400 milioni di feature phone solo in India, quindi gli utenti di feature phone potrebbero diventare una parte significativa del tuo pubblico. Inoltre, nei mercati emergenti le velocità di connessione simili al 2G sono la norma. Come abbiamo fatto a far funzionare bene PROXX nelle condizioni dei feature phone?
Le prestazioni sono importanti e includono sia le prestazioni di caricamento sia quelle di runtime. È stato 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 ha molti più dati e approfondimenti sul perché le prestazioni sono importanti.
Questa è la prima parte di una serie di due. La parte 1 si concentra sulle prestazioni di caricamento, mentre la parte 2 si concentrerà sulle prestazioni di runtime.
Acquisizione dello status quo
Testare le prestazioni di caricamento su un dispositivo reale è fondamentale. 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 caricamento su un dispositivo reale con una connessione 3G emulata.
Il 3G è una buona velocità da misurare. Anche se potresti avere familiarità con 4G, LTE o presto anche 5G, la realtà di internet mobile è molto diversa. Forse sei in treno, a una conferenza, a un concerto o in aereo. La velocità di connessione che sperimenterai sarà probabilmente più vicina al 3G e, a volte, anche peggiore.
Detto questo, in questo articolo ci concentreremo sul 2G perché PROXX si rivolge esplicitamente ai feature phone e ai mercati emergenti nel suo pubblico di destinazione. Una volta che WebPageTest ha eseguito il test, ottieni un grafico a cascata (simile a quello che vedi in DevTools) e una sequenza in alto. La sequenza di immagini mostra ciò che l'utente vede durante il caricamento dell'app. In 2G, l'esperienza di caricamento della versione non ottimizzata di PROXX è piuttosto negativa:
Se caricata su 3G, l'utente vede 4 secondi di vuoto bianco. Con una connessione 2G, l'utente non vede assolutamente nulla per oltre 8 secondi. Se hai letto 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é sullo schermo venga visualizzato qualcosa. Il lato positivo di questo scenario è che, non appena un elemento appare sullo schermo, è anche interattivo. O no?
Dopo aver scaricato circa 62 kB di JS compressi con gzip e aver generato il DOM, l'utente può visualizzare la nostra app. L'app è tecnicamente interattiva. Tuttavia, l'immagine mostra una realtà diversa. I caratteri web vengono ancora caricati in background e, finché non sono pronti, l'utente non può visualizzare il testo. Sebbene questo stato sia considerato un First Meaningful Paint (FMP), non è sicuramente considerato interattivo, in quanto l'utente non può sapere a cosa si riferiscono gli input. È necessario un altro secondo su 3G e 3 secondi su 2G prima che l'app sia pronta per l'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 questo possiamo esaminare il grafico a cascata e analizzare il motivo per cui le risorse vengono caricate troppo tardi. Nella nostra traccia 2G per PROXX possiamo vedere due principali segnali di allarme:
- Sono presenti più linee sottili multicolore.
- I file JavaScript formano una catena. Ad esempio, il caricamento della seconda risorsa inizia solo al termine della prima e quello della terza risorsa inizia solo al termine della seconda.
Riduzione del numero di connessioni
Ogni linea sottile (dns, connect, ssl) rappresenta 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 nostra struttura a cascata vediamo una nuova connessione per:
- Richiesta n. 1: il nostro
index.html - Richiesta n. 5: gli stili dei caratteri di
fonts.googleapis.com - Richiesta n. 8: Google Analytics
- Richiesta n. 9: un file di caratteri di
fonts.gstatic.com - Richiesta n. 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 per Google Analytics potrebbe essere evitata inserendo in linea qualcosa come Minimal Analytics, ma Google Analytics non impedisce il rendering o l'interattività della nostra app, quindi non ci interessa molto la velocità di caricamento. Idealmente, Google Analytics dovrebbe essere caricato durante il tempo 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 manifest dell'app web è prescritta dalla specifica di recupero, poiché il manifest deve essere caricato tramite una connessione senza credenziali. Anche in questo caso, il file manifest dell'app web non impedisce il rendering o l'interattività dell'app, quindi non dobbiamo preoccuparci troppo.
Tuttavia, i due caratteri e i relativi stili rappresentano un problema, in quanto bloccano il rendering e l'interattività. Se esaminiamo il CSS fornito da fonts.googleapis.com, vediamo che contiene solo due regole @font-face, una per ogni carattere. Gli stili dei caratteri sono così piccoli che abbiamo deciso di inserirli nel nostro 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.
Parallelizzazione dei caricamenti
Osservando il grafico a cascata, possiamo vedere che, una volta caricato il primo file JavaScript, i nuovi file iniziano a caricarsi immediatamente. Questo è tipico delle dipendenze dei moduli. Il nostro modulo principale probabilmente contiene importazioni statiche, quindi JavaScript non può essere eseguito finché queste importazioni non vengono caricate. L'aspetto importante da considerare è che questi tipi di dipendenze sono noti al momento della build. Possiamo utilizzare 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 delle nostre modifiche. È importante non modificare altre variabili nella nostra configurazione di test che potrebbero alterare i risultati, quindi utilizzeremo la configurazione semplice di WebPageTest per il resto di questo articolo e osserveremo la sequenza:
Queste modifiche hanno ridotto il nostro TTI da 11 a 8,5, ovvero circa i 2,5 secondi di tempo di configurazione della connessione che volevamo rimuovere. Ottimo lavoro.
Prerendering
Anche se abbiamo appena ridotto il TTI, non abbiamo influito sulla schermata bianca interminabile che l'utente deve sopportare per 8,5 secondi. Probabilmente i maggiori miglioramenti per FMP possono essere ottenuti inviando markup con stile nel tuo index.html. Le tecniche più comuni per raggiungere questo obiettivo sono il prerendering e il rendering lato server, che sono strettamente correlati e sono spiegati 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, appunto, sul lato server, mentre il prerendering la esegue in fase di creazione 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 prerenderer. In PROXX abbiamo scelto di utilizzare Puppeteer, che avvia Chrome senza alcuna UI e consente di controllare da remoto l'istanza con un'API Node. Lo utilizziamo per inserire il nostro markup e il nostro JavaScript e poi leggere il DOM come stringa di HTML. Poiché utilizziamo i moduli CSS, l'incorporamento degli stili CSS 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);
In questo modo, possiamo aspettarci un miglioramento del nostro FMP. Dobbiamo comunque caricare ed eseguire la stessa quantità di JavaScript di prima, quindi non dobbiamo aspettarci che il TTI cambi molto. Anzi, il nostro index.html è aumentato e potrebbe ritardare un po' il TTI. C'è solo un modo per scoprirlo: eseguire WebPageTest.
Il nostro First Meaningful Paint è passato da 8,5 secondi a 4,9 secondi, un miglioramento enorme. Il nostro TTI si verifica ancora intorno agli 8,5 secondi, quindi non è stato influenzato in modo significativo da questa modifica. In questo caso, abbiamo apportato una modifica percettiva. Qualcuno potrebbe addirittura definirlo un gioco di prestigio. Eseguendo il rendering di un'immagine intermedia del gioco, miglioriamo le prestazioni di caricamento percepite.
Inlining
Un'altra metrica fornita sia da DevTools che da WebPageTest è il Time To First Byte (TTFB). Questo è il tempo che intercorre tra l'invio del primo byte della richiesta e la ricezione del primo byte della risposta. Questo tempo viene spesso chiamato anche tempo di round trip (RTT), anche se tecnicamente esiste una differenza tra questi due numeri: l'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 di richiesta/risposta.
Osservando il nostro grafico a cascata, possiamo vedere che tutte le richieste trascorrono la maggior parte del tempo in attesa dell'arrivo del primo byte della risposta.
Questo problema è il motivo per cui è stato originariamente concepito HTTP/2 Push. Lo sviluppatore di app sa che sono necessarie determinate risorse e può eseguirne il push. Quando il client si rende conto di dover recuperare risorse aggiuntive, queste sono già presenti nelle cache del browser. HTTP/2 Push turned out to be too hard to get right and is considered discouraged. Questo problema verrà rivisto durante la standardizzazione di HTTP/3. Per il momento, la soluzione più semplice è incorporare tutte le risorse critiche a scapito dell'efficienza della memorizzazione nella cache.
Il nostro CSS fondamentale è già incorporato grazie ai moduli CSS e al nostro prerenderer basato su Puppeteer. Per JavaScript, dobbiamo incorporare i nostri moduli critici e le relative dipendenze. La difficoltà di questa attività varia in base al bundler che utilizzi.
In questo modo, abbiamo ridotto il TTI di 1 secondo. Siamo arrivati al punto in cui il nostro index.html contiene tutto il necessario per il rendering iniziale e per diventare interattivo. Il rendering dell'HTML può essere eseguito mentre è ancora in fase di download, creando il nostro FMP. Nel momento in cui l'HTML viene analizzato ed eseguito, l'app è interattiva.
Suddivisione aggressiva del codice
Sì, il nostro index.html contiene tutto il necessario per diventare interattivo. Ma a un esame più attento si scopre che contiene anche tutto il resto. Il nostro index.html è di circa 43 kB. Mettiamo questo 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 un po' di codice per rendere persistenti e caricare le impostazioni utente. Questo è tutto. 43 KB sembrano molti.
Per capire da dove proviene la dimensione del bundle, possiamo utilizzare uno strumento di esplorazione delle mappe di origine o uno strumento simile per analizzare la composizione del bundle. Come previsto, il nostro bundle contiene la logica del gioco, il motore di rendering, la schermata di vittoria, la schermata di sconfitta e una serie di utilità. Per la pagina di destinazione è necessario solo un piccolo sottoinsieme di questi moduli. Spostando tutto ciò che non è strettamente necessario per l'interattività in un modulo con caricamento lento, il TTI diminuirà in modo significativo.
Quello che dobbiamo fare è suddividere il codice. La suddivisione del codice suddivide il bundle monolitico in parti più piccole che possono essere caricate in modo lento on demand. I bundler più diffusi come Webpack, Rollup e Parcel supportano la suddivisione del codice utilizzando import() dinamici. Il bundler analizzerà il codice e incorporerà tutti i moduli importati staticamente. Tutto ciò che importi dinamicamente verrà inserito nel proprio file e verrà recuperato dalla rete solo dopo l'esecuzione della chiamata import(). Ovviamente, l'accesso alla rete ha un costo e deve essere eseguito solo se hai tempo a disposizione. Il mantra qui è importare staticamente i moduli che sono fondamentali al momento del caricamento e caricare dinamicamente tutto il resto. Tuttavia, non dovresti aspettare l'ultimo momento per caricare in modalità lazy i moduli che verranno sicuramente utilizzati. Idle Until Urgent di Phil Walton è un ottimo pattern per una via di mezzo tra il caricamento lento e il caricamento eager.
In PROXX abbiamo creato un file lazy.js che importa staticamente tutto ciò di cui non abbiamo bisogno. Nel nostro file principale, possiamo quindi importare dinamicamente lazy.js. Tuttavia, alcuni dei nostri componenti Preact sono finiti in lazy.js, il che si è rivelato un po' complicato, in quanto Preact non è in grado di gestire i componenti con caricamento lento in modo predefinito. 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();
}
};
}
In questo modo, possiamo utilizzare una Promise 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 <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 tutto questo, abbiamo ridotto il nostro index.html a soli 20 kB, meno della metà delle dimensioni originali. Che effetto ha questo su FMP e TTI? WebPageTest te lo dirà.
La differenza tra FMP e TTI è di 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.
Più giochi di prestigio
Se guardi l'elenco dei moduli critici riportato sopra, vedrai che il motore di rendering non ne fa parte. Naturalmente, il gioco non può iniziare finché non abbiamo il nostro motore di rendering per eseguire il rendering del gioco. Potremmo disattivare il pulsante "Inizia" finché il nostro motore di rendering non è pronto per avviare il gioco, ma secondo la nostra esperienza l'utente di solito impiega abbastanza tempo per configurare le impostazioni di gioco, quindi non è necessario. Nella maggior parte dei casi, il caricamento del motore di rendering e degli altri moduli rimanenti viene completato quando l'utente preme "Inizia". Nel raro caso in cui l'utente sia più veloce della sua connessione di rete, mostriamo una semplice schermata di caricamento che attende il completamento dei moduli rimanenti.
Conclusione
La misurazione è importante. Per evitare di perdere tempo con problemi che non sono reali, ti consigliamo di misurare sempre 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 su come l'utente percepisce il caricamento della tua app. La struttura a cascata può indicarti 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 connessione.
- Precarica o addirittura inserisci in linea le risorse necessarie per il primo rendering e l'interattività.
- Esegui il prerendering dell'app per migliorare le prestazioni di caricamento percepite.
- Utilizza la 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 su dispositivi con vincoli estremi.