Accueil / Articles / Analyser la construction lente d’un monorepo Webpack avant de recourir à un nouvel outil de bundling

Analyser la construction lente d’un monorepo Webpack avant de recourir à un nouvel outil de bundling

Comment l’analyse de la construction d’un micro-frontend Webpack d’une durée de 20 minutes a révélé l’utilisation de Terser et Babel, des installations ainsi que du cache Docker, et quels correctifs ont permis de réduire ce temps à environ deux minutes.

2078 mots

Lorsque les temps de compilation du frontend ralentissent, on a tendance à blâmer le bundler et à envisager une migration. Cette réaction est souvent erronée. Cet étude de cas porte sur une plateforme micro-frontend dont les temps de compilation avec Webpack dépassaient 20 minutes ; l’analyse a montré que le bundler lui-même n’était pas la cause du problème, et une série de modifications ciblées a permis de réduire ces temps à environ 2 minutes sans remplacer Webpack. Vous découvrirez comment identifier le véritable goulot d’étranglement, quels outils basés sur Rust ont remplacé quelles étapes de la compilation, et pourquoi l’installation des dépendances, les tokens de registre ainsi que le layering Docker étaient aussi importants que n’importe quel chargeur.

Lorsque le temps de compilation devient un problème pour la plateforme

La plateforme en question a reçu plus de 600 demandes de fusion par mois de la part de plus de dix ingénieurs, ce qui a entraîné plus de 5 000 exécutions de CI. Avec 20 minutes par build, cela représente plus de 1 600 heures d’exécutions de CI par mois. À un tel volume, les builds lents cessent d’être un simple désagrément pour devenir un goulot d’étranglement pour toute l’organisation : les retours arrivent en retard, les files d’attente de CI s’allongent, et les correctifs urgents en production attendent leur tour.

La structure du monorepo a aggravé la situation :

20 production apps: React and Next.js applications
7 shared libraries
1 centralized E2E test suite
~3,950 TSX/JSX files and 6,600+ TypeScript source files
~350 reusable component modules
144 npm dependencies (74 production, 70 development)
Workspace: single hoisted monorepo
Team: 10+ engineers, 600+ PRs/month
CI: 5,000+ build runs/month

Vingt applications partageant sept bibliothèques dans un seul espace de travail signifie que toute modification des outils affecte tout en même temps.

Pourquoi le profilage a précédé la migration

Changer de outil de bundling dans 20 applications en production, 7 bibliothèques partagées et 144 dépendances est un projet à haut risque. Une régression dans l’une des bibliothèques partagées peut avoir des conséquences sur toutes les applications, et la vérification à nouveau de tous les résultats de compilation pourrait prendre des semaines sans garantie de succès. L’équipe a donc d’abord analysé l’ensemble du processus. Cette étude a révélé des coûts importants liés à d’autres éléments que l’outil de bundling lui-même, tels que la vérification des types, l’orchestration des tests et la résolution des dépendances, domaines dans lesquels un nouvel outil de bundling n’aurait apporté aucune amélioration.

La leçon générale est bien connue dans tout travail de performance : optimiser avant de mesurer transforme chaque changement en une simple supposition. La compilation avec Webpack passe par plusieurs phases distinctes avant de produire un résultat final :

  • compilation des modules
  • exécution des chargeurs
  • génération des blocs de code
  • optimisation des ressources
  • minification
  • émission des ressources

Sans des délais pour chaque phase, il est impossible de savoir laquelle mérite une attention particulière ; par conséquent, rien dans la configuration du chargeur ou du plugin n’a été modifié tant que ces données n’étaient pas disponibles.

Recherche du point critique avec ProgressPlugin

Webpack inclut déjà le ProgressPlugin, qui ne nécessite aucune installation supplémentaire. Vous devez indiquer l’existence de Webpack dans le fichier de configuration :

const webpack = require('webpack');

et ajouter ce plugin à l’array plugins :

plugins: [
  new webpack.ProgressPlugin()
]

Avec les résultats d’analyse activés, une ligne a immédiatement attiré l’attention :

[webpack] 92% sealing > asset processing
TerserPlugin took 825.31s

Un seul plugin consommait plus de 13 minutes. Par rapport à ses voisins lors de l’étape d’optimisation, ce déséquilibre est frappant :

copy-webpack-plugin      30ms
WriteIndexHtmlPlugin     22ms
RealContentHashPlugin    58ms
CompressionPlugin        70ms
LicenseWebpackPlugin     1.15s
TerserPlugin             825.31s

Tous les autres plugins se sont exécutés en quelques millisecondes, ou, dans le cas du plugin de licence, en environ une seconde. Le bundler n’était pas lent ; c’est la minification du JavaScript avec Terser qui l’était. Cette seule observation a redirigé tous les efforts.

Temps d’exécution au niveau du chargeur avec le Speed Measure Plugin

ProgressPlugin indique quelle étape est lente, mais pas quelle chaîne de chargeurs au sein de la compilation en est responsable. Pour cela, l’équipe a ajouté speed-measure-webpack-plugin, qui enrobe la configuration exportée :

const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap(config);

SMP indique le temps que chaque chaîne de chargement passe à traiter les modules, ce qui a permis de comparer le processus avant et après l’introduction de SWC. Il a également fourni une évaluation honnête de la situation réelle. La chaîne CSS composée de mini-css-extract-plugin, css-loader et postcss-loader n’a pas gagné en vitesse ; elle est même passée légèrement de 8,66 à 9,36 secondes. Le CSS et les modules non gérés par aucun chargeur représentaient près de 16 secondes sur les 20,78 secondes restantes du temps total de traitement. Plutôt que de débattre des préférences en matière d’outils, l’équipe disposait de chiffres concrets indiquant où effectuer les prochains ajustements.

Un point important à connaître : SMP enrobe les plugins et chargeurs afin de mesurer leur temps d’exécution, et il est connu pour entrer en conflit avec certains plugins plus récents ; il convient donc de l’utiliser uniquement pour des tests diagnostiques et non dans la configuration de production.

Objectifs qui ont guidé chaque décision

Au préalable de toute modification, l’équipe a listé les objectifs en trois catégories.

Prestations de compilation

  • Réduire la latence de compilation à froid.
  • Accélérer les compilations incrémentielles.
  • Réduire le temps consacré à la transpilation et à la minification.
  • Mieux exploiter les cœurs CPU disponibles.

Efficacité du CI

  • Reutiliser davantage les couches de cache Docker.
  • Raccourcir le temps d’installation des dépendances.
  • Obtenir des pipelines plus déterministes.

Stabilité de la plateforme

  • Préserver la stabilité des outils à l’échelle enterprise.
  • Éviter les migrations risquées.
  • Rester compatible avec l’écosystème existant.
  • Équilibrer la vitesse brute et la maintenabilité à long terme.

Pourquoi Vite et Rspack ont été mis de côté

Vite a fait l’objet d’une évaluation approfondie. Il s’agit d’un logiciel excellent qui constitue souvent le choix idéal pour des projets nouveaux ou plus simples. La différence clé résidait dans le fait qu’il a été testé contre ce pipeline réel plutôt que contre les petites démonstrations présentes dans de nombreux guides de migration. La conclusion était que le coût majeur, à savoir la minification, est indépendant du outil de bundling : passer à Vite aurait pris environ quatre semaines et laissé l’équipe confrontée au même goulot d’étranglement, avec en plus des incompatibilités liées à un nouveau format de configuration et aux plugins à gérer. Le registre des décisions a indiqué que Vite devrait être réexaminé si la compilation devenait un jour le principal obstacle.

Une nuance importante : Vite minimise le JavaScript par défaut avec esbuild et non Terser, donc une migration aurait en pratique changé le minificateur également. Cela renforce plutôt que d’affaiblir le point essentiel. Le facteur déterminant était le choix du minificateur, et Webpack peut utiliser le même minificateur rapide sans aucune migration.

Rspack, un bundler basé sur Rust disposant d’une configuration compatible avec Webpack, a également été envisagé et semblait prometteur. À l’époque, des incidents de sécurité récents ainsi qu’un écosystème encore en développement ont poussé l’équipe à la prudence, ce qui a conduit à le garder sur la liste de surveillance pour une réévaluation ultérieure. Vérifiez son état actuel avant de tirer vos propres conclusions.

Réconstruction des étapes les plus lentes

Avec la stratégie définie, les efforts se sont concentrés sur le remplacement progressif des composants les plus lents.

SWC au lieu de Babel pour la transpilation

Le transpilation de JavaScript et TypeScript représentait l’un des coûts les plus importants qui subsistaient. Babel a été remplacé par SWC, un compilateur basé sur Rust conçu pour des transformations de JS et TS à haute vitesse, intégré via swc-loader et précédé par thread-loader:

{
  test: /\.(jsx?|tsx?)$/,
  use: [
    'thread-loader',
    {
      loader: 'swc-loader'
    }
  ]
}

Les benchmarks publics montrent que SWC effectue la transpilation bien plus rapidement que Babel, et dans ce pipeline le changement a permis une transpilation plus rapide, un travail en parallèle grâce à thread-loader, moins de blocage du processus principal et des exécutions CI plus courtes. L’entrée swc-loader ici dépend d’un fichier .swcrc ou d’options intégrées pour les paramètres du parseur et de JSX ; sans eux, la syntaxe TypeScript et JSX ne pourra pas être analysée.

esbuild à la place de Terser pour la minification

Le profil avait déjà identifié le principal responsable, donc la minification de JavaScript a été transférée à esbuild grâce au plugin esbuild-loader:

new EsbuildPlugin({
  target: 'es2015',
  minify: true
})

Le choix a été guidé par le projet minification-benchmarks, qui compare esbuild, terser, swc, uglify-js et d’autres en termes de taille après minification, de taille après compression gzip et de temps de traitement. Selon ces données :

  • les résultats d’esbuild après minification et compression gzip étaient compétitifs, légèrement supérieurs au meilleur résultat mais restant dans une fourchette de 5 à 8 pour cent ;
  • esbuild a mis environ 295 ms contre environ 6,7 secondes pour terser sur le même fichier d’entrée, un écart qui s’accroît dans un monorepo ;
  • @swc/core et oxc-minify produisaient des résultats plus petits, mais leurs compromis en termes de vitesse et les difficultés d’intégration ont fait pencher la décision en faveur d’esbuild.

L’option target: 'es2015' indique à esbuild quelle syntaxe il peut utiliser ; assurez-vous qu’elle corresponde au niveau de support réel de votre navigateur, car un cible plus récent lui permet de générer du code plus court.

LightningCSS pour la minification CSS

La minification CSS a été remplacée par LightningCSS, développé par l’équipe de Parcel, et intégré dans css-minimizer-webpack-plugin en tant que fonction de minification :

new CssMinimizerPlugin({
  minify: CssMinimizerPlugin.lightningCssMinify,
})

Les bénéfices de LightningCSS le comparent au minificateur CSS de cssnano et d’esbuild. Ils montrent qu’il est le plus rapide dans chaque scénario testé, avec des fichiers de sortie nettement plus petits et des gains particulièrement importants pour de gros fichiers CSS tels que ceux générés par Tailwind. Dans un pipeline CI où l’optimisation du CSS influence directement la latence globale, une meilleure compression associée à un temps de traitement réduit en fait le choix évident. Avec ces améliorations des minificateurs, la minification du JavaScript est devenue environ 10 fois plus rapide et l’optimisation du CSS environ 6 fois plus rapide.

Utilisation de chaque cœur CPU

Les agents CI disposent généralement de plusieurs cœurs, mais de nombreux pipelines laissent la plupart d’entre eux inutilisés. L’ajout de thread-loader avant les chargeurs gourmands permet de répartir ce travail sur plusieurs workers :

use: ['thread-loader', 'swc-loader']

Cela a réduit les goulots d’étranglement liés aux compilations lentes, amélioré l’utilisation des ressources et augmenté la productivité du CI. Il convient de garder à l’esprit que chaque travailleur implique des coûts liés au démarrage et à l’échange de messages ; pour un chargeur déjà rapide comme SWC sur un petit projet, les travailleurs peuvent coûter plus qu’ils ne permettent d’économiser, il est donc nécessaire de mesurer les performances avec et sans eux.

Installations plus rapides et plus déterministes avec pnpm

La compilation n’était pas le seul facteur coûteux ; l’installation des dépendances consommait également du temps dans le cadre du CI. L’équipe a comparé npm, yarn, pnpm et bun en utilisant des benchmarks publics portant sur la vitesse d’installation, l’utilisation du disque et les modèles de résolution. Bun s’est montré performant dans les tests synthétiques, mais pnpm a pris le dessus en termes de maturité de son écosystème, de compatibilité avec Node.js et d’expérience dans les grands systèmes en production.

Lorsque npm copie les paquets dans chaque répertoire node_modules, pnpm conserve un stockage global adressable par contenu : chaque version de paquet est téléchargée une seule fois et liée de manière fixe aux projets. Cela présente plusieurs avantages :

  • les installations sont plus rapides car les paquets sont récupérés une seule fois puis réutilisés ;
  • l’utilisation disque diminue car les espaces de travail ne dupliquent plus les mêmes paquets ;
  • la résolution stricte empêche les dépendances fantômes, c’est-à-dire les paquets que votre code importe sans les déclarer, ce qui rend les builds plus déterministes ;
  • le cache des couches Docker s’améliore car le stockage peut être réutilisé entre différentes builds.

Dans Docker, un montage du cache BuildKit permet de conserver le stockage pnpm entre les builds, tandis que l’option --frozen-lockfile fait échouer l’installation si le fichier de verrouillage est obsolète :

RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

Un détail d’authentification qui a perturbé le cache

L’une des solutions les plus efficaces n’avait rien à voir avec les outils front-end. Les paquets provenaient d’AWS CodeArtifact, dont les tokens d’autorisation expirent après 12 heures. La récupération répétée de ces tokens au sein du pipeline entraînait l’invalidation du cache, des tentatives d’authentification répétées ainsi que des problèmes avec les couches Docker, car une valeur changeante introduite dans une couche modifiait la clé de cache correspondante.

La solution consistait à demander le token une seule fois par job Jenkins et à le réutiliser pour chaque étape :

export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
  --domain <domain> \
  --domain-owner <owner> \
  --query authorizationToken \
  --output text)

Cela a permis une meilleure réutilisation des couches, moins d’installations redondantes et des pipelines plus stables. Si vous transmettez un tel token lors d’une construction Docker, préférez un montage de secret plutôt qu’un argument de construction afin qu’il ne figure pas dans l’historique de l’image et n’invalide pas le cache.

Organisation des Dockerfiles en couches pour des accès au cache plus rapides

Finalement, les Dockerfiles ont été réorganisés en couches classées du moins au plus sujet à des changements fréquents :

  1. le temps d’exécution de base ;
  2. l’installation des dépendances ;
  3. la copie du code source ;
  4. la compilation avec Webpack.

En combinant --mount=type=cache pour le stockage des packages et --mount=type=secret pour les identifiants, cet ordre de traitement permettait de ne recompiler que les deux dernières étapes lors d’un changement de code, augmentant ainsi considérablement la réutilisation du cache.

Points clés

  • Considérez la vitesse de compilation comme un problème système. Ici, les meilleures améliorations proviennent du minificateur, des installations, des identifiants et de l’organisation en couches Docker, et non du bundler.
  • Analysiez d’abord les performances. Le ProgressPlugin a permis de réduire en quelques minutes une étape de minification avec Terser qui durait initialement 13 minutes ; une migration aléatoire aurait nécessité des semaines.
  • Les outils basés sur Rust tels que SWC, esbuild et LightningCSS ont un effet cumulatif : chacun réduit une partie distincte de la latence.
  • pnpm ainsi qu’un cache Docker bien géré améliorent à la fois le déterminisme et la vitesse.
  • Tout ce qui change à chaque exécution, même un jeton d’authentification, peut détruire silencieusement le cache.
  • Gardez les migrations en tête, mais conditionnez-les aux données. En modernisant chaque étape individuellement, cette plateforme est passée de plus de 20 minutes à environ 2 minutes tout en préservant son écosystème.