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

Una nuova strategia di chunking di webpack in Next.js e Gatsby riduce al minimo il codice duplicato per migliorare le prestazioni di caricamento pagina.

Houssein Djirdeh
Houssein Djirdeh

Chrome sta collaborando con strumenti e framework nell'ecosistema open source di JavaScript. Di recente sono state aggiunte alcune 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 bundler principale. webpack v3 ha introdotto CommonsChunkPlugin per consentire l'output di moduli condivisi tra diversi punti di ingresso in un singolo (o pochi) chunk "comuni". Il codice condiviso può essere scaricato separatamente e memorizzato nella cache del browser in anticipo, il che può comportare un rendimento di caricamento migliore.

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

Configurazione comune di entrypoint e bundle

Sebbene sia pratico, il concetto di raggruppare tutto il codice dei moduli condivisi in un unico chunk ha i suoi limiti. I moduli non condivisi in ogni entry point 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 v4 ha rimosso il plug-in a favore di uno nuovo: SplitChunksPlugin.

Suddivisione in blocchi migliorata

Le impostazioni predefinite di SplitChunksPlugin sono adatte alla maggior parte degli utenti. Vengono creati più blocchi suddivisi in base a una serie di condizioni per evitare di recuperare codice duplicato in 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, genera un bundle commons che contiene 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 un chunk condiviso significhi che può essere scaricato e memorizzato nella cache per qualsiasi entrypoint, l'euristica basata sull'utilizzo di includere 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 più codice non necessario.
  • Se aumenti il rapporto, viene duplicato più codice in più route.

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 singolo chunk
  • Viene creato un chunk 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 per la generazione di un chunk è 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, riduce al minimo la quantità di codice non necessario (o duplicato) per qualsiasi entry point.
  • 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 annullamento della convalida della 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 le basi per il chunking 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 euristica e una strategia di bundle "comune" per alcuni motivi. Ciò include la preoccupazione che un numero molto maggiore di richieste HTTP possa influire negativamente sulle prestazioni del sito.

I browser possono aprire solo un numero limitato di connessioni TCP a un'unica origine (6 per Chrome), quindi ridurre al minimo il numero di chunk 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 nostro bundler.

Tutti i principali browser supportano HTTP/2. I team di Chrome e Next.js volevano verificare se l'aumento del numero di richieste tramite la suddivisione del singolo bundle "commons" di Next.js in più chunk condivisi avrebbe influito in qualche modo sulle prestazioni di caricamento. Hanno iniziato misurando le prestazioni di un singolo sito e 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 tutti invariati quando è stato modificato il numero massimo di richieste iniziali (da 5 a 15). È interessante notare che abbiamo notato un leggero overhead delle prestazioni solo dopo aver suddiviso in modo aggressivo le richieste in centinaia.

Prestazioni di caricamento pagina con centinaia di richieste

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

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

Riduzioni del payload JavaScript con aumento del chunking

Questo esperimento riguardava solo la modifica del numero di richieste per verificare se ci sarebbe stato un effetto negativo sulle prestazioni di caricamento 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 pressoché invariata, il che spiega perché le prestazioni di caricamento pagina non sono necessariamente migliorate con la riduzione della quantità di codice.

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

Riduzioni delle dimensioni con blocchi granulari

Molti framework, tra cui Next.js, si affidano al routing lato client (gestito da JavaScript) per inserire nuovi tag di script per ogni transizione di route. Ma come fanno a predeterminare questi blocchi dinamici in fase di build?

Next.js utilizza un file manifest di build lato server per determinare quali chunk di output vengono utilizzati dai diversi entry point. Per fornire queste informazioni anche al client, è stato creato un file manifest di build lato client abbreviato per mappare tutte le dipendenze per ogni entry point.

// 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 inizialmente implementata in Next.js dietro un flag, dove è stata testata su un numero di early adopter. 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 tutti i percorsi (compresso)

La versione finale è stata fornita 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, hanno anche notato riduzioni significative 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 tutti i percorsi (compresso)

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

Conclusione

Il concetto di invio di blocchi granulari non è specifico di Next.js, Gatsby o persino webpack. Tutti dovrebbero prendere in considerazione l'idea di migliorare la strategia di chunking della propria applicazione se segue un approccio di bundle "comune" di grandi dimensioni, indipendentemente dal framework o dal bundler di moduli utilizzato.

  • Se vuoi vedere le stesse ottimizzazioni di suddivisione in blocchi applicate a un'applicazione React vanilla, dai un'occhiata a questa app React di esempio. Utilizza una versione semplificata della strategia di suddivisione in blocchi 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. Dai un'occhiata a manualChunks se vuoi configurare manualmente il comportamento.