Come abbiamo migliorato le prestazioni di SmashingMag

Pubblicato: 2022-03-10
Riepilogo rapido ↬ In questo articolo, daremo un'occhiata da vicino ad alcune delle modifiche che abbiamo apportato proprio a questo sito — in esecuzione su JAMStack con React — per ottimizzare le prestazioni web e migliorare le metriche di Core Web Vitals. Con alcuni degli errori che abbiamo commesso e alcune delle modifiche inaspettate che hanno contribuito a migliorare tutte le metriche su tutta la linea.

Ogni storia di performance web è simile, vero? Si inizia sempre con la tanto attesa revisione del sito web. Un giorno in cui un progetto, completamente rifinito e accuratamente ottimizzato, viene lanciato, classificandosi in alto e superando i punteggi delle prestazioni in Lighthouse e WebPageTest. C'è una celebrazione e un sincero senso di realizzazione che prevale nell'aria, magnificamente riflesso nei retweet, nei commenti, nelle newsletter e nei thread di Slack.

Tuttavia, col passare del tempo, l'eccitazione svanisce lentamente e si insinuano modifiche urgenti, funzionalità tanto necessarie e nuovi requisiti aziendali. E all'improvviso, prima che te ne accorga, la base di codice diventa un po' sovrappeso e frammentata , di terze parti gli script devono essere caricati un po' prima e nuovi contenuti dinamici brillanti si fanno strada nel DOM attraverso le backdoor degli script di quarte parti e dei loro ospiti indesiderati.

Ci siamo stati anche a Smashing. Non molte persone lo sanno, ma siamo una squadra molto piccola di circa 12 persone, molte delle quali lavorano part-time e la maggior parte delle quali di solito indossa molti cappelli diversi in un dato giorno. Sebbene le prestazioni siano il nostro obiettivo da quasi un decennio ormai, non abbiamo mai avuto un team dedicato alle prestazioni.

Dopo l'ultima riprogettazione alla fine del 2017, c'era Ilya Pukhalski sul lato JavaScript (part-time), Michael Riethmueller sul lato CSS (poche ore a settimana) e davvero il tuo, a giocare a giochi mentali con CSS critici e cercando di destreggiarsi tra poche cose di troppo.

Schermata delle sorgenti delle prestazioni che mostra i punteggi di Lighthouse compresi tra 40 e 60
È qui che abbiamo iniziato. Con i punteggi di Lighthouse compresi tra 40 e 60, abbiamo deciso di affrontare le prestazioni (ancora una volta) a testa alta. (Fonte immagine: Lighthouse Metrics) (Anteprima grande)

Come è successo, abbiamo perso le tracce delle prestazioni nella frenesia della routine quotidiana. Progettavamo e costruivamo cose, impostavamo nuovi prodotti, refactoring dei componenti e pubblicavamo articoli. Quindi, verso la fine del 2020, le cose sono andate un po' fuori controllo, con i punteggi di Lighthouse rosso-giallastri che si sono presentati lentamente su tutta la linea. Abbiamo dovuto aggiustarlo.

Ecco dove eravamo

Alcuni di voi potrebbero sapere che stiamo girando su JAMStack, con tutti gli articoli e le pagine archiviati come file Markdown, file Sass compilati in CSS, JavaScript diviso in blocchi con Webpack e Hugo che crea pagine statiche che poi serviamo direttamente da un CDN Edge. Nel 2017 abbiamo costruito l'intero sito con Preact, ma poi siamo passati a React nel 2019 e lo abbiamo utilizzato insieme ad alcune API per la ricerca, i commenti, l'autenticazione e il checkout.

L'intero sito è stato creato pensando a un miglioramento progressivo, il che significa che tu, caro lettore, puoi leggere ogni articolo di Smashing nella sua interezza senza la necessità di avviare l'applicazione. Non è nemmeno molto sorprendente: alla fine, un articolo pubblicato non cambia molto nel corso degli anni, mentre pezzi dinamici come l'autenticazione dell'appartenenza e il checkout richiedono l'esecuzione dell'applicazione.

L'intera build per la distribuzione di circa 2500 articoli in tempo reale richiede circa 6 minuti al momento. Anche il processo di compilazione da solo è diventato una vera bestia nel tempo, con iniezioni CSS critiche, suddivisione del codice di Webpack, inserti dinamici di pubblicità e pannelli di funzionalità, (ri)generazione di RSS ed eventuali test A/B al limite.

All'inizio del 2020, abbiamo iniziato con il grande refactoring dei componenti del layout CSS. Non abbiamo mai usato CSS-in-JS o componenti di stile, ma invece un buon vecchio sistema di moduli Sass basato su componenti che sarebbe stato compilato in CSS. Nel 2017, l'intero layout è stato creato con Flexbox e ricostruito con CSS Grid e CSS Custom Properties a metà del 2019. Tuttavia, alcune pagine necessitavano di un trattamento speciale a causa di nuovi spot pubblicitari e nuovi pannelli di prodotti. Quindi, mentre il layout funzionava, non funzionava molto bene ed era piuttosto difficile da mantenere.

Inoltre, l'intestazione con la navigazione principale doveva cambiare per accogliere più elementi che volevamo visualizzare dinamicamente. Inoltre, volevamo rifattorizzare alcuni componenti utilizzati di frequente utilizzati nel sito e anche il CSS utilizzato lì aveva bisogno di una revisione: la casella della newsletter era il colpevole più notevole. Abbiamo iniziato con il refactoring di alcuni componenti con CSS utility-first, ma non siamo mai arrivati ​​al punto che è stato utilizzato in modo coerente nell'intero sito.

Il problema più grande era il grande bundle JavaScript che, non molto sorprendentemente, stava bloccando il thread principale per centinaia di millisecondi. Un grosso pacchetto JavaScript potrebbe sembrare fuori luogo su una rivista che pubblica semplicemente articoli, ma in realtà dietro le quinte c'è un sacco di scripting.

Abbiamo vari stati dei componenti per clienti autenticati e non autenticati. Una volta effettuato l'accesso, vogliamo mostrare tutti i prodotti nel prezzo finale e, quando aggiungi un libro al carrello, vogliamo mantenere un carrello accessibile toccando un pulsante, indipendentemente dalla pagina in cui ti trovi. La pubblicità deve arrivare rapidamente senza causare cambiamenti di layout dirompenti e lo stesso vale per i pannelli di prodotti nativi che mettono in evidenza i nostri prodotti. Inoltre, un addetto ai servizi che memorizza nella cache tutte le risorse statiche e le serve per le visualizzazioni ripetute, insieme alle versioni memorizzate nella cache degli articoli che un lettore ha già visitato.

Quindi tutto questo scripting doveva accadere ad un certo punto, e stava prosciugando l'esperienza di lettura anche se il copione arrivava piuttosto tardi. Francamente, stavamo lavorando scrupolosamente al sito e ai nuovi componenti senza tenere d'occhio le prestazioni (e avevamo alcune altre cose da tenere a mente per il 2020). La svolta è arrivata inaspettatamente. Harry Roberts ha tenuto la sua (eccellente) Web Performance Masterclass come seminario online con noi e durante l'intero seminario ha utilizzato Smashing come esempio evidenziando i problemi che avevamo e suggerendo soluzioni a questi problemi insieme a strumenti e linee guida utili.

Durante il workshop, ho preso diligentemente appunti e ho rivisitato la base di codice. Al momento del seminario, i nostri punteggi di Lighthouse erano 60–68 sulla home page e circa 40-60 sulle pagine degli articoli , e ovviamente peggiori sui dispositivi mobili. Finito il workshop, ci siamo messi al lavoro.

Identificazione dei colli di bottiglia

Spesso tendiamo a fare affidamento su punteggi particolari per capire quanto bene ci esibiamo, ma troppo spesso i singoli punteggi non forniscono un quadro completo. Come ha eloquentemente notato David East nel suo articolo, le prestazioni sul web non sono un singolo valore; è una distribuzione. Anche se un'esperienza web è pesantemente e completamente una prestazione ottimizzata a tutto tondo, non può essere solo veloce. Potrebbe essere veloce per alcuni visitatori, ma alla fine sarà anche più lento (o lento) per altri.

Le ragioni sono numerose, ma la più importante è un'enorme differenza nelle condizioni di rete e nell'hardware dei dispositivi in ​​tutto il mondo. Il più delle volte non possiamo davvero influenzare queste cose, quindi dobbiamo assicurarci che la nostra esperienza le soddisfi invece.

In sostanza, il nostro compito è quindi aumentare la proporzione di esperienze scattanti e diminuire la proporzione di esperienze lente. Ma per questo, dobbiamo avere un quadro corretto di quale sia effettivamente la distribuzione. Ora, gli strumenti di analisi e gli strumenti di monitoraggio delle prestazioni forniranno questi dati quando necessario, ma abbiamo esaminato in modo specifico CruUX, il rapporto sull'esperienza utente di Chrome. CrUX genera una panoramica delle distribuzioni delle prestazioni nel tempo, con il traffico raccolto dagli utenti di Chrome. Gran parte di questi dati si riferiscono a Core Web Vitals che Google ha annunciato nel 2020 e che contribuiscono e sono anche esposti in Lighthouse.

Le più grandi statistiche di Contentful Paint (LCP) che mostrano un massiccio calo delle prestazioni tra maggio e settembre nel 2020
La distribuzione delle prestazioni per Largest Contentful Paint nel 2020. Tra maggio e settembre le prestazioni sono diminuite notevolmente. Dati da CruUX. (Grande anteprima)

Abbiamo notato che su tutta la linea la nostra performance è regredita drammaticamente durante tutto l'anno, con cali particolari intorno ad agosto e settembre. Una volta che abbiamo visto questi grafici, potremmo guardare indietro ad alcuni dei PR che abbiamo spinto in diretta allora per studiare cosa è effettivamente successo.

Non ci è voluto molto per capire che proprio in questo periodo abbiamo lanciato una nuova barra di navigazione dal vivo. Quella barra di navigazione, utilizzata su tutte le pagine, si basava su JavaScript per visualizzare gli elementi di navigazione in un menu al tocco o al clic, ma il bit JavaScript era effettivamente raggruppato all'interno del bundle app.js. Per migliorare Time To Interactive, abbiamo deciso di estrarre lo script di navigazione dal pacchetto e servirlo in linea.

Più o meno nello stesso periodo siamo passati da un file CSS critico (obsoleto) creato manualmente a un sistema automatizzato che generava CSS critici per ogni modello (homepage, articolo, pagina del prodotto, evento, bacheca di lavoro e così via) e CSS critico in linea durante il tempo di costruzione. Eppure non ci siamo davvero resi conto di quanto fosse più pesante il CSS critico generato automaticamente. Abbiamo dovuto esplorarlo in modo più dettagliato.

E più o meno nello stesso periodo, stavamo regolando il caricamento dei caratteri Web , cercando di spingere i caratteri Web in modo più aggressivo con suggerimenti di risorse come il precaricamento. Tuttavia, questo sembra ostacolare i nostri sforzi in termini di prestazioni, poiché i caratteri Web ritardavano il rendering del contenuto, avendo una priorità eccessiva accanto al file CSS completo.

Ora, uno dei motivi più comuni per la regressione è il costo elevato di JavaScript, quindi abbiamo anche esaminato Webpack Bundle Analyzer e la mappa delle richieste di Simon Hearne per ottenere un'immagine visiva delle nostre dipendenze JavaScript. All'inizio sembrava abbastanza sano.

Una mappa mentale visiva delle dipendenze JavaScript
Niente di veramente innovativo: la mappa delle richieste all'inizio non sembrava essere eccessiva. (Grande anteprima)

Sono arrivate alcune richieste al CDN, un servizio di consenso ai cookie Cookiebot, Google Analytics, oltre ai nostri servizi interni per la fornitura di pannelli di prodotti e pubblicità personalizzata. Non sembrava che ci fossero molti colli di bottiglia, finché non abbiamo guardato un po' più da vicino.

Nel lavoro sulle prestazioni, è comune guardare le prestazioni di alcune pagine critiche, molto probabilmente la home page e molto probabilmente alcune pagine di articoli/prodotti. Tuttavia, sebbene ci sia una sola home page, potrebbero esserci molte pagine di prodotti differenti, quindi dobbiamo scegliere quelle che siano rappresentative del nostro pubblico.

In effetti, poiché stiamo pubblicando un bel po' di articoli ricchi di codice e di design su SmashingMag, nel corso degli anni abbiamo accumulato letteralmente migliaia di articoli che contenevano GIF pesanti, frammenti di codice evidenziati dalla sintassi, incorporamenti di CodePen, video/audio incorpora e nidifica thread di commenti infiniti.

Quando riuniti, molti di loro stavano causando a dir poco un'esplosione delle dimensioni del DOM insieme a un eccessivo lavoro sul thread principale , rallentando l'esperienza su migliaia di pagine. Per non parlare del fatto che con la pubblicità in atto, alcuni elementi DOM sono stati iniettati alla fine del ciclo di vita della pagina causando una cascata di ricalcoli di stile e ridisegni, anche attività costose che possono produrre attività lunghe.

Tutto questo non veniva mostrato nella mappa che abbiamo generato per una pagina di articolo abbastanza leggera nel grafico sopra. Quindi abbiamo scelto le pagine più pesanti che avevamo - l'onnipotente homepage, la più lunga, quella con molti incorporamenti di video e quella con molti incorporamenti di CodePen - e abbiamo deciso di ottimizzarle il più possibile. Dopotutto, se sono veloci, anche le pagine con un singolo incorporamento di CodePen dovrebbero essere più veloci.

Con queste pagine in mente, la mappa aveva un aspetto leggermente diverso. Nota l'enorme linea spessa che si dirige verso il lettore Vimeo e il CDN Vimeo, con 78 richieste provenienti da un articolo Smashing.

Una mappa mentale visiva che mostra problemi di prestazioni soprattutto negli articoli che utilizzavano molti video e/o incorporamenti di video
In alcune pagine di articoli, il grafico aveva un aspetto diverso. Soprattutto con un sacco di codice o incorporamenti di video, le prestazioni sono diminuite in modo piuttosto significativo. Sfortunatamente, molti dei nostri articoli li hanno. (Grande anteprima)

Per studiare l'impatto sul thread principale, abbiamo approfondito il pannello Prestazioni in DevTools. Più specificamente, stavamo cercando attività che durassero più di 50 ms (evidenziate con un rettangolo rosso nell'angolo in alto a destra) e attività che contenessero stili di ricalcolo (barra viola). Il primo indicherebbe una costosa esecuzione di JavaScript, mentre il secondo esporrebbe invalidazioni di stile causate da iniezioni dinamiche di contenuto nel DOM e CSS non ottimali. Questo ci ha fornito alcuni suggerimenti utili su dove iniziare. Ad esempio, abbiamo subito scoperto che il caricamento del nostro font web aveva un costo di ridisegno significativo, mentre i blocchi JavaScript erano ancora abbastanza pesanti da bloccare il thread principale.

Uno screenshot del pannello delle prestazioni in DevTools che mostra i blocchi JavaScript che erano ancora abbastanza pesanti da bloccare il thread principale
Studio del pannello Prestazioni in DevTools. C'erano alcune attività lunghe, che richiedevano più di 50 ms e bloccavano il thread principale. (Grande anteprima)

Come linea di base, abbiamo esaminato molto da vicino i Core Web Vitals, cercando di assicurarci di ottenere un buon punteggio in tutti loro. Abbiamo scelto di concentrarci specificamente sui dispositivi mobili lenti, con 3G lento, RTT di 400 ms e velocità di trasferimento di 400 kbps, solo per essere pessimisti. Non sorprende quindi che anche Lighthouse non sia stato molto soddisfatto del nostro sito, fornendo punteggi rossi completamente solidi per gli articoli più pesanti e lamentandosi instancabilmente di JavaScript, CSS, immagini fuori schermo non utilizzati e delle loro dimensioni.

Uno screenshot dei dati di Lighthouse che mostra le opportunità e i risparmi stimati
Anche Lighthouse non è stato particolarmente contento della performance di alcune pagine. Questo è quello con molti incorporamenti di video. (Grande anteprima)

Una volta che abbiamo avuto alcuni dati di fronte a noi, potremmo concentrarci sull'ottimizzazione delle tre pagine di articoli più pesanti, con particolare attenzione a CSS critici (e non critici), bundle JavaScript, attività lunghe, caricamento di font Web, cambiamenti di layout e terze parti -incorpora. Successivamente rivedremo anche la base di codice per rimuovere il codice legacy e utilizzare le nuove funzionalità del browser moderno. Sembrava che ci fosse molto lavoro da fare, e in effetti eravamo piuttosto impegnati per i mesi a venire.

Migliorare l'ordine dei beni nel <head>

Ironia della sorte, la prima cosa che abbiamo esaminato non era nemmeno strettamente correlata a tutti i compiti che abbiamo identificato sopra. Durante il workshop sulle prestazioni, Harry ha dedicato molto tempo a spiegare l'ordine delle risorse nella <head> di ogni pagina, sottolineando che fornire rapidamente contenuti critici significa essere molto strategici e attenti a come vengono ordinate le risorse nel codice sorgente .

Ora non dovrebbe essere una grande rivelazione che i CSS critici siano utili per le prestazioni web. Tuttavia, è stata un po' una sorpresa quanta differenza abbia l'ordine di tutte le altre risorse - suggerimenti sulle risorse, precaricamento dei caratteri Web, script sincroni e asincroni, CSS e metadati completi.

Abbiamo capovolto l'intero <head> , posizionando i CSS critici prima di tutti gli script asincroni e di tutte le risorse precaricate come caratteri, immagini ecc. Abbiamo suddiviso le risorse a cui ci connetteremo o precaricheremo per modello e tipo di file, in modo che le immagini critiche, l'evidenziazione della sintassi e gli incorporamenti di video vengano richiesti in anticipo solo per un determinato tipo di articoli e pagine.

In generale, abbiamo orchestrato accuratamente l'ordine in <head> , ridotto il numero di risorse precaricate che erano in competizione per la larghezza di banda e ci siamo concentrati sull'ottenimento di CSS critici in modo corretto. Se desideri approfondire alcune delle considerazioni critiche con l'ordine <head> , Harry le evidenzia nell'articolo su CSS e prestazioni di rete. Questo cambiamento da solo ci ha portato a circa 3–4 punti Lighthouse su tutta la linea.

Passaggio dal CSS critico automatizzato al CSS critico manuale

Tuttavia, spostare i tag <head> è stata una parte semplice della storia. Una più difficile è stata la generazione e la gestione di file CSS critici. Nel 2017, abbiamo creato manualmente CSS critici per ogni modello, raccogliendo tutti gli stili necessari per il rendering dei primi 1000 pixel di altezza su tutte le larghezze dello schermo. Questo ovviamente è stato un compito ingombrante e leggermente poco stimolante, per non parlare dei problemi di manutenzione per domare un'intera famiglia di file CSS critici e un file CSS completo.

Quindi abbiamo esaminato le opzioni per automatizzare questo processo come parte della routine di compilazione. Non c'era davvero una carenza di strumenti disponibili, quindi ne abbiamo testati alcuni e abbiamo deciso di eseguire alcuni test. Siamo riusciti a configurarli e funzionare abbastanza rapidamente. L'output sembrava essere abbastanza buono per un processo automatizzato, quindi dopo alcune modifiche alla configurazione, lo abbiamo collegato e lo abbiamo portato in produzione. Ciò è accaduto intorno a luglio-agosto dell'anno scorso, il che è ben visualizzato nel picco e nel calo delle prestazioni nei dati CrUX sopra. Abbiamo continuato ad andare avanti e indietro con la configurazione, avendo spesso problemi con cose semplici come l'aggiunta di stili particolari o la rimozione di altri. Ad esempio, stili di richiesta di consenso ai cookie che non sono realmente inclusi in una pagina a meno che lo script del cookie non sia stato inizializzato.

In ottobre abbiamo introdotto alcune importanti modifiche al layout del sito e, esaminando il CSS critico, ci siamo imbattuti esattamente negli stessi problemi ancora una volta: il risultato generato era piuttosto dettagliato e non era proprio quello che volevamo . Quindi, come esperimento alla fine di ottobre, abbiamo riunito tutti i nostri punti di forza per rivisitare il nostro approccio CSS critico e studiare quanto sarebbe piccolo un CSS critico artigianale . Abbiamo preso un respiro profondo e trascorso giorni intorno allo strumento di copertura del codice sulle pagine chiave. Abbiamo raggruppato le regole CSS manualmente e rimosso i duplicati e il codice legacy in entrambe le posizioni: il CSS critico e il CSS principale. È stata davvero una pulizia tanto necessaria, poiché molti stili scritti nel 2017-2018 sono diventati obsoleti nel corso degli anni.

Di conseguenza, ci siamo ritrovati con tre file CSS critici realizzati a mano e con altri tre file attualmente in lavorazione:

  • critical-homepage-manual.css (8,2 KB, Brotlified)
  • critical-article-manual.css (8 KB, Brotlified)
  • critical-articles-manual.css (6 KB, Brotlified)
  • critical-books-manual.css ( lavoro da svolgere )
  • critical-events-manual.css ( lavoro da svolgere )
  • critical-job-board-manual.css ( lavoro da svolgere )

I file sono inline nell'head di ogni template, e al momento sono duplicati nel bundle CSS monolitico che contiene tutto ciò che è mai stato utilizzato (o non più realmente utilizzato) sul sito. Al momento, stiamo cercando di scomporre l'intero bundle CSS in alcuni pacchetti CSS, in modo che un lettore della rivista non scarichi gli stili dalla bacheca di lavoro o dalle pagine del libro, ma quando raggiunge quelle pagine otterrebbe un rapido rendering con CSS critici e ottieni il resto del CSS per quella pagina in modo asincrono, solo su quella pagina.

Certo, i file CSS critici realizzati a mano non erano di dimensioni molto inferiori: abbiamo ridotto la dimensione dei file CSS critici di circa il 14% . Tuttavia, includevano tutto ciò di cui avevamo bisogno nel giusto ordine dall'alto alla fine senza duplicati e stili di override. Questo sembrava essere un passo nella giusta direzione e ci ha dato una spinta al Faro di altri 3–4 punti. Stavamo facendo progressi.

Modifica del caricamento dei caratteri Web

Con font-display a portata di mano, il caricamento dei caratteri sembra essere un problema in passato. Sfortunatamente, nel nostro caso non è del tutto corretto. Voi, cari lettori, sembrate visitare una serie di articoli su Smashing Magazine. Inoltre, torni spesso sul sito per leggere un altro articolo, forse poche ore o giorni dopo, o forse una settimana dopo. Uno dei problemi che abbiamo riscontrato con font-display utilizzata nel sito è stato che per i lettori che si spostavano molto tra gli articoli, abbiamo notato molti flash tra il carattere di riserva e il carattere Web (cosa che normalmente non dovrebbe accadere poiché i caratteri sarebbero correttamente memorizzato nella cache).

Non sembrava un'esperienza utente decente, quindi abbiamo esaminato le opzioni. In Smashing, utilizziamo due caratteri tipografici principali : Mija per i titoli ed Elena per il body copy. Mija è disponibile in due pesi (Regular e Bold), mentre Elena è disponibile in tre pesi (Regular, Italic, Bold). Abbiamo abbandonato Bold Italic di Elena anni fa durante la riprogettazione solo perché l'abbiamo usato solo su poche pagine. Sottoponiamo gli altri caratteri rimuovendo i caratteri non utilizzati e gli intervalli Unicode.

I nostri articoli sono per lo più impostati nel testo, quindi abbiamo scoperto che la maggior parte delle volte sul sito il più grande contenuto pittorico è il primo paragrafo di testo in un articolo o la foto dell'autore. Ciò significa che dobbiamo prestare particolare attenzione per garantire che il primo paragrafo appaia rapidamente in un carattere di riserva, passando con grazia al carattere Web con ridisposizioni minime.

Dai un'occhiata da vicino all'esperienza di caricamento iniziale della prima pagina (rallentata tre volte):

Avevamo quattro obiettivi primari quando abbiamo trovato una soluzione:

  1. Alla prima visita, renderizza immediatamente il testo con un carattere di riserva;
  2. Abbina le metriche dei caratteri dei caratteri di riserva e dei caratteri Web per ridurre al minimo i cambiamenti di layout;
  3. Carica tutti i caratteri web in modo asincrono e applicali tutti in una volta (max. 1 reflow);
  4. Nelle visite successive, renderizza tutto il testo direttamente nei caratteri web (senza lampeggiare o ridisporre).

Inizialmente, abbiamo effettivamente provato a utilizzare font-display: swap on font-face . Questa sembrava essere l'opzione più semplice, tuttavia, come accennato in precedenza, alcuni lettori visiteranno un certo numero di pagine, quindi abbiamo riscontrato molto sfarfallio con i sei caratteri che stavamo visualizzando in tutto il sito. Inoltre, con la sola visualizzazione dei caratteri , non è stato possibile raggruppare richieste o ridipingere.

Un'altra idea era di eseguire il rendering di tutto in font di riserva alla visita iniziale , quindi richiedere e memorizzare nella cache tutti i font in modo asincrono e solo nelle visite successive fornire i font Web direttamente dalla cache. Il problema con questo approccio era che un certo numero di lettori proveniva dai motori di ricerca e almeno alcuni di loro vedranno solo quella pagina e non volevamo eseguire il rendering di un articolo solo con un font di sistema.

Allora cos'è?

Dal 2017 utilizziamo l'approccio Two-Stage-Render per il caricamento dei caratteri Web, che descrive sostanzialmente due fasi del rendering: una con un sottoinsieme minimo di caratteri Web e l'altra con una famiglia completa di pesi dei caratteri. In passato, abbiamo creato sottoinsiemi minimi di Mija Bold ed Elena Regular che erano i pesi più utilizzati sul sito. Entrambi i sottoinsiemi includono solo caratteri latini, punteggiatura, numeri e alcuni caratteri speciali. Questi caratteri ( ElenaInitial.woff2 e MijaInitial.woff2 ) erano di dimensioni molto ridotte, spesso solo di circa 10-15 KB. Li serviamo nella prima fase del rendering dei caratteri, visualizzando l'intera pagina in questi due caratteri.

CLS causato dallo sfarfallio dei caratteri Web
CLS causato dallo sfarfallio dei caratteri Web (le ombre sotto le immagini dell'autore si spostano a causa della modifica del carattere). Generato con Layout Shift GIF Generator. (Grande anteprima)

Lo facciamo con un'API di caricamento dei caratteri che ci fornisce informazioni su quali caratteri sono stati caricati correttamente e quali no. Dietro le quinte, accade aggiungendo una classe .wf-loaded-stage1 al body , con stili che rendono il contenuto in quei caratteri:

 .wf-loaded-stage1 article, .wf-loaded-stage1 promo-box, .wf-loaded-stage1 comments { font-family: ElenaInitial,sans-serif; } .wf-loaded-stage1 h1, .wf-loaded-stage1 h2, .wf-loaded-stage1 .btn { font-family: MijaInitial,sans-serif; }

Poiché i file dei caratteri sono piuttosto piccoli, si spera che passino attraverso la rete abbastanza rapidamente. Quindi, quando il lettore può effettivamente iniziare a leggere un articolo, carichiamo tutti i pesi dei caratteri in modo asincrono e aggiungiamo .wf-loaded-stage2 al corpo :

 .wf-loaded-stage2 article, .wf-loaded-stage2 promo-box, .wf-loaded-stage2 comments { font-family: Elena,sans-serif; } .wf-loaded-stage2 h1, .wf-loaded-stage2 h2, .wf-loaded-stage2 .btn { font-family: Mija,sans-serif; }

Quindi, quando caricano una pagina, i lettori otterranno prima un font Web di un piccolo sottoinsieme, quindi passeremo alla famiglia di font completa. Ora, per impostazione predefinita, questi passaggi tra i caratteri di riserva e i caratteri Web avvengono in modo casuale, in base a ciò che viene prima attraverso la rete. Potrebbe sembrare piuttosto dirompente quando hai iniziato a leggere un articolo. Quindi, invece di lasciare che sia il browser a decidere quando cambiare font, raggruppiamo repaints , riducendo al minimo l'impatto del reflow.

 /* Loading web fonts with Font Loading API to avoid multiple repaints. With help by Irina Lipovaya. */ /* Credit to initial work by Zach Leatherman: https://noti.st/zachleat/KNaZEg/the-five-whys-of-web-font-loading-performance#sWkN4u4 */ // If the Font Loading API is supported... // (If not, we stick to fallback fonts) if ("fonts" in document) { // Create new FontFace objects, one for each font let ElenaRegular = new FontFace( "Elena", "url(/fonts/ElenaWebRegular/ElenaWebRegular.woff2) format('woff2')" ); let ElenaBold = new FontFace( "Elena", "url(/fonts/ElenaWebBold/ElenaWebBold.woff2) format('woff2')", { weight: "700" } ); let ElenaItalic = new FontFace( "Elena", "url(/fonts/ElenaWebRegularItalic/ElenaWebRegularItalic.woff2) format('woff2')", { style: "italic" } ); let MijaBold = new FontFace( "Mija", "url(/fonts/MijaBold/Mija_Bold-webfont.woff2) format('woff2')", { weight: "700" } ); // Load all the fonts but render them at once // if they have successfully loaded let loadedFonts = Promise.all([ ElenaRegular.load(), ElenaBold.load(), ElenaItalic.load(), MijaBold.load() ]).then(result => { result.forEach(font => document.fonts.add(font)); document.documentElement.classList.add('wf-loaded-stage2'); // Used for repeat views sessionStorage.foutFontsStage2Loaded = true; }).catch(error => { throw new Error(`Error caught: ${error}`); }); }

Tuttavia, cosa succede se il primo piccolo sottoinsieme di caratteri non arriva rapidamente attraverso la rete? Abbiamo notato che questo sembra accadere più spesso di quanto vorremmo. In tal caso, allo scadere di un timeout di 3 secondi, i browser moderni ricadono su un font di sistema (nel nostro stack di font sarebbe Arial), quindi passano a ElenaInitial o MijaInitial , solo per passare rispettivamente a Elena o Mija completa in seguito . Ciò ha prodotto un po' troppo di sfogo alla nostra degustazione. Inizialmente stavamo pensando di rimuovere il rendering della prima fase solo per le reti lente (tramite Network Information API), ma poi abbiamo deciso di rimuoverlo del tutto.

Quindi in ottobre abbiamo rimosso del tutto i sottoinsiemi, insieme alla fase intermedia. Ogni volta che tutti i pesi dei caratteri Elena e Mija vengono scaricati con successo dal cliente e pronti per essere applicati, avviamo la fase 2 e ridipingiamo tutto in una volta. E per rendere i reflow ancora meno evidenti, abbiamo dedicato un po' di tempo alla corrispondenza dei caratteri di fallback e dei caratteri Web . Ciò significava principalmente applicare dimensioni dei caratteri e altezze delle linee leggermente diverse per gli elementi dipinti nella prima parte visibile della pagina.

Per questo, abbiamo usato font-style-matcher e (ahem, ahem) alcuni numeri magici. Questo è anche il motivo per cui inizialmente abbiamo scelto -apple-system e Arial come caratteri di fallback globali; San Francisco (renderizzato tramite -apple-system ) sembrava essere un po' più carino di Arial, ma se non è disponibile, abbiamo scelto di utilizzare Arial solo perché è ampiamente diffuso nella maggior parte dei sistemi operativi.

In CSS, sarebbe simile a questo:

 .article__summary { font-family: -apple-system,Arial,BlinkMacSystemFont,Roboto Slab,Droid Serif,Segoe UI,Ubuntu,Cantarell,Georgia,sans-serif; font-style: italic; /* Warning: magic numbers ahead! */ /* San Francisco Italic and Arial Italic have larger x-height, compared to Elena */ font-size: 0.9213em; line-height: 1.487em; } .wf-loaded-stage2 .article__summary { font-family: Elena,sans-serif; font-size: 1em; /* Original font-size for Elena Italic */ line-height: 1.55em; /* Original line-height for Elena Italic */ }

Questo ha funzionato abbastanza bene. Il testo viene visualizzato immediatamente e i caratteri Web vengono visualizzati sullo schermo raggruppati, provocando idealmente esattamente un riflusso nella prima visualizzazione e nessun riflusso del tutto nelle visualizzazioni successive.

Una volta che i caratteri sono stati scaricati, li archiviamo nella cache di un addetto ai servizi. Nelle visite successive controlliamo prima se i caratteri sono già nella cache. Se lo sono, li recuperiamo dalla cache del lavoratore del servizio e li applichiamo immediatamente. E in caso contrario, ricominciamo da capo con il fallback-web-font-switcheroo .

Questa soluzione ha ridotto al minimo (uno) il numero di reflow su connessioni relativamente veloci, mantenendo anche i caratteri nella cache in modo persistente e affidabile. In futuro, speriamo sinceramente di sostituire i numeri magici con f-mod. Forse Zach Leatherman sarebbe orgoglioso.

Identificazione e scomposizione del monolitico JS

Quando abbiamo studiato il thread principale nel pannello Performance di DevTools, sapevamo esattamente cosa dovevamo fare. C'erano otto compiti lunghi che richiedevano tra 70 ms e 580 ms, bloccando l'interfaccia e rendendola non reattiva. In generale, questi erano gli script che costavano di più:

  • uc.js , uno script di prompt dei cookie (70 ms)
  • ricalcoli di stile causati dal file full.css in entrata (176 ms) (il CSS critico non contiene stili al di sotto dell'altezza di 1000 px in tutte le finestre)
  • script pubblicitari in esecuzione al caricamento dell'evento per la gestione di pannelli, carrello, ecc. + ricalcoli di stile (276ms)
  • interruttore del carattere web, ricalcoli di stile (290 ms)
  • valutazione app.js (580 ms)

Ci siamo concentrati per primi su quelli più dannosi, per così dire i compiti lunghi più lunghi.

Uno screenshot tratto da DevTools che mostra le convalide dello stile per la prima pagina di una rivista di successo
In fondo, Devtools mostra le invalidazioni di stile: un cambio di carattere ha interessato 549 elementi che dovevano essere ridipinti. Per non parlare dei cambiamenti di layout che stava causando. (Grande anteprima)

Il primo si è verificato a causa di costosi ricalcoli del layout causati dal cambio dei font (da font di fallback a font Web), causando oltre 290 ms di lavoro extra (su un laptop veloce e una connessione veloce). Rimuovendo la fase uno dal solo caricamento del font, siamo stati in grado di recuperare circa 80 ms. Non era abbastanza buono perché erano ben oltre il budget di 50 ms. Così abbiamo iniziato a scavare più a fondo.

Il motivo principale per cui si sono verificati i ricalcoli è semplicemente dovuto alle enormi differenze tra i caratteri di fallback e i caratteri Web. Abbinando l'altezza e le dimensioni della riga per i caratteri di riserva e i caratteri Web , siamo stati in grado di evitare molte situazioni in cui una riga di testo sarebbe andata a capo su una nuova riga nel carattere di riserva, ma poi sarebbe diventata leggermente più piccola e si sarebbe adattata alla riga precedente, causando grandi cambiamenti nella geometria dell'intera pagina e, di conseguenza, enormi cambiamenti di layout. Abbiamo giocato anche con letter-spacing e word-spacing , ma non ha prodotto buoni risultati.

Con queste modifiche, siamo stati in grado di tagliare altri 50-80 ms, ma non siamo stati in grado di ridurlo al di sotto di 120 ms senza visualizzare il contenuto in un carattere di riserva e visualizzare il contenuto nel carattere Web in seguito. Ovviamente, dovrebbe interessare in modo massiccio solo i visitatori che visitano per la prima volta, in quanto le visualizzazioni di pagina conseguenti verrebbero visualizzate con i caratteri recuperati direttamente dalla cache del lavoratore del servizio, senza costosi reflow dovuti al cambio di carattere.

A proposito, è abbastanza importante notare che nel nostro caso, abbiamo notato che la maggior parte delle attività lunghe non erano causate da JavaScript massiccio, ma invece da ricalcoli del layout e analisi del CSS, il che significava che dovevamo fare un po' di CSS cleaning, especially watching out for situations when styles are overwritten. In some way, it was good news because we didn't have to deal with complex JavaScript issues that much. However, it turned out not to be straightforward as we are still cleaning up the CSS this very day. We were able to remove two Long Tasks for good, but we still have a few outstanding ones and quite a way to go. Fortunately, most of the time we aren't way above the magical 50ms threshold.

The much bigger issue was the JavaScript bundle we were serving, occupying the main thread for a whopping 580ms. Most of this time was spent in booting up app.js which contains React, Redux, Lodash, and a Webpack module loader. The only way to improve performance with this massive beast was to break it down into smaller pieces. So we looked into doing just that.

With Webpack, we've split up the monolithic bundle into smaller chunks with code-splitting , about 30Kb per chunk. We did some package.json cleansing and version upgrade for all production dependencies, adjusted the browserlistrc setup to address the two latest browser versions, upgraded to Webpack and Babel to the latest versions, moved to Terser for minification, and used ES2017 (+ browserlistrc) as a target for script compilation.

We also used BabelEsmPlugin to generate modern versions of existing dependencies. Finally, we've added prefetch links to the header for all necessary script chunks and refactored the service worker, migrating to Workbox with Webpack (workbox-webpack-plugin).

A screenshot showing JavaScript chunks affecting performance with each running no longer than 40ms on the main thread
JavaScript chunks in action, with each running no longer than 40ms on the main thread. (Grande anteprima)

Remember when we switched to the new navigation back in mid-2020, just to see a huge performance penalty as a result? The reason for it was quite simple. While in the past the navigation was just static plain HTML and a bit of CSS, with the new navigation, we needed a bit of JavaScript to act on opening and closing of the menu on mobile and on desktop. That was causing rage clicks when you would click on the navigation menu and nothing would happen, and of course, had a penalty cost in Time-To-Interactive scores in Lighthouse.

We removed the script from the bundle and extracted it as a separate script . Additionally, we did the same thing for other standalone scripts that were used rarely — for syntax highlighting, tables, video embeds and code embeds — and removed them from the main bundle; instead, we granularly load them only when needed.

Performance stats for the smashing magazine front page showing the function call for nav.js that happened right after a monolithic app.js bundle had been executed
Notice that the function call for nav.js is happening after a monolithic app.js bundle is executed. That's not quite right. (Grande anteprima)

However, what we didn't notice for months was that although we removed the navigation script from the bundle, it was loading after the entire app.js bundle was evaluated, which wasn't really helping Time-To-Interactive (see image above). We fixed it by preloading nav.js and deferring it to execute in the order of appearance in the DOM, and managed to save another 100ms with that operation alone. By the end, with everything in place we were able to bring the task to around 220ms.

A screenshot of the the Long task reduced by almost 200ms
By prioritizing the nav.js script, we were able to reduce the Long task by almost 200ms. (Grande anteprima)

We managed to get some improvement in place, but still have quite a way to go, with further React and Webpack optimizations on our to-do list. At the moment we still have three major Long Tasks — font switch (120ms), app.js execution (220ms) and style recalculations due to the size of full CSS (140ms). For us, it means cleaning up and breaking up the monolithic CSS next.

It's worth mentioning that these results are really the best-scenario- results. On a given article page we might have a large number of code embeds and video embeds, along with other third-party scripts and customer's browser extensions that would require a separate conversation.

Dealing With 3rd-Parties

Fortunately, our third-party scripts footprint (and the impact of their friends' fourth-party-scripts) wasn't huge from the start. But when these third-party scripts accumulated, they would drive performance down significantly. This goes especially for video embedding scripts , but also syntax highlighting, advertising scripts, promo panels scripts and any external iframe embeds.

Obviously, we defer all of these scripts to start loading after the DOMContentLoaded event, but once they finally come on stage, they cause quite a bit of work on the main thread. This shows up especially on article pages, which are obviously the vast majority of content on the site.

The first thing we did was allocating proper space to all assets that are being injected into the DOM after the initial page render. It meant width and height for all advertising images and the styling of code snippets. We found out that because all the scripts were deferred, new styles were invalidating existing styles, causing massive layout shifts for every code snippet that was displayed. We fixed that by adding the necessary styles to the critical CSS on the article pages.

We've re-established a strategy for optimizing images (preferably AVIF or WebP — still work in progress though). All images below the 1000px height threshold are natively lazy-loaded (with <img loading=lazy> ), while the ones on the top are prioritized ( <img loading=eager> ). The same goes for all third-party embeds.

We replaced some dynamic parts with their static counterparts — eg while a note about an article saved for offline reading was appearing dynamically after the article was added to the service worker's cache, now it appears statically as we are, well, a bit optimistic and expect it to be happening in all modern browsers.

As of the moment of writing, we're preparing facades for code embeds and video embeds as well. Plus, all images that are offscreen will get decoding=async attribute, so the browser has a free reign over when and how it loads images offscreen, asynchronously and in parallel.

A screenshot of the main front page of smashing magazine being highlighted by the Diagnostics CSS tool for each image that does not have a width/height attribute
Diagnostics CSS in use: highlighting images that don't have width/height attributes, or are served in legacy formats. (Grande anteprima)

To ensure that our images always include width and height attributes, we've also modified Harry Roberts' snippet and Tim Kadlec's diagnostics CSS to highlight whenever an image isn't served properly. It's used in development and editing but obviously not in production.

One technique that we used frequently to track what exactly is happening as the page is being loaded, was slow-motion loading .

First, we've added a simple line of code to the diagnostics CSS, which provides a noticeable outline for all elements on the page.

* { outline: 3px solid red }
* { outline: 3px solid red } 
A screenshot of an article published on smashing magazine with red lines on the layout to help check the stability and rendering on the page
A quick trick to check the stability of the layout, by adding * { outline: 3px red } and observing the boxes as the browser is rendering the page. (Grande anteprima)

Then we record a video of the page loaded on a slow and fast connection. Then we rewatch the video by slowing down the playback and moving back and forward to identify where massive layout shifts happen.

Here's the recording of a page being loaded on a fast connection:

Recording for the loading of the page with an outline applied, to observe layout shifts.

And here's the recording of a recording being played to study what happens with the layout:

Auditing the layout shifts by rewatching a recording of the site loading in slow motion, watching out for height and width of content blocks, and layout shifts.

By auditing the layout shifts this way, we were able to quickly notice what's not quite right on the page, and where massive recalculation costs are happening. As you probably have noticed, adjusting the line-height and font-size on headings might go a long way to avoid large shifts.

With these simple changes alone, we were able to boost performance score by a whopping 25 Lighthouse points for the video-heaviest article, and gain a few points for code embeds.

Enhancing The Experience

We've tried to be quite strategic in pretty much everything from loading web fonts to serving critical CSS. However, we've done our best to use some of the new technologies that have become available last year.

We are planning on using AVIF by default to serve images on SmashingMag, but we aren't quite there yet, as many of our images are served from Cloudinary (which already has beta support for AVIF), but many are directly from our CDN yet we don't really have a logic in place just yet to generate AVIFs on the fly. That would need to be a manual process for now.

We're lazy rendering some of the offset components of the page with content-visibility: auto . For example, the footer, the comments section, as well as the panels way below the first 1000px height threshold, are all rendered later after the visible portion of each page has been rendered.

Abbiamo giocato un po' con link rel="prefetch" e anche link rel="prerender" (NoPush prefetch) alcune parti della pagina che molto probabilmente verranno utilizzate per un'ulteriore navigazione, ad esempio per precaricare le risorse per il primo articoli in prima pagina (ancora in discussione).

Precarichiamo anche le immagini dell'autore per ridurre il più grande contenuto pittorico e alcune risorse chiave che vengono utilizzate in ogni pagina, come le immagini del gatto che balla (per la navigazione) e l'ombra utilizzata per tutte le immagini dell'autore. Tuttavia, tutti sono precaricati solo se un lettore si trova su uno schermo più grande (>800px), anche se stiamo cercando di utilizzare l'API Network Information invece per essere più precisi.

Abbiamo anche ridotto le dimensioni del CSS completo e di tutti i file CSS critici rimuovendo il codice legacy, refactoring di un certo numero di componenti e rimuovendo il trucco dell'ombreggiatura del testo che stavamo usando per ottenere sottolineature perfette con una combinazione di text-decoration-skip -inchiostro e spessore-decorazione del testo (finalmente!).

Lavoro da fare

Abbiamo dedicato una notevole quantità di tempo ad aggirare tutte le modifiche minori e principali sul sito. Abbiamo notato miglioramenti piuttosto significativi su desktop e un aumento abbastanza evidente su dispositivi mobili. Al momento in cui scriviamo, i nostri articoli ottengono in media un punteggio compreso tra 90 e 100 Lighthouse su desktop e circa 65-80 su dispositivo mobile .

Il punteggio di Lighthouse sul desktop è compreso tra 90 e 100
Punteggio delle prestazioni su desktop. La homepage è già fortemente ottimizzata. (Grande anteprima)
Il punteggio del faro sui dispositivi mobili è compreso tra 65 e 80
Sui dispositivi mobili, non raggiungiamo quasi mai un punteggio di Lighthouse superiore a 85. I problemi principali sono ancora Time to Interactive e Total Blocking Time. (Grande anteprima)

Il motivo dello scarso punteggio sui dispositivi mobili è chiaramente lo scarso Time to Interactive e lo scarso tempo di Total Blocking a causa dell'avvio dell'app e delle dimensioni dell'intero file CSS. Quindi c'è ancora del lavoro da fare lì.

Per quanto riguarda i passaggi successivi, stiamo attualmente cercando di ridurre ulteriormente la dimensione del CSS e di suddividerlo in moduli, in modo simile a JavaScript, caricando alcune parti del CSS (ad esempio checkout o bacheca di lavoro o libri/eBook) solo quando necessario.

Esploriamo anche le opzioni per un'ulteriore sperimentazione in bundle sui dispositivi mobili per ridurre l'impatto sulle prestazioni di app.js , sebbene al momento non sembri banale. Infine, esamineremo alternative alla nostra soluzione di richiesta dei cookie, ricostruendo i nostri contenitori con CSS clamp() , sostituendo la tecnica del rapporto di riempimento inferiore con il rapporto di aspect-ratio e cercando di servire quante più immagini possibili in AVIF.

Questo è tutto, gente!

Si spera che questo piccolo caso di studio ti sia utile e forse ci sono una o due tecniche che potresti essere in grado di applicare subito al tuo progetto. Alla fine, le prestazioni riguardano la somma di tutti i piccoli dettagli che, sommati, creano o distruggono l'esperienza del cliente.

Mentre siamo molto impegnati a migliorare le prestazioni, lavoriamo anche per migliorare l' accessibilità e il contenuto del sito. Quindi, se noti qualcosa che non va bene o qualcosa che potremmo fare per migliorare ulteriormente Smashing Magazine, faccelo sapere nei commenti a questo articolo.

Infine, se desideri rimanere aggiornato su articoli come questo, iscriviti alla nostra newsletter via e-mail per suggerimenti web amichevoli, chicche, strumenti e articoli e una selezione stagionale di gatti Smashing.