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

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

Chrome collabora con gli strumenti e i framework dell'ecosistema open source di JavaScript. Di recente sono state aggiunte diverse ottimizzazioni più recenti per migliorare le prestazioni di caricamento di Next.js e Gatsby. Questo articolo illustra 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 bndler di base. webpack 3 ha introdotto CommonsChunkPlugin per consentire di generare moduli condivisi tra diversi punti di contatto in un singolo (o pochi) chunk "comuni". Il codice condiviso può essere scaricato separatamente e archiviato nella cache del browser in anticipo, il che può migliorare le prestazioni in fase di caricamento.

Questo pattern è diventato popolare con molti framework di applicazioni a pagina singola che adottano una configurazione di punto di contatto e bundle simile alla seguente:

Configurazione comune di bundle e punto di contatto

Sebbene pratico, il concetto di raggruppare tutto il codice del modulo condiviso in un unico blocco ha i suoi limiti. I moduli non condivisi in ogni punto di contatto possono essere scaricati per i percorsi che non li utilizzano, con il risultato che viene scaricato più codice del necessario. Ad esempio, quando page1 carica il chunk common, carica il codice per moduleC anche se page1 non utilizza moduleC. Per questo motivo, insieme ad altri, webpack 4 ha rimosso il plug-in in favore di un nuovo plug-in: SplitChunksPlugin.

Suddivisione in blocchi migliorata

Le impostazioni predefinite per SplitChunksPlugin sono adatte alla maggior parte degli utenti. Vengono creati più chunk suddivisi in base a una serie di condizioni per impedire il recupero di codice duplicato su più percorsi.

Tuttavia, molti framework web che utilizzano questo plug-in seguono ancora un approccio "single-commons" alla suddivisione in chunk. Next.js, ad esempio, genera un bundle commons contenente tutti i moduli utilizzati 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 uno chunk condiviso consenta di scaricarlo e memorizzarlo nella cache per qualsiasi punto di contatto, l'euristica basata sull'utilizzo che prevede l'inclusione di moduli comuni utilizzati in più della metà delle pagine non è molto efficace. La modifica di questo rapporto può avere solo due risultati:

  • Se riduci il rapporto, viene scaricato più codice non necessario.
  • Se aumenti il rapporto, viene duplicato più codice in 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 singoli chunk
  • Viene creato un chunk frameworks separato per le dipendenze del framework (react, react-dom e così via).
  • Vengono creati tutti i chunk condivisi necessari (fino a 25)
  • La dimensione minima per un chunk da generare è stata modificata in 20 KB

Questa strategia di suddivisione granulare offre i seguenti vantaggi:

  • I tempi di caricamento delle pagine sono migliorati. L'emissione di più chunk condivisi, anziché di uno solo, consente di minimizzare la quantità di codice non necessario (o duplicato) per qualsiasi punto di contatto.
  • Memorizzazione nella cache migliorata durante la navigazione. La suddivisione di librerie di grandi dimensioni e dipendenze del framework in blocchi separati riduce la possibilità di invalidazione della cache, poiché è improbabile che entrambe le parti cambino fino a quando non viene eseguito un upgrade.

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

Più richieste HTTP

SplitChunksPlugin ha definito le basi per il chunking granulare e l'applicazione di questo approccio a un framework come Next.js non era un concetto completamente nuovo. Tuttavia, molti framework hanno continuato a utilizzare una singola strategia basata su regole euristiche e su pacchetti "comuni" per diversi motivi. Ciò include il timore che molte altre 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 minimizzare il numero di chunk generati da un aggregatore 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 più richieste in streaming in parallelo utilizzando una singola connessione su una singola origine. In altre parole, in genere non dobbiamo preoccuparci di limitare il numero di chunk emessi dal nostro bundler.

Tutti i principali browser supportano HTTP/2. I team di Chrome e Next.js volevano capire se l'aumento del numero di richieste con la suddivisione del singolo bundle "commons" di Next.js in più chunk condivisi avrebbe influito in qualche modo sul rendimento del 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, inizio rendering e First Contentful Paint sono rimasti invariati quando è stato modificato il numero di richieste iniziali massime (da 5 a 15). È interessante notare che abbiamo notato un lieve overhead del rendimento solo dopo la suddivisione aggressiva in centinaia di richieste.

Prestazioni di caricamento pagina con centinaia di richieste

Ciò ha dimostrato che rimanere al di sotto di una soglia affidabile (20-25 richieste) ha consentito di trovare il giusto equilibrio tra prestazioni di caricamento ed efficienza della cache. Dopo alcuni test di riferimento, è stato selezionato il valore 25 come conteggio maxInitialRequest.

La modifica del numero massimo di richieste in parallelo ha generato più di un singolo bundle condiviso e la loro separazione in modo appropriato per ogni punto di contatto ha ridotto notevolmente la quantità di codice non necessario per la stessa pagina.

Riduzioni del payload JavaScript con un aumento del chunking

Questo esperimento riguardava solo la modifica del numero di richieste per verificare se si verificasse un effetto negativo sul rendimento del caricamento della pagina. I risultati suggeriscono che l'impostazione di maxInitialRequests su 25 nella pagina di test è stata ottimale perché ha ridotto le dimensioni del payload JavaScript senza rallentare la pagina. La quantità totale di codice JavaScript necessaria per eseguire l'idratazione della pagina è rimasta invariata, il che spiega perché il rendimento del caricamento della pagina non è necessariamente migliorato con la riduzione della quantità di codice.

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

Riduzioni delle dimensioni con chunk granulari

Molti framework, tra cui Next.js, si basano sul routing lato client (gestito da JavaScript) per iniettare tag script più recenti per ogni transizione di route. Ma come vengono predeterminati questi chunk dinamici in fase di compilazione?

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

// 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ù chunk 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 early adopter. Molti hanno registrato riduzioni significative del codice JavaScript totale utilizzato per l'intero sito:

Sito web Modifica JS totale % di differenza
https://www.barnebys.com/ -238 KB -23%
https://sumup.com/ -220 KB -30%
https://www.hashicorp.com/ -11 MB -71%
Riduzione delle dimensioni di JavaScript in tutti i percorsi (compressi)

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

Gatsby

Gatsby utilizzava lo stesso approccio di utilizzo 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, ha anche riscontrato riduzioni significative del codice JavaScript in molti siti di grandi dimensioni:

Sito web Modifica JS totale % 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%
Riduzione delle dimensioni di JavaScript in tutti i percorsi (compressi)

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

Conclusione

Il concetto di pubblicazione di blocchi granulari non è specifico di Next.js, Gatsby o anche webpack. Tutti dovrebbero valutare la possibilità di migliorare la strategia di suddivisione dell'applicazione se segue un approccio di aggregazione di elementi comuni 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 standard, consulta questa app React di esempio. Utilizza una versione semplificata della strategia di suddivisione granulare e può aiutarti ad applicare lo stesso tipo di logica al tuo sito.
  • Per il raggruppamento, i chunk vengono creati in modo granulare per impostazione predefinita. Consulta manualChunks se vuoi configurare manualmente il comportamento.