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. Una caratteristica che distingue PROXX è l'attenzione all'accessibilità (puoi giocarci con uno screen reader) e la possibilità 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 piccoli senza input tocco
- Quantità di memoria molto limitate
Tuttavia, utilizzano un browser moderno e sono molto convenienti. Per questo motivo, i cellulari stanno tornando di moda 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 solo in India verranno venduti circa 400 milioni di feature phone, pertanto gli utenti di questi dispositivi potrebbero diventare una parte significativa del tuo pubblico. Inoltre, le velocità di connessione simili al 2G sono la norma nei mercati emergenti. Come siamo riusciti a far funzionare bene PROXX in condizioni di telefoni con funzionalità di base?
Le prestazioni sono importanti, sia quelle in fase di caricamento che quelle di runtime. È stato dimostrato che un buon rendimento è correlato a una maggiore fidelizzazione degli utenti, a conversioni migliorate e, soprattutto, a una maggiore 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.
Acquisire 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 riscontrerai sarà più simile al 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:
Se caricato tramite 3G, l'utente vede 4 secondi di schermo bianco vuoto. Oltre 2G, l'utente non vede assolutamente nulla per più di 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 tutto ciò che viene visualizzato sullo schermo è anche interattivo. O no?
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 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 nostra traccia 2G per PROXX possiamo vedere due importanti indicatori di problemi:
- 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 la terza risorsa inizia solo al termine della seconda.
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 nostra struttura a cascata vediamo una nuova connessione 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. Il nuovo collegamento per Google Analytics potrebbe essere evitato 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 in caso 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à della nostra app, quindi non dobbiamo preoccuparci più di tanto.
I due caratteri e i relativi stili, tuttavia, rappresentano un problema in quanto bloccano il rendering e anche l'interattività. Se esaminiamo il CSS fornito 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 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 falsare i risultati, pertanto utilizzeremo la configurazione semplice di WebPageTest per il resto di questo articolo e esamineremo la sequenza di immagini:
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 per le FMP possono essere ottenuti inviando markup con stile in index.html
. Le tecniche comuni per ottenere questo risultato 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 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 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, possiamo incorporare i CSS degli 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);
Con questa soluzione, possiamo aspettarci un miglioramento per il nostro programma FMP. Dobbiamo comunque caricare ed eseguire la stessa quantità di codice JavaScript di prima, quindi non ci aspettiamo che il TTI cambi molto. Se c'è un problema, il nostro index.html
è aumentato e potrebbe posticipare un po' il TTI. C'è un solo modo per scoprirlo: esegui WebPageTest.
Il nostro First Meaningful Paint è passato da 8,5 secondi a 4,9 secondi, un miglioramento enorme. 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. Alcuni potrebbero persino chiamarlo inganno. 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 Time To First Byte (TTFB). Si tratta del tempo che intercorre tra l'invio del primo byte della richiesta e la ricezione del primo byte della risposta. 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 di richiesta/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 di app sa che sono necessarie determinate risorse e può inviarle. 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 relative dipendenze. La difficoltà di questa attività varia in base al bundler utilizzato.
In questo modo abbiamo risparmiato 1 secondo sul 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. Non appena l'HTML è stato analizzato ed eseguito, l'app è interattiva.
Suddivisione aggressiva del codice
Sì, il nostro index.html
contiene tutto ciò che serve per diventare interattivo. Ma a un'analisi più attenta, risulta 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.
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 vittoria, la schermata di perdita e una serie di utilità. Per la pagina di destinazione è necessario solo un piccolo sottoinsieme di questi moduli. Spostare tutto ciò che non è strettamente necessario per l'interattività in un modulo caricato a livello di lazy comporterà una riduzione significativa del TTI.
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 dinamicamente verrà inserito in un proprio file e verrà 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 componente wrapper 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 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 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à.
Il nostro FMP e il nostro TTI sono separati da soli 100 ms, poiché si tratta solo di analizzare ed eseguire il codice JavaScript in linea. 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 "Inizia" finché il nostro motore di rendering non è pronto per avviare il gioco, ma in base alla nostra esperienza l'utente di solito impiega abbastanza tempo per configurare le impostazioni del gioco da rendere questa operazione non necessaria. Il più delle volte, il motore di rendering e gli altri moduli rimanenti sono stati caricati quando l'utente preme "Inizia". 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 di immagini può fornire informazioni su come l'utente percepisce il caricamento dell'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:
- Carica il maggior numero possibile di asset tramite una 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.