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

Une nouvelle stratégie de fragmentation webpack dans Next.js et Gatsby réduit le code en double pour améliorer les performances de chargement des pages.

Chrome collabore avec les outils et les frameworks de l'écosystème Open Source JavaScript. Un certain nombre de nouvelles optimisations ont récemment été ajoutées pour améliorer les performances de chargement de Next.js et de Gatsby. Cet article présente une stratégie de segmentation plus précise, 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 bundleur 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 seul (ou plusieurs) bloc (ou blocs) "communs". 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 avec de nombreux frameworks d'application monopage qui ont adopté une configuration de point d'entrée et de bundle semblable à celle-ci:

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

Bien que pratique, le concept de regroupement de tout le code de module partagé dans un seul bloc présente des limites. Les modules qui ne sont pas partagés dans chaque point d'entrée peuvent être téléchargés pour les routes qui ne les utilisent pas, ce qui entraîne le téléchargement de plus de code que nécessaire. Par exemple, lorsque page1 charge le segment common, il charge le code pour moduleC, même si page1 n'utilise pas moduleC. Pour cette raison, ainsi que pour quelques 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 segments de fractionnement sont créés en fonction d'un certain nombre de conditions pour éviter d'extraire du code en double sur plusieurs routes.

Cependant, de nombreux frameworks Web qui utilisent ce plug-in suivent toujours une approche "single-commons" pour le fractionnement de blocs. Par exemple, Next.js génère 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 consistant à inclure des modules courants utilisés dans plus de la moitié des pages n'est pas très efficace. Modifier ce ratio n'aura qu'un seul résultat:

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

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

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

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

  • Les temps de chargement des pages sont améliorés. L'émission de plusieurs segments partagés au lieu d'un seul réduit la quantité de code inutile (ou en double) pour n'importe quel point d'entrée.
  • Amélioration de la mise en cache lors des navigations Diviser de grandes bibliothèques et des dépendances de framework en segments distincts réduit la possibilité d'invalidation du cache, car il est peu probable que ces deux éléments changent avant une mise à niveau.

Vous pouvez voir l'ensemble de la configuration adoptée par Next.js dans webpack-config.ts.

Plus de requêtes HTTP

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

Les navigateurs ne peuvent ouvrir qu'un nombre limité de connexions TCP vers une seule origine (6 pour Chrome). Par conséquent, en réduisant le nombre de segments générés par un bundler, vous pouvez vous assurer que le nombre total de requêtes reste inférieur à 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 besoin de nous soucier de limiter le nombre de blocs émis par notre bundleur.

Tous les principaux navigateurs sont compatibles avec HTTP/2. Les équipes Chrome et Next.js voulaient savoir si l'augmentation du nombre de requêtes en divisant le seul bundle "commons" de Next.js en plusieurs segments partagés affecterait les performances de chargement. Il a 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 des pages avec un nombre de requêtes plus élevé

Sur une page Web unique, nous avons effectué en moyenne trois séries d'essais. Les temps load, start-render et First Contentful Paint sont restés à peu près les mêmes lorsque nous avons modifié le nombre maximal de requêtes initiales (de 5 à 15). Fait intéressant, nous n'avons constaté qu'un léger surcoût en termes de performances qu'après avoir divisé de manière agressive en centaines de requêtes.

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

Cela a montré que rester en dessous d'un seuil fiable (20 à 25 requêtes) permettait de trouver le bon équilibre entre les performances de chargement et l'efficacité du cache. Après quelques tests de référence, le nombre de maxInitialRequest a été sélectionné sur 25.

La modification du nombre maximal de requêtes effectuées en parallèle a entraîné la création de plusieurs bundles partagés. En les séparant de manière appropriée pour chaque point d'entrée, nous avons considérablement réduit la quantité de code inutile pour la même page.

Réductions de la charge utile JavaScript avec un fractionnement accru

Ce test ne visait qu'à modifier le nombre de requêtes pour voir s'il y avait un impact négatif sur les performances de chargement de la page. Les résultats suggèrent que la valeur optimale pour maxInitialRequests sur la page de test était 25, car elle a réduit la taille de la charge utile JavaScript sans ralentir la page. La quantité totale de code 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 n'ont pas nécessairement été améliorées avec la réduction de la quantité de code.

webpack utilise 30 Ko comme taille minimale par défaut pour la génération d'un bloc. Toutefois, associer une valeur maxInitialRequests de 25 à une taille minimale de 20 Ko a permis d'améliorer la mise en cache.

Réduction de la taille avec des fragments précis

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

Next.js utilise un fichier manifeste de compilation côté serveur pour déterminer les blocs de sortie utilisés par différents points d'entrée. Pour fournir ces informations au client également, un fichier manifeste de compilation côté client abrégée a été créé pour mapper toutes les dépendances pour 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 précise a été déployée pour la première fois dans Next.js derrière un indicateur, où elle a été testée sur un certain nombre d'utilisateurs précoces. De nombreux utilisateurs ont constaté une réduction significative du code JavaScript total utilisé pour l'ensemble de leur site:

Site Web Total de la modification 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 pour tous les parcours (compressés)

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

Gatsby

Gatsby suivait la même approche consistant à utiliser une heuristique basée sur l'utilisation pour définir des modules courants:

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 segmentation granulaire similaire, ils ont également constaté des réductions importantes du code JavaScript sur de nombreux grands sites:

Site Web Total de la modification 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 pour tous les parcours (compressés)

Consultez la PR pour comprendre comment cette logique a été implémentée dans la configuration webpack, qui est fournie par défaut dans la version 2.20.7.

Conclusion

Le concept d'envoi de blocs précis n'est pas propre à Next.js, Gatsby ni même à webpack. Tout le monde devrait envisager d'améliorer la stratégie de segmentation de son application si elle suit une approche de gros bundle "communs", quel que soit le framework ou le bundleur de modules utilisé.

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