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 minimise 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 d'optimisations plus récentes ont été récemment ajoutées pour améliorer les performances de chargement de Next.js et Gatsby. Cet article présente une stratégie de fragmentation plus précise qui est désormais disponible 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 la création de modules de sortie partagés entre différents points d'entrée dans un seul fragment (ou fragment) "commun". Le code partagé peut être téléchargé séparément et stocké tôt dans le cache du navigateur, ce qui peut améliorer les performances de chargement.

Ce modèle est devenu populaire auprès de nombreux frameworks d'application monopage adoptant une configuration de point d'entrée et de bundle semblable à ceci:

Configuration commune des points d'entrée et des lots

Bien que pratique, le concept de regroupement de tout le code de module partagé en un seul fragment a ses 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 fragment common, il charge le code pour moduleC même si page1 n'utilise pas moduleC. Pour cette raison, comme quelques autres, Webpack v4 a supprimé le plug-in au profit d'un nouveau: SplitChunksPlugin.

Fractionnement améliorée

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

Toutefois, de nombreux frameworks Web qui utilisent ce plug-in suivent toujours une approche de type "single-common" en matière de division des fragments. Next.js, par exemple, génère un bundle commons contenant tout module utilisé 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. Si vous modifiez ce ratio, vous n'obtiendrez que l'un des deux résultats suivants:

  • Si vous réduisez ce ratio, davantage de code inutile est téléchargé.
  • Si vous augmentez ce ratio, davantage de code sera 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 toute route.

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

Cette stratégie de fragmentation précise offre les avantages suivants:

  • Le temps de chargement des pages a été amélioré. L'émission de plusieurs fragments partagés, au lieu d'un seul, réduit la quantité de code inutile (ou dupliqué) pour tout point d'entrée.
  • Amélioration de la mise en cache pendant la navigation : La division des bibliothèques volumineuses et des dépendances de framework en fragments distincts réduit le risque d'invalidation du cache, car elles sont peu susceptibles de changer avant une mise à niveau.

Vous pouvez voir 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 d'une segmentation précise, et l'application de cette approche à un framework comme Next.js n'était pas un concept entièrement nouveau. Toutefois, de nombreux frameworks continuent à utiliser une seule stratégie de groupe heuristique et "communs" pour plusieurs raisons. Par exemple, un grand nombre de requêtes HTTP peuvent avoir un impact négatif sur les performances du site.

Les navigateurs ne peuvent ouvrir qu'un nombre limité de connexions TCP à une seule origine (six pour Chrome). Par conséquent, en réduisant le nombre de fragments 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 en 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 fragments émis par notre bundler.

Tous les principaux navigateurs sont compatibles avec le protocole HTTP/2. Les équipes Chrome et Next.js souhaitaient savoir si l'augmentation du nombre de requêtes en divisant le seul bundle "commons" de Next.js en plusieurs fragments partagés affecte les performances de chargement. L'équipe 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 augmentation du nombre de demandes

En moyenne, en trois exécutions de plusieurs essais sur une même page Web, les durées load, start-render et First Contentful Paint sont toutes restées à peu près identiques lorsque le nombre maximal de requêtes initiales est différent (de 5 à 15). Il est intéressant de noter que nous n'avons constaté une légère baisse des performances qu'après une division agressive en centaines de requêtes.

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

Cela a montré que le fait de rester en dessous d'un seuil fiable (20 à 25 requêtes) permet de trouver le bon é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 valeur de maxInitialRequest.

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

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

L'objectif de ce test était simplement de modifier le nombre de requêtes afin de déterminer s'il y avait un effet négatif sur les performances de chargement des pages. Les résultats suggèrent que définir maxInitialRequests sur 25 sur la page de test était optimal, car cela 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 la quantité de code réduite ne s'améliore pas nécessairement.

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

Réduire la taille avec des fragments granulaires

De nombreux frameworks, y compris Next.js, s'appuient sur le routage côté client (géré par JavaScript) pour injecter des tags de script plus récents pour 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 fragments de sortie utilisés par différents points d'entrée. Afin de fournir également ces informations au client, un fichier manifeste de compilation abrégé côté client a été créé afin de 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 fragments partagés dans une application Next.js

Cette nouvelle stratégie de segmentation précise a d'abord été déployée dans Next.js derrière un indicateur, puis testée auprès d'un certain nombre d'utilisateurs de la première heure. De nombreuses personnes ont constaté une réduction significative de la quantité totale de code JavaScript utilisée pour l'ensemble de leur site:

Site Web Total des modifications 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éductions de taille JavaScript sur toutes les routes (compressée)

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

Gatsby

Gatsby suivait la même approche, qui consistait à 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 sa configuration Webpack afin d'adopter une stratégie de segmentation granulaire similaire, elle a également constaté des réductions importantes concernant JavaScript sur de nombreux sites volumineux:

Site Web Total des modifications 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éductions de taille JavaScript sur toutes les routes (compressée)

Consultez la PR pour comprendre comment l'équipe a mis en œuvre cette logique dans sa configuration Webpack, fournie par défaut dans la version 2.20.7.

Conclusion

Le concept d'expédition de fragments précis n'est pas spécifique à Next.js, à Gatsby ni même à Webpack. Tout le monde devrait envisager d'améliorer la stratégie de fragmentation de son application si elle suit une approche groupée de grands groupes, quel que soit le framework ou le bundler de module utilisé.

  • Si vous souhaitez que les mêmes optimisations de segmentation soient appliquées à une application React standard, consultez cet exemple d'application React. Elle utilise une version simplifiée de la stratégie de segmentation précise et peut vous aider à appliquer le même type de logique à votre site.
  • Dans le cas de la propriété de consolidation, les fragments sont créés de façon précise par défaut. Consultez manualChunks si vous souhaitez configurer manuellement le comportement.