Amélioration des performances de chargement des pages Next.js et Gatsby grâce à la segmentation précise

Une stratégie de découpage webpack plus récente dans Next.js et Gatsby minimise le code en double pour améliorer les performances de chargement des pages.

Chrome collabore avec des outils et des frameworks de l'écosystème Open Source JavaScript. Un certain nombre d'optimisations plus récentes ont été ajoutées récemment pour améliorer les performances de chargement de Next.js et Gatsby. Cet article présente une stratégie de segmentation granulaire améliorée qui est désormais fournie par défaut dans les deux frameworks.

Introduction

Comme de nombreux frameworks Web, Next.js et Gatsby utilisent webpack comme bundler principal. webpack v3 a introduit CommonsChunkPlugin pour permettre de générer des modules partagés entre différents points d'entrée dans un ou plusieurs blocs "commons". Le code partagé peut être téléchargé séparément et stocké dans le cache du navigateur dès le début, ce qui peut améliorer les performances de chargement.

Ce modèle est devenu populaire auprès de nombreux frameworks d'applications monopages qui ont adopté une configuration de point d'entrée et de bundle ressemblant à ceci :

Configuration courante du point d'entrée et du bundle

Bien que pratique, le concept de regroupement de tout le code de module partagé dans un seul bloc présente des limites. Les modules non partagés dans chaque point d'entrée peuvent être téléchargés pour les routes qui ne l'utilisent pas, ce qui entraîne le téléchargement de plus de code que nécessaire. Par exemple, lorsque page1 charge le bloc common, il charge le code pour moduleC même si page1 n'utilise pas moduleC. C'est pourquoi, entre autres, webpack v4 a supprimé le plug-in au profit d'un nouveau : SplitChunksPlugin.

Amélioration du découpage

Les paramètres par défaut de SplitChunksPlugin conviennent à la plupart des utilisateurs. Plusieurs blocs fractionnés sont créés en fonction d'un certain nombre de conditions pour éviter de récupérer du code en double sur plusieurs routes.

Toutefois, de nombreux frameworks Web qui utilisent ce plug-in suivent toujours une approche "single-commons" pour le fractionnement des blocs. Next.js, par exemple, générerait un bundle commons contenant tous les modules utilisés dans plus de 50 % des pages et toutes les dépendances du framework (react, react-dom, etc.).

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)[\\/]/,
      },
    },
  },

Bien que l'inclusion de code dépendant du framework dans un bloc partagé signifie qu'il peut être téléchargé et mis en cache pour n'importe quel point d'entrée, l'heuristique basée sur l'utilisation qui consiste à inclure les modules courants utilisés dans plus de la moitié des pages n'est pas très efficace. Modifier ce ratio n'aurait que deux conséquences possibles :

  • Si vous réduisez le ratio, davantage de code inutile sera téléchargé.
  • Si vous augmentez le ratio, davantage de code sera dupliqué sur plusieurs routes.

Pour résoudre ce problème, Next.js a adopté une configuration différente pourSplitChunksPlugin qui réduit le code inutile pour n'importe quelle route.

  • Tout module tiers suffisamment volumineux (supérieur à 160 Ko) est divisé en son propre bloc individuel.
  • Un bloc frameworks distinct est créé pour les dépendances du framework (react, react-dom, etc.).
  • Autant de blocs partagés que nécessaire sont créés (jusqu'à 25).
  • La taille minimale d'un bloc à générer est désormais de 20 Ko.

Cette stratégie de segmentation précise présente les avantages suivants :

  • Le temps de chargement des pages a été amélioré. L'émission de plusieurs blocs partagés, au lieu d'un seul, minimise la quantité de code inutile (ou en double) pour tout point d'entrée.
  • Amélioration de la mise en cache lors des navigations. La division des grandes bibliothèques et des dépendances du framework en blocs distincts réduit le risque d'invalidation du cache, car il est peu probable que les deux changent avant une mise à niveau.

Vous pouvez consulter l'intégralité de la configuration adoptée par Next.js dans webpack-config.ts.

Plus de requêtes HTTP

SplitChunksPlugin a défini la base du chunking précis, et l'application de cette approche à un framework comme Next.js n'était pas un concept entièrement nouveau. Toutefois, de nombreux frameworks ont continué à utiliser une stratégie de bundle heuristique et "commons" unique pour plusieurs raisons. Cela inclut la crainte que de nombreuses requêtes HTTP supplémentaires puissent nuire aux performances du site.

Les navigateurs ne peuvent ouvrir qu'un nombre limité de connexions TCP à une seule origine (6 pour Chrome). Par conséquent, en minimisant le nombre de blocs générés par un bundler, vous pouvez vous assurer que le nombre total de requêtes reste en dessous de ce seuil. Toutefois, cela n'est vrai que pour HTTP/1.1. Le multiplexage dans HTTP/2 permet de diffuser plusieurs requêtes en parallèle à l'aide d'une seule connexion sur une seule origine. En d'autres termes, nous n'avons généralement pas à nous soucier de limiter le nombre de blocs émis par notre outil de regroupement.

Tous les principaux navigateurs sont compatibles avec HTTP/2. Les équipes Chrome et Next.js ont voulu voir si l'augmentation du nombre de requêtes en divisant le bundle "commons" unique de Next.js en plusieurs blocs partagés affecterait les performances de chargement. Ils ont commencé par mesurer les performances d'un seul site tout en modifiant le nombre maximal de requêtes parallèles à l'aide de la propriété maxInitialRequests.

Performances de chargement de page avec un nombre de requêtes accru

Dans une moyenne de trois exécutions de plusieurs essais sur une même page Web, les temps load, start-render et First Contentful Paint sont restés à peu près les mêmes lorsque le nombre maximal de requêtes initiales a varié (de 5 à 15). Il est intéressant de noter que nous n'avons constaté qu'une légère surcharge de performances qu'après avoir divisé de manière agressive les requêtes en centaines.

Performances de chargement de page avec des centaines de requêtes

Cela a montré que le maintien sous un seuil fiable (20 à 25 requêtes) permettait de trouver le juste équilibre entre les performances de chargement et l'efficacité de la mise en cache. Après quelques tests de référence, 25 a été sélectionné comme nombre maxInitialRequest.

La modification du nombre maximal de requêtes parallèles a entraîné la création de plusieurs bundles partagés. La séparation appropriée de ces bundles pour chaque point d'entrée a considérablement réduit la quantité de code inutile pour la même page.

Réductions de la charge utile JavaScript avec une segmentation accrue

Ce test consistait uniquement à modifier le nombre de requêtes pour voir si cela aurait un impact négatif sur les performances de chargement des pages. Les résultats suggèrent que la définition de maxInitialRequests sur 25 sur la page de test était optimale, car elle a réduit la taille de la charge utile JavaScript sans ralentir la page. La quantité totale de JavaScript nécessaire pour hydrater la page est restée à peu près la même, ce qui explique pourquoi les performances de chargement de la page ne se sont pas nécessairement améliorées avec la réduction de la quantité de code.

webpack utilise 30 Ko comme taille minimale par défaut pour générer un bloc. Toutefois, l'association d'une valeur maxInitialRequests de 25 à une taille minimale de 20 Ko a permis d'obtenir une meilleure mise en cache.

Réductions de taille avec des blocs granulaires

De nombreux frameworks, y compris Next.js, s'appuient sur le routage côté client (géré par JavaScript) pour injecter des balises de script plus récentes à chaque transition de route. Mais comment prédéterminent-ils ces blocs dynamiques au moment de la compilation ?

Next.js utilise un fichier manifeste de compilation côté serveur pour déterminer quels blocs de sortie sont utilisés par différents points d'entrée. Pour fournir également ces informations au client, un fichier manifeste de compilation côté client abrégé a été créé pour mapper toutes les dépendances de chaque point d'entrée.

// 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}`)) || []
  )
}
Sortie de plusieurs blocs partagés dans une application Next.js.

Cette nouvelle stratégie de segmentation granulaire a d'abord été déployée dans Next.js derrière un indicateur, où elle a été testée par un certain nombre de premiers utilisateurs. Beaucoup ont constaté une réduction significative du code JavaScript total utilisé pour l'ensemble de leur site :

Site Web Évolution totale du code JS Différence (en %)
https://www.barnebys.com/ -238 Ko -23 %
https://sumup.com/ -220 Ko -30 %
https://www.hashicorp.com/ -11 Mo -71 %
Réduction de la taille des fichiers JavaScript sur toutes les routes (compressés)

La version finale est fournie par défaut dans la version 9.2.

Gatsby

Gatsby suivait la même approche en utilisant une heuristique basée sur l'utilisation pour définir les modules communs :

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)[\\/]/,
      },

En optimisant leur configuration webpack pour adopter une stratégie de découpage granulaire similaire, ils ont également constaté des réductions importantes de JavaScript sur de nombreux grands sites :

Site Web Évolution totale du code JS Différence (en %)
https://www.gatsbyjs.org/ -680 Ko -22 %
https://www.thirdandgrove.com/ -390 Ko -25 %
https://ghost.org/ -1,1 Mo -35 %
https://reactjs.org/ -80 Ko - 8 %
Réduction de la taille des fichiers JavaScript sur toutes les routes (compressés)

Consultez la demande d'extraction pour comprendre comment ils ont implémenté cette logique dans leur configuration webpack, qui est fournie par défaut dans la version 2.20.7.

Conclusion

Le concept d'envoi de blocs granulaires n'est pas spécifique à Next.js, Gatsby ni même webpack. Tout le monde devrait envisager d'améliorer la stratégie de découpage de son application si elle suit une approche de bundle "commons" volumineux, quel que soit le framework ou le module bundler utilisé.

  • Si vous souhaitez voir les mêmes optimisations de découpage appliquées à une application React vanilla, consultez cet exemple d'application React. Il utilise une version simplifiée de la stratégie de découpage précis et peut vous aider à commencer à appliquer le même type de logique à votre site.
  • Pour Rollup, les blocs sont créés de manière granulaire par défaut. Consultez manualChunks si vous souhaitez configurer manuellement le comportement.