Angular : réduire la taille des bundles JavaScript

Front-end Mezgani said
Angular Performance Bundle Lazy Loading Optimisation
Angular : réduire la taille des bundles JavaScript

Réduisez les bundles Angular avec source-map-explorer, lazy loading, @defer et tree-shaking : analysez, identifiez les goulots et optimisez pas à pas.

Comprendre les bundles Angular

Quand vous lancez ng build, Angular CLI compile votre code TypeScript et toutes ses dépendances en fichiers JavaScript appelés bundles. Ce sont ces fichiers que le navigateur télécharge quand un utilisateur ouvre votre application.

Un bundle trop lourd a des conséquences directes sur l'expérience utilisateur :

Taille du bundle principal Temps de chargement (4G) Impact utilisateur
< 150 Ko gzippé ~0,5s Excellent — LCP optimal
150 – 300 Ko gzippé ~1–2s Acceptable — à surveiller
300 – 600 Ko gzippé ~2–4s Problématique — 20% d'abandons
> 600 Ko gzippé > 4s Critique — 50%+ d'abandons mobile

Anatomie des fichiers générés

Après un ng build --configuration=production, Angular génère plusieurs fichiers dans le dossier dist/ :

dist/ma-boutique/browser/
├── main-ABCD1234.js          ← code de votre application (le plus gros)
├── polyfills-EFGH5678.js     ← compatibilité navigateurs anciens
├── chunk-XYZ9.js             ← chunk lazy-loadé (une route ou un composant)
├── chunk-ABC0.js             ← autre chunk lazy-loadé
├── styles-IJKL9012.css       ← styles compilés
└── index.html                ← point d'entrée

# Tailles typiques d'une app Angular e-commerce (avant optimisation)
# main.js         → 850 Ko brut / 220 Ko gzippé  ← trop gros
# polyfills.js    → 35 Ko brut / 11 Ko gzippé    ← normal
# styles.css      → 120 Ko brut / 22 Ko gzippé   ← acceptable

Les 5 coupables les plus fréquents

Causes principales d'un bundle gonflé :
  1. Librairies importées en entierimport * as _ from 'lodash' embarque 530 Ko pour utiliser 3 fonctions
  2. Pas de lazy loading — toutes les pages chargées au démarrage, même celles jamais visitées
  3. Composants tiers lourds — éditeurs WYSIWYG, lecteurs PDF, graphiques complexes chargés immédiatement
  4. Bibliothèques d'icônes complètes — charger Font Awesome entier pour 10 icônes
  5. Moment.js — embarque toutes les locales (400 Ko) même si vous n'utilisez que le français

Analyser avec source-map-explorer

Avant d'optimiser quoi que ce soit, mesurez. Il est inutile de passer des heures à optimiser une librairie qui ne représente que 2 Ko si une autre en occupe 200 Ko. source-map-explorer génère une carte visuelle de votre bundle.

Installation et utilisation

# Installer source-map-explorer comme dépendance de développement
npm install --save-dev source-map-explorer

# Ajouter un script dans package.json pour faciliter l'analyse
# "scripts": {
#   "analyze": "source-map-explorer 'dist/ma-boutique/browser/*.js'"
# }
# Étape 1 : Construire avec les source maps activées
ng build --configuration=production --source-map

# Étape 2 : Lancer l'analyse (ouvre un rapport HTML dans le navigateur)
npm run analyze

# Alternative avec npx (sans installation globale)
npx source-map-explorer 'dist/ma-boutique/browser/main-*.js'

Lire le rapport

Le rapport affiche un treemap : chaque rectangle représente un fichier ou une librairie, sa taille étant proportionnelle à la place occupée dans le bundle. Voici comment interpréter les résultats sur une app e-commerce typique :

/* Exemple de rapport source-map-explorer — app boutique en ligne */

main-ABCD1234.js (850 Ko total)
├── node_modules/
│   ├── @angular/          → 180 Ko  ✓ normal (le framework)
│   ├── moment/            → 230 Ko  ✗ TROP GROS — remplacer par date-fns
│   ├── lodash/            → 95 Ko   ✗ importer uniquement les fonctions utilisées
│   ├── chart.js/          → 180 Ko  ✗ charger en lazy ou utiliser une alternative
│   └── rxjs/              → 45 Ko   ✓ acceptable
└── src/
    ├── app/features/      → 60 Ko   ✓ votre code métier
    ├── app/shared/        → 30 Ko   ✓ composants partagés
    └── environments/      → 2 Ko    ✓ config

/* Plan d'action déduit de l'analyse :
   1. Remplacer moment → date-fns (gain : -210 Ko)
   2. Importer lodash par fonction → lodash-es (gain : -80 Ko)
   3. Lazy-loader chart.js → @defer (gain : -180 Ko sur le bundle principal)
   Gain total estimé : -470 Ko = bundle principal réduit de 55% */
Générer le rapport régulièrement : Intégrez npm run analyze à votre processus de revue de code. Une PR qui ajoute 50 Ko sans justification devrait être questionnée. Certaines équipes fixent un seuil dans leur CI : si le bundle dépasse X Ko, le build échoue.

Lazy loading : routes et composants

Le lazy loading est la technique la plus impactante pour réduire le bundle initial. L'idée est simple : ne charger le code d'une page que quand l'utilisateur navigue vers elle.

Avant le lazy loading — toutes les pages au démarrage

// ❌ Sans lazy loading — tout est dans main.js dès le départ
import { Routes } from '@angular/router';
// Ces imports STATIQUES embarquent tout le code dans main.js
import { AccueilComponent }    from './pages/accueil/accueil.component';
import { CatalogueComponent }  from './pages/catalogue/catalogue.component';
import { ProduitComponent }    from './pages/produit/produit.component';
import { PanierComponent }     from './pages/panier/panier.component';
import { CommandesComponent }  from './pages/commandes/commandes.component';
import { AdminTableauComponent } from './pages/admin/tableau.component';

export const routes: Routes = [
  { path: '',          component: AccueilComponent },
  { path: 'catalogue', component: CatalogueComponent },
  { path: 'produit/:id', component: ProduitComponent },
  { path: 'panier',    component: PanierComponent },
  { path: 'commandes', component: CommandesComponent },
  { path: 'admin',     component: AdminTableauComponent },
];
// Résultat : main.js contient TOUT — même la page admin que 99% ne visitent jamais

Après le lazy loading — un chunk par page

// ✅ Avec lazy loading — chaque page est un chunk séparé
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    // loadComponent() — lazy loading d'un composant standalone
    // Angular crée un chunk séparé pour ce composant
    loadComponent: () =>
      import('./pages/accueil/accueil.component')
        .then(m => m.AccueilComponent)
  },
  {
    path: 'catalogue',
    loadComponent: () =>
      import('./pages/catalogue/catalogue.component')
        .then(m => m.CatalogueComponent)
  },
  {
    path: 'produit/:id',
    loadComponent: () =>
      import('./pages/produit/produit.component')
        .then(m => m.ProduitComponent)
  },
  {
    path: 'panier',
    loadComponent: () =>
      import('./pages/panier/panier.component')
        .then(m => m.PanierComponent)
  },
  {
    path: 'commandes',
    // canActivate protège la route — le code ne charge même pas si non autorisé
    canActivate: [authGuard],
    loadComponent: () =>
      import('./pages/commandes/commandes.component')
        .then(m => m.CommandesComponent)
  },
  {
    path: 'admin',
    canActivate: [adminGuard],
    // loadChildren() pour un module complet avec ses propres sous-routes
    loadChildren: () =>
      import('./pages/admin/admin.routes')
        .then(m => m.adminRoutes)
  },
];

/* Résultat après build :
   main.js          → 180 Ko  (framework + accueil uniquement)
   chunk-catalogue  → 45 Ko   (chargé à la navigation /catalogue)
   chunk-produit    → 38 Ko   (chargé à la navigation /produit)
   chunk-panier     → 22 Ko   (chargé à la navigation /panier)
   chunk-commandes  → 31 Ko   (chargé à la navigation /commandes)
   chunk-admin      → 89 Ko   (chargé uniquement par les admins)
*/

Préchargement intelligent

Le lazy loading pur peut créer un délai perceptible lors de la navigation. La stratégie de préchargement charge les chunks en arrière-plan après le chargement initial :

// app.config.ts — configurer une stratégie de préchargement
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(
      routes,
      // PreloadAllModules : précharge tous les chunks lazy après le démarrage
      // L'utilisateur navigue instantanément car le code est déjà là
      withPreloading(PreloadAllModules)
    )
  ]
};

@defer : charger les composants lourds à la demande

Le lazy loading gère les routes entières. @defer va plus loin : il permet de charger un composant spécifique au sein d'une page uniquement quand c'est nécessaire. C'est idéal pour les composants lourds qui ne sont pas immédiatement visibles.

Cas concret : page produit avec éditeur d'avis

// Page produit — l'éditeur d'avis (lourд) n'est chargé qu'au clic
@Component({
  selector: 'app-page-produit',
  standalone: true,
  // Notez : EditeurAvisComponent N'EST PAS dans imports[]
  // @defer gère son chargement dynamiquement
  template: `
    <!-- Informations produit — chargées immédiatement -->
    <div class="row">
      <div class="col-md-6">
        <img [src]="produit().imageUrl" [alt]="produit().nom" class="img-fluid rounded">
      </div>
      <div class="col-md-6">
        <h1>{{ produit().nom }}</h1>
        <p class="display-5 text-primary fw-bold">{{ produit().prix }}€</p>
        <button class="btn btn-primary btn-lg" (click)="ajouterAuPanier()">
          Ajouter au panier
        </button>
      </div>
    </div>

    <hr class="my-4">

    <!-- Éditeur d'avis — chargé uniquement quand l'utilisateur interagit -->
    @defer (on interaction) {
      <!-- Ce composant (150 Ko) n'est téléchargé qu'au clic/focus -->
      <app-editeur-avis [produitId]="produit().id"></app-editeur-avis>
    } @placeholder {
      <!-- Affiché AVANT l'interaction — léger, toujours présent -->
      <button class="btn btn-outline-secondary w-100">
        ✍️ Écrire un avis — cliquez pour ouvrir l'éditeur
      </button>
    } @loading (minimum 300ms) {
      <!-- Affiché pendant le téléchargement du chunk -->
      <div class="text-center py-4">
        <div class="spinner-border text-primary"></div>
        <p class="mt-2">Chargement de l'éditeur...</p>
      </div>
    } @error {
      <div class="alert alert-danger">Impossible de charger l'éditeur. Réessayez.</div>
    }
  `
})
export class PageProduitComponent {
  produit = input.required<Produit>();
  private panier = inject(PanierService);
  ajouterAuPanier() { this.panier.ajouter(this.produit()); }
}

Les triggers @defer disponibles

Trigger Charge quand... Cas d'usage
on interaction Clic ou focus sur le placeholder Éditeurs, formulaires, modals
on viewport L'élément entre dans la fenêtre Sections bas de page, cartes infinies
on hover Survol du placeholder Tooltips riches, previews
on timer(2000) Après X millisecondes Widgets non critiques, chat, cookies
on idle Navigateur inactif Préchargement discret
when condition Signal/expression devient true Contenu conditionnel au rôle utilisateur
// Exemple combiné : chatbot chargé après 3 secondes d'inactivité
// puis préchargé en arrière-plan dès que le navigateur est idle
@Component({
  template: `
    @defer (on timer(3000); prefetch on idle) {
      <app-widget-chatbot></app-widget-chatbot>
    } @placeholder {
      <div class="chatbot-placeholder"></div>
    }

    <!-- Carte avis clients — chargée quand visible à l'écran -->
    @defer (on viewport) {
      <app-carrousel-avis [produitId]="produitId()"></app-carrousel-avis>
    } @loading {
      <div class="placeholder-glow">
        <div class="placeholder col-12" style="height: 200px"></div>
      </div>
    }
  `
})
export class PageAccueilComponent {
  produitId = signal(1);
}

Tree-shaking et imports ciblés

Le tree-shaking est le processus par lequel le bundler supprime automatiquement le code que vous importez mais n'utilisez pas. Pour qu'il soit efficace, il faut importer précisément ce dont vous avez besoin.

Imports en entier vs imports ciblés

// ❌ Import en entier — embarque TOUTE la librairie (530 Ko pour lodash)
import * as _ from 'lodash';
import moment from 'moment';

// Dans le code :
const uniqueIds = _.uniqBy(commandes, 'clientId');       // utilise uniqBy
const date      = moment(commande.date).format('DD/MM'); // utilise format
// Résultat : 530 Ko de lodash + 300 Ko de moment pour 2 fonctions !
// ✅ Import ciblé — tree-shaking supprime tout le reste
// Lodash-es (version ES modules de lodash — tree-shakeable)
import { uniqBy } from 'lodash-es';  // ~3 Ko au lieu de 530 Ko

// date-fns (alternative moderne à moment, entièrement tree-shakeable)
import { format } from 'date-fns/format';        // ~2 Ko
import { parseISO } from 'date-fns/parseISO';    // ~1 Ko
import { fr } from 'date-fns/locale/fr';         // locale française uniquement

// Même résultat, 99% moins de code dans le bundle
const uniqueIds = uniqBy(commandes, 'clientId');
const date      = format(parseISO(commande.date), 'dd/MM/yyyy', { locale: fr });

Icônes — n'importer que ce dont vous avez besoin

// ❌ Charger Font Awesome entier (600+ icônes = ~400 Ko)
// Dans index.html ou styles.scss :
// @import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.x/css/all.min.css');

// ✅ Option 1 : Bootstrap Icons via CDN ciblé (seulement ~30 Ko gzippé)
// Dans index.html — charger uniquement le subset utilisé :
// <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11/font/bootstrap-icons.min.css">

// ✅ Option 2 : SVG inline — zéro dépendance, zéro requête réseau
// Créer un composant IconeComponent qui retourne du SVG inline
@Component({
  selector: 'app-icone',
  standalone: true,
  template: `
    @switch (nom) {
      @case ('panier') {
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <circle cx="9" cy="21" r="1"></circle><circle cx="20" cy="21" r="1"></circle>
          <path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
        </svg>
      }
      @case ('coeur') {
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
        </svg>
      }
    }
  `
})
export class IconeComponent {
  nom = input.required<'panier' | 'coeur' | 'recherche'>();
}

RxJS — importer uniquement les opérateurs utilisés

// ❌ Ancienne syntaxe RxJS (RxJS 5) — embarque tout
// import 'rxjs/add/operator/map';  ← à ne plus utiliser

// ✅ Import ciblé RxJS 7+ — tree-shaking natif
import { Observable, of, throwError } from 'rxjs';
import { map, filter, switchMap, catchError, debounceTime } from 'rxjs/operators';

// Angular tree-shake automatiquement les opérateurs non utilisés en production

Optimiser les dépendances tierces

Les dépendances node_modules représentent souvent 60 à 80% de la taille du bundle. Voici comment identifier et remplacer les librairies trop lourdes.

Tableau des alternatives légères

Librairie courante Taille bundle Alternative recommandée Taille réduite
moment.js ~300 Ko date-fns (tree-shakeable) ~5 Ko (fonctions utilisées)
lodash ~530 Ko lodash-es + imports ciblés ~3–15 Ko
chart.js (complet) ~180 Ko chart.js avec register manuel ~50–80 Ko
axios ~40 Ko HttpClient Angular (natif) 0 Ko (déjà inclus)
jquery ~90 Ko API DOM native + Angular bindings 0 Ko
Font Awesome (all) ~400 Ko Bootstrap Icons ou SVG inline <30 Ko

Chart.js — n'enregistrer que les types utilisés

// ❌ Import complet de Chart.js — embarque tous les types de graphiques
import Chart from 'chart.js/auto';  // 180 Ko — tous les types inclus

// ✅ Import sélectif — enregistrer uniquement les types utilisés
import {
  Chart,
  BarController, BarElement,
  CategoryScale, LinearScale,
  Tooltip, Legend
} from 'chart.js';

// Enregistrer uniquement ce dont on a besoin (graphiques en barres)
Chart.register(
  BarController, BarElement,
  CategoryScale, LinearScale,
  Tooltip, Legend
);
// Résultat : ~60 Ko au lieu de 180 Ko (-67%)

// Utilisation normale ensuite
@Component({
  selector: 'app-graphique-ventes',
  standalone: true,
  template: `<canvas #canvas></canvas>`
})
export class GraphiqueVentesComponent {
  canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
  private graphique?: Chart;

  ngAfterViewInit() {
    this.graphique = new Chart(this.canvas().nativeElement, {
      type: 'bar',
      data: {
        labels: ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun'],
        datasets: [{ label: 'Ventes 2026', data: [1200, 980, 1450, 1100, 1650, 1820] }]
      }
    });
  }
}
Tester avant de remplacer : Avant de remplacer une librairie, vérifiez avec bundlephobia.com la taille exacte de votre alternative. Entrez le nom du package et comparez le "minified + gzipped". C'est la taille réelle que le navigateur télécharge.

Budget Angular CLI : bloquer les régressions

Une optimisation effectuée aujourd'hui peut être effacée par une PR qui ajoute une librairie lourde demain. Le budget Angular CLI transforme la taille du bundle en contrainte de build : si le budget est dépassé, le build échoue.

Configurer les budgets dans angular.json

// angular.json — section configurations/production/budgets
{
  "projects": {
    "ma-boutique": {
      "architect": {
        "build": {
          "configurations": {
            "production": {
              "budgets": [
                {
                  "type": "initial",
                  "maximumWarning": "400kb",   // Warning si > 400 Ko
                  "maximumError": "600kb"       // Erreur (build échoue) si > 600 Ko
                },
                {
                  "type": "anyComponentStyle",
                  "maximumWarning": "4kb",
                  "maximumError": "8kb"         // CSS inline de composant trop gros
                },
                {
                  "type": "anyScript",
                  "maximumWarning": "100kb",
                  "maximumError": "200kb"       // N'importe quel chunk lazy
                }
              ]
            }
          }
        }
      }
    }
  }
}
# Ce que vous verrez dans le terminal si le budget est dépassé :
ng build

# ✓ Generating browser application bundles (phase: sealing)...
# chunk {main} main.js (main) 612.34 kB [initial] [rendered]
#
# ✘ [Error] bundle initial exceeded maximum budget.
#   Budget 600.00 kB was not met by 12.34 kB with a total of 612.34 kB.
#
# Build at: 2026-05-04T10:30:00.000Z - Time: 12458ms

Script d'analyse pré-commit

// package.json — scripts utiles pour l'équipe
{
  "scripts": {
    "build:prod": "ng build --configuration=production",
    "analyze":    "ng build --configuration=production --source-map && npx source-map-explorer 'dist/ma-boutique/browser/*.js'",
    "check:size": "ng build --configuration=production && echo 'Budget OK ✓'",
    "ci:build":   "ng build --configuration=production --no-progress"
  }
}

Workflow d'optimisation continue

L'optimisation des bundles n'est pas un événement unique — c'est un processus continu. Voici le workflow recommandé pour maintenir des bundles légers dans la durée.

Les 5 étapes d'une session d'optimisation

# Étape 1 — Mesurer l'état actuel (toujours AVANT de toucher quoi que ce soit)
npm run analyze
# → noter les 3 plus gros contributeurs dans node_modules/

# Étape 2 — Identifier la cible d'optimisation
# Règle : attaquer d'abord ce qui représente > 5% du bundle total

# Étape 3 — Appliquer une optimisation (une seule à la fois)
# ex : remplacer moment par date-fns

# Étape 4 — Remesurer et comparer
npm run analyze
# → vérifier que la cible a bien diminué, rien d'autre n'a grossi

# Étape 5 — Mettre à jour le budget dans angular.json
# → abaisser le maximumWarning au nouveau niveau atteint
# → cela empêche toute régression future

Indicateurs Lighthouse à surveiller

# Lancer un audit Lighthouse sur la page d'accueil (prod ou staging)
# Dans Chrome DevTools → Lighthouse → Generate report

# Métriques à suivre pour une app Angular e-commerce :
# ┌─────────────────────────────────────────────────────────┐
# │ Métrique                 Objectif       Critique        │
# ├─────────────────────────────────────────────────────────┤
# │ First Contentful Paint   < 1,8s         > 3s            │
# │ Largest Contentful Paint < 2,5s         > 4s            │
# │ Total Blocking Time      < 200ms        > 600ms         │
# │ Time to Interactive      < 3,8s         > 7,3s          │
# │ Bundle initial (gzippé)  < 150 Ko       > 300 Ko        │
# └─────────────────────────────────────────────────────────┘

Checklist complète d'optimisation

  • source-map-explorer lancé et rapport analysé avant toute optimisation
  • Toutes les routes utilisent loadComponent() ou loadChildren()
  • Les composants lourds (+50 Ko) sont enveloppés dans @defer
  • moment.js remplacé par date-fns avec imports ciblés
  • lodash remplacé par lodash-es avec imports ciblés
  • Chart.js ou librairies graphiques avec Chart.register() sélectif
  • Icônes en SVG inline ou Bootstrap Icons (pas Font Awesome complet)
  • Budgets configurés dans angular.json et CI bloque si dépassés
  • Stratégie de préchargement configurée (PreloadAllModules ou custom)
  • Lighthouse score Performance ≥ 90 sur mobile
Résumé : Réduire les bundles Angular suit une méthode simple — mesurer, identifier, corriger, remesurer. Les gains les plus rapides : activer le lazy loading sur toutes les routes (-40 à -60% sur le bundle initial), remplacer moment.js (-200 Ko), et utiliser @defer pour les composants lourds non visibles immédiatement. Avec un budget dans angular.json, ces gains sont protégés contre les régressions.

Partager