Prestazioni migliorate per il caricamento delle pagine di Next.js e Gatsby con suddivisione granulare

Una strategia di suddivisione dei chunk webpack più recente in Next.js e Gatsby riduce al minimo il codice duplicato per migliorare le prestazioni di caricamento della pagina.

Houssein Djirdeh
Houssein Djirdeh

Chrome collabora con strumenti e framework nell'ecosistema open source JavaScript. Di recente sono state aggiunte diverse ottimizzazioni più recenti per migliorare le prestazioni di caricamento di Next.js e Gatsby. Questo articolo descrive una strategia di suddivisione granulare migliorata che ora viene fornita per impostazione predefinita in entrambi i framework.

Introduzione

Come molti framework web, Next.js e Gatsby utilizzano webpack come bundler principale. webpack v3 ha introdotto CommonsChunkPlugin per consentire l'output di moduli condivisi tra diversi punti di ingresso in un unico (o pochi) chunk "commons" (o chunk). Il codice condiviso può essere scaricato separatamente e memorizzato nella cache del browser in anticipo, il che può migliorare le prestazioni di caricamento.

Questo pattern è diventato popolare con molti framework di applicazioni a pagina singola che adottano un punto di ingresso e una configurazione del bundle simile a questa:

Configurazione comune di punto di ingresso e bundle

Sebbene pratico, il concetto di raggruppare tutto il codice del modulo condiviso in un unico chunk presenta dei limiti. I moduli non condivisi in ogni punto di ingresso possono essere scaricati per le route che non li utilizzano, con conseguente download di più codice del necessario. Ad esempio, quando viene caricato il blocco page1, viene caricato il codice per moduleC anche se page1 non utilizza moduleC.common Per questo motivo, oltre ad altri, webpack v4 ha rimosso il plug-in a favore di uno nuovo: SplitChunksPlugin.

Chunking migliorato

Le impostazioni predefinite per SplitChunksPlugin sono adatte alla maggior parte degli utenti. Vengono creati più blocchi suddivisi a seconda di una serie di condizioni per evitare il recupero di codice duplicato su più route.

Tuttavia, molti framework web che utilizzano questo plug-in seguono ancora un approccio "single-commons" alla suddivisione dei chunk. Next.js, ad esempio, genererebbe un bundle commons contenente qualsiasi modulo utilizzato in più del 50% delle pagine e tutte le dipendenze del framework (react, react-dom e così via).

const splitChunksConfigs = {
  
  prod: {
    chunks: 'all',
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: 'commons',
        chunks: 'all',
        minChunks: totalPages > 2 ? totalPages * 0.5 : 2,
      },
      react: {
        name: 'commons',
        chunks: 'all',
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler|use-subscription)[\\/]/,
      },
    },
  },

Sebbene l'inclusione di codice dipendente dal framework in un chunk condiviso significhi che può essere scaricato e memorizzato nella cache per qualsiasi punto di ingresso, l'euristica basata sull'utilizzo che include moduli comuni utilizzati in più della metà delle pagine non è molto efficace. La modifica di questo rapporto comporterebbe solo uno dei due risultati:

  • Se riduci il rapporto, viene scaricato codice non necessario.
  • Se aumenti il rapporto, più codice viene duplicato su più percorsi.

Per risolvere questo problema, Next.js ha adottato una configurazione diversa perSplitChunksPlugin che riduce il codice non necessario per qualsiasi route.

  • Qualsiasi modulo di terze parti sufficientemente grande (superiore a 160 KB) viene suddiviso in un proprio chunk individuale
  • Viene creato un blocco frameworks separato per le dipendenze del framework (react, react-dom e così via).
  • Vengono creati tutti i blocchi condivisi necessari (fino a 25).
  • La dimensione minima di un segmento da generare è stata modificata a 20 kB

Questa strategia di suddivisione granulare offre i seguenti vantaggi:

  • I tempi di caricamento delle pagine sono migliorati. L'emissione di più blocchi condivisi, anziché di uno solo, riduce al minimo la quantità di codice non necessario (o duplicato) per qualsiasi punto di ingresso.
  • Miglioramento della memorizzazione nella cache durante la navigazione. La suddivisione di librerie e dipendenze del framework di grandi dimensioni in blocchi separati riduce la possibilità di invalidare la cache, poiché è improbabile che entrambi cambino fino a quando non viene eseguito un upgrade.

Puoi visualizzare l'intera configurazione adottata da Next.js in webpack-config.ts.

Altre richieste HTTP

SplitChunksPlugin ha definito la base per la suddivisione granulare e l'applicazione di questo approccio a un framework come Next.js non era un concetto del tutto nuovo. Tuttavia, molti framework hanno continuato a utilizzare una singola strategia di bundle euristica e "commons" per diversi motivi. Ciò include la preoccupazione che molte più richieste HTTP possano influire negativamente sulle prestazioni del sito.

I browser possono aprire solo un numero limitato di connessioni TCP a una singola origine (6 per Chrome), quindi ridurre al minimo il numero di blocchi generati da un bundler può garantire che il numero totale di richieste rimanga al di sotto di questa soglia. Tuttavia, questo vale solo per HTTP/1.1. Il multiplexing in HTTP/2 consente di trasmettere in streaming più richieste in parallelo utilizzando una singola connessione su una singola origine. In altre parole, in genere non dobbiamo preoccuparci di limitare il numero di blocchi emessi dal bundler.

Tutti i principali browser supportano HTTP/2. I team di Chrome e Next.js volevano verificare se l'aumento del numero di richieste dividendo il singolo bundle "commons" di Next.js in più blocchi condivisi avrebbe influito in qualche modo sul rendimento di caricamento. Hanno iniziato misurando il rendimento di un singolo sito modificando il numero massimo di richieste parallele utilizzando la proprietà maxInitialRequests.

Prestazioni di caricamento pagina con un numero maggiore di richieste

In una media di tre esecuzioni di più prove su una singola pagina web, i tempi di load, start-render e First Contentful Paint sono rimasti pressoché invariati quando è stato variato il conteggio massimo delle richieste iniziali (da 5 a 15). È interessante notare che abbiamo notato un leggero sovraccarico delle prestazioni solo dopo aver suddiviso in modo aggressivo centinaia di richieste.

Rendimento del caricamento pagina con centinaia di richieste

Ciò ha dimostrato che rimanere al di sotto di una soglia affidabile (20-25 richieste) ha trovato il giusto equilibrio tra le prestazioni di caricamento e l'efficienza della memorizzazione nella cache. Dopo alcuni test di base, è stato selezionato 25 come conteggio maxInitialRequest.

La modifica del numero massimo di richieste parallele ha comportato la creazione di più di un singolo bundle condiviso e la loro separazione appropriata per ogni punto di ingresso ha ridotto significativamente la quantità di codice non necessario per la stessa pagina.

Riduzioni del payload JavaScript con un aumento della suddivisione in blocchi

Questo esperimento riguardava solo la modifica del numero di richieste per verificare se ci sarebbero stati effetti negativi sulle prestazioni di caricamento della pagina. I risultati suggeriscono che l'impostazione di maxInitialRequests su 25 nella pagina di test era ottimale perché riduceva le dimensioni del payload JavaScript senza rallentare la pagina. La quantità totale di JavaScript necessaria per l'idratazione della pagina è rimasta più o meno la stessa, il che spiega perché il rendimento del caricamento della pagina non è migliorato necessariamente con la riduzione della quantità di codice.

webpack utilizza 30 KB come dimensione minima predefinita per la generazione di un chunk. Tuttavia, l'accoppiamento di un valore maxInitialRequests di 25 con una dimensione minima di 20 KB ha portato a una memorizzazione nella cache migliore.

Riduzioni delle dimensioni con blocchi granulari

Molti framework, tra cui Next.js, si basano sul routing lato client (gestito da JavaScript) per inserire tag script più recenti per ogni transizione di route. Ma come fanno a predeterminare questi blocchi dinamici al momento della creazione?

Next.js utilizza un file manifest di build lato server per determinare quali blocchi di output vengono utilizzati da diversi punti di ingresso. Per fornire queste informazioni anche al client, è stato creato un file manifest di build lato client abbreviato per mappare tutte le dipendenze per ogni punto di ingresso.

// Returns a promise for the dependencies for a particular route
getDependencies (route) {
  return this.promisedBuildManifest.then(
    man => (man[route] && man[route].map(url => `/_next/${url}`)) || []
  )
}
Output di più blocchi condivisi in un'applicazione Next.js.

Questa nuova strategia di suddivisione granulare è stata implementata per la prima volta in Next.js dietro un flag, dove è stata testata su un numero di primi utenti. Molti hanno riscontrato riduzioni significative del codice JavaScript totale utilizzato per l'intero sito:

Sito web Variazione totale JS % di differenza
https://www.barnebys.com/ -238 KB -23%
https://sumup.com/ -220 kB -30%
https://www.hashicorp.com/ -11 MB -71%
Riduzioni delle dimensioni di JavaScript in tutte le route (compresse)

La versione finale è stata spedita per impostazione predefinita nella versione 9.2.

Gatsby

Gatsby utilizzava lo stesso approccio di un'euristica basata sull'utilizzo per definire i moduli comuni:

config.optimization = {
  
  splitChunks: {
    name: false,
    chunks: `all`,
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: `commons`,
        chunks: `all`,
        // if a chunk is used more than half the components count,
        // we can assume it's pretty global
        minChunks: componentsCount > 2 ? componentsCount * 0.5 : 2,
      },
      react: {
        name: `commons`,
        chunks: `all`,
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
      },

Ottimizzando la configurazione di webpack per adottare una strategia di suddivisione granulare simile, hanno anche notato riduzioni considerevoli di JavaScript in molti siti di grandi dimensioni:

Sito web Variazione totale JS % di differenza
https://www.gatsbyjs.org/ -680 KB -22%
https://www.thirdandgrove.com/ - 390 kB -25%
https://ghost.org/ -1,1 MB -35%
https://reactjs.org/ -80 Kb -8%
Riduzioni delle dimensioni di JavaScript in tutte le route (compresse)

Dai un'occhiata alla PR per capire come hanno implementato questa logica nella configurazione di webpack, fornita per impostazione predefinita nella versione 2.20.7.

Conclusione

Il concetto di spedizione di blocchi granulari non è specifico di Next.js, Gatsby o webpack. Tutti dovrebbero prendere in considerazione la possibilità di migliorare la strategia di suddivisione in blocchi dell'applicazione se segue un approccio di tipo "commons" di grandi dimensioni, indipendentemente dal framework o dal bundler di moduli utilizzato.

  • Se vuoi vedere le stesse ottimizzazioni di suddivisione applicate a un'applicazione React di base, dai un'occhiata a questa app React di esempio. Utilizza una versione semplificata della strategia di suddivisione granulare e può aiutarti a iniziare ad applicare lo stesso tipo di logica al tuo sito.
  • Per il rollup, i chunk vengono creati in modo granulare per impostazione predefinita. Consulta manualChunks se vuoi configurare manualmente il comportamento.