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 delle pagine.

Chrome sta collaborando con strumenti e framework nell'ecosistema open source JavaScript. Di recente è stata aggiunta una serie di ottimizzazioni più recenti per migliorare le prestazioni di caricamento di Next.js e Gatsby. Questo articolo illustra una strategia migliorata di suddivisione in blocchi, che viene fornita per impostazione predefinita in entrambi i framework.

Introduzione

Come molti framework web, Next.js e Gatsby usano webpack come bundle principali. webpack v3 introdotto CommonsChunkPlugin per consentire di generare moduli di output condivisi tra diversi punti di ingresso in un unico (o più) blocchi (o blocchi) "comuni". Il codice condiviso può essere scaricato separatamente e archiviato in anticipo nella cache del browser, il che può migliorare le prestazioni di caricamento.

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

Configurazione del bundle e del punto di ingresso comune

Sebbene sia pratico, il concetto di raggruppamento di tutto il codice dei moduli condivisi in un unico blocco ha le sue limitazioni. I moduli non condivisi in ogni punto di ingresso possono essere scaricati per le route che non lo utilizzano, il che comporta il download di più codice del necessario. Ad esempio, quando page1 carica il blocco 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 e ne ha sostituito uno nuovo: SplitChunksPlugin.

Chunking migliorato

Le impostazioni predefinite di SplitChunksPlugin funzionano bene per la maggior parte degli utenti. Vengono creati più blocchi suddivisi in base a diverse conditions per impedire il recupero del codice duplicato su più route.

Tuttavia, molti framework web che utilizzano questo plug-in seguono ancora un approccio "singolo comune" alla suddivisione dei blocchi. Next.js, ad esempio, genererà un bundle commons contenente qualsiasi modulo utilizzato in più del 50% delle pagine e in 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 del codice dipendente dal framework in un blocco condiviso ne consenta il download e la memorizzazione nella cache per qualsiasi punto di ingresso, l'euristica basata sull'utilizzo che prevede l'inclusione di moduli comuni utilizzati in più di metà delle pagine non è molto efficace. La modifica di questo rapporto comporterebbe solo uno dei due risultati seguenti:

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

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

  • Qualsiasi modulo di terze parti sufficientemente grande (superiore a 160 kB) viene suddiviso in singoli blocchi
  • 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 per un blocco da generare viene 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ù blocchi condivisi, anziché uno solo, riduce al minimo la quantità di codice non necessario (o duplicato) per qualsiasi punto di ingresso.
  • Memorizzazione nella cache migliorata durante la navigazione. La suddivisione delle librerie e delle dipendenze del framework di grandi dimensioni in blocchi separati riduce la possibilità di invalidazione della cache, poiché è improbabile che entrambi cambino fino a quando non viene eseguito un upgrade.

Puoi vedere 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 usare un'unica strategia di bundle euristica e "comuni" per diversi motivi. Ciò include il timore che un numero maggiore di richieste HTTP possa influire negativamente sulle prestazioni del sito.

I browser possono aprire solo un numero limitato di connessioni TCP con 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 un'unica connessione su una singola origine. In altre parole, generalmente 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 mediante la suddivisione del singolo bundle "commons" di Next.js in più blocchi condivisi avrebbe influito in alcun modo sulle prestazioni di caricamento. Ha iniziato misurando le prestazioni di un singolo sito modificando il numero massimo di richieste parallele utilizzando la proprietà maxInitialRequests.

Prestazioni di caricamento della 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é uguali quando si variava il numero massimo di richieste iniziali (da 5 a 15). È interessante notare che abbiamo notato un leggero overhead del rendimento solo dopo aver suddiviso in modo aggressivo centinaia di richieste.

Prestazioni di caricamento pagina con centinaia di richieste

In questo modo è emerso che rimanere al di sotto di una soglia affidabile (20-25 richieste) ha trovato 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 in parallelo ha prodotto 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 maggiore chunking

Questo esperimento riguardava solo la modifica del numero di richieste per verificare se si verificasse 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é ha ridotto le dimensioni del payload JavaScript senza rallentare la pagina. La quantità totale di JavaScript necessaria per idratare la pagina è rimasta pressoché invariata, il che spiega perché le prestazioni di caricamento pagina non sono necessariamente migliorate con la quantità ridotta di codice.

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

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 compilazione lato server per determinare quali blocchi di output vengono utilizzati dai diversi punti di ingresso. Per fornire queste informazioni anche al client, è stato creato un file manifest della build lato client ridotto 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 più recente strategia di suddivisione in blocchi è stata implementata inizialmente in Next.js dietro un flag, dove è stata testata su un certo numero di primi utenti. Molti hanno registrato riduzioni significative del numero totale di JavaScript utilizzato per l'intero sito:

Sito web Variazione JS totale % 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)

Per impostazione predefinita, la versione finale è stata fornita nella versione 9.2.

Gatsby

Gatsby seguiva lo stesso approccio che prevedeva l'utilizzo di un'euristica basata sull'utilizzo per definire 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 del webpack in modo da adottare una strategia di suddivisione granulare simile, ha anche notato riduzioni significative di JavaScript in molti siti di grandi dimensioni:

Sito web Variazione 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%
Riduzioni delle dimensioni di JavaScript - in tutte le route (compresse)

Dai un'occhiata al PR per capire in che modo è stato implementato questa logica nella configurazione del webpack, che viene fornita per impostazione predefinita nella versione 2.20.7.

Conclusione

Il concetto di spedizione di blocchi granulari non è specifico per Next.js, Gatsby o nemmeno webpack. Tutti dovrebbero prendere in considerazione di migliorare la strategia di chunking della propria applicazione se segue un grande approccio per i bundle "commons", indipendentemente dal framework o dal bundler modulo utilizzato.

  • Se vuoi vedere le stesse ottimizzazioni di suddivisione applicate a un'applicazione vanilla React, dai un'occhiata a questa app React di esempio, che 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 raggruppamento, i blocchi vengono creati in modo granulare per impostazione predefinita. Dai un'occhiata a manualChunks per configurare manualmente il comportamento.