Intermédiaire 16 min de lecture · 3 327 mots

JavaScript performance : Bundle splitting et tree shaking

JavaScript performance : Bundle splitting et tree shaking
Intermédiaire · 2025.12.18

Introduction

JavaScript est souvent le plus gros bottleneck de performance web moderne. Un bundle mal optimisé impacte directement TTI, TBT et FID.

Impact mesuré

Cas réel - Application React:
Avant optimisation:
├── main.js: 850 KB (gzipped: 280 KB)
├── TTI: 8.2s
├── TBT: 2400ms
└── FID: 420ms

Après bundle splitting + tree shaking:
├── main.js: 45 KB (gzipped: 12 KB)
├── vendors.js: 180 KB (gzipped: 52 KB) [cached]
├── routes/*.js: 20-80 KB chacun [lazy]
├── TTI: 2.1s (-74%)
├── TBT: 180ms (-92%)
└── FID: 35ms (-92%)

Impact business: +34% conversion rate

1. Bundle analysis et diagnostic

1.1 Webpack Bundle Analyzer

Installation et configuration:

npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  // ... config existante
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: process.env.ANALYZE ? 'server' : 'disabled',
      analyzerPort: 8888,
      openAnalyzer: true,
      generateStatsFile: true,
      statsFilename: 'bundle-stats.json',
      statsOptions: { source: false }
    })
  ]
};
// package.json
{
  "scripts": {
    "build": "webpack --mode production",
    "analyze": "ANALYZE=true npm run build"
  }
}

Lecture du rapport:

Bundle composition typique AVANT optimisation:

main.js (850 KB)
├── node_modules/ (720 KB) ❌ Too large
│   ├── react + react-dom (120 KB)
│   ├── lodash (70 KB) ❌ Import entier inutile
│   ├── moment (230 KB) ❌ Toutes les locales
│   ├── chart.js (180 KB)
│   └── autres libs (120 KB)
├── src/ (130 KB)
│   ├── components/ (80 KB)
│   ├── utils/ (30 KB)
│   └── pages/ (20 KB) ❌ Devrait être lazy
└── Duplicates (15 KB) ❌ Code dupliqué

Red flags:
❌ node_modules > 200 KB
❌ Code dupliqué entre chunks
❌ Librairies entières importées
❌ Toutes les pages dans le bundle initial

1.2 Source Map Explorer

Alternative visuelle:

npm install --save-dev source-map-explorer

# Génération avec sourcemaps
npm run build -- --devtool source-map

# Analyse
npx source-map-explorer dist/main.*.js

Script automatisé:

// analyze-bundle.js
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');

class BundleAnalyzer {
  analyze() {
    console.log('Building with source maps...');
    execSync('npm run build -- --devtool source-map', { stdio: 'inherit' });

    console.log('nAnalyzing bundles...');
    const distPath = path.join(**dirname, 'dist');
    const files = fs.readdirSync(distPath);
    const jsFiles = files.filter(f => f.endsWith('.js') && !f.endsWith('.map'));

    const results = {};

    jsFiles.forEach(file => {
      const filePath = path.join(distPath, file);
      const stats = fs.statSync(filePath);
      const gzipSize = this.getGzipSize(filePath);

      results[file] = {
        size: stats.size,
        gzipSize,
        ratio: (gzipSize / stats.size * 100).toFixed(1) + '%'
      };

      console.log(n${file}:);
      console.log(  Raw: ${this.formatBytes(stats.size)});
      console.log(  Gzip: ${this.formatBytes(gzipSize)});
      console.log(  Ratio: ${results[file].ratio});
    });

    // Warnings
    this.checkBudgets(results);

    return results;
  }

  getGzipSize(filePath) {
    const { gzipSync } = require('zlib');
    const content = fs.readFileSync(filePath);
    return gzipSync(content).length;
  }

  checkBudgets(results) {
    console.log('n=== Budget Check ===');

    const budgets = {
      'main': 50 * 1024,      // 50 KB
      'vendor': 200 * 1024,   // 200 KB
      'chunk': 100 * 1024     // 100 KB
    };

    Object.entries(results).forEach(([file, stats]) => {
      let budget;
      if (file.includes('main')) budget = budgets.main;
      else if (file.includes('vendor')) budget = budgets.vendor;
      else budget = budgets.chunk;

      const overBudget = stats.gzipSize > budget;
      const icon = overBudget ? '❌' : '✅';
      const percent = ((stats.gzipSize / budget) * 100).toFixed(0);

      console.log(${icon} ${file}: ${percent}% of budget);

      if (overBudget) {
        const excess = this.formatBytes(stats.gzipSize - budget);
        console.log(   Over by ${excess});
      }
    });
  }

  formatBytes(bytes) {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  }
}

new BundleAnalyzer().analyze();

2. Tree Shaking

2.1 Configuration Webpack

Activation du tree shaking:

// webpack.config.js
module.exports = {
  mode: 'production', // Active automatiquement tree shaking

  optimization: {
    usedExports: true, // Mark unused exports
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            dead_code: true,
            drop_console: true,
            drop_debugger: true,
            pure_funcs: ['console.log', 'console.info']
          },
          mangle: true,
          output: {
            comments: false
          }
        }
      })
    ],
    sideEffects: false // Assume no side effects (verify in package.json)
  },

  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', {
                modules: false, // ⚠️ Crucial pour tree shaking
                targets: '> 0.25%, not dead'
              }]
            ]
          }
        }
      }
    ]
  }
};

package.json sideEffects:

{
  "name": "my-app",
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfills.js"
  ]
}

2.2 Import patterns optimisés

Mauvais patterns (pas de tree shaking):

// ❌ Import namespace entier
import * from 'lodash';
const result = *.debounce(fn, 300);

// ❌ Default export d'un gros objet
import utils from './utils';
utils.formatDate(date);

// ❌ Import * as
import * as d3 from 'd3';
d3.select('body');

Bons patterns (tree shaking efficace):

// ✅ Named imports spécifiques
import { debounce } from 'lodash-es'; // Version ES modules
const debouncedFn = debounce(fn, 300);

// ✅ Import direct du module
import debounce from 'lodash-es/debounce';

// ✅ Named exports dans votre code
export const formatDate = (date) => { /* ... */ };
export const formatCurrency = (amount) => { /* ... */ };

// Dans un autre fichier
import { formatDate } from './utils'; // Seul formatDate est inclus

// ✅ Barrel exports avec re-export nommé
// utils/index.js
export { formatDate } from './date';
export { formatCurrency } from './currency';
// Pas de export * from './date' (empêche tree shaking)

2.3 Cas pratique: Lodash

Problème: Lodash complet = 70 KB (24 KB gzipped)

Solution 1: lodash-es

npm install lodash-es
npm uninstall lodash
// Avant (70 KB inclus)
import * from 'lodash';
*.debounce(fn, 300);
*.chunk(array, 3);

// Après (2-3 KB inclus)
import { debounce, chunk } from 'lodash-es';
debounce(fn, 300);
chunk(array, 3);

Solution 2: babel-plugin-lodash

npm install --save-dev babel-plugin-lodash
// .babelrc
{
  "plugins": ["lodash"],
  "presets": [["@babel/preset-env", { "modules": false }]]
}

// Code (même syntaxe)
import { debounce, chunk } from 'lodash';

// Transformé automatiquement en:
import debounce from 'lodash/debounce';
import chunk from 'lodash/chunk';

Résultats:

Avant:
├── Bundle size: +70 KB
└── Gzipped: +24 KB

Après (lodash-es):
├── Bundle size: +3 KB (-96%)
└── Gzipped: +1.2 KB (-95%)

3 fonctions utilisées = 1.2 KB au lieu de 24 KB

2.4 Cas pratique: Moment.js

Problème: Moment avec toutes les locales = 230 KB

Solution 1: Day.js (alternative moderne)

npm uninstall moment
npm install dayjs
// Avant (moment.js - 230 KB)
import moment from 'moment';
import 'moment/locale/fr';

moment.locale('fr');
const date = moment().format('LL');

// Après (dayjs - 7 KB)
import dayjs from 'dayjs';
import 'dayjs/locale/fr';
import localizedFormat from 'dayjs/plugin/localizedFormat';

dayjs.extend(localizedFormat);
dayjs.locale('fr');
const date = dayjs().format('LL');

Solution 2: ContextReplacementPlugin (si moment requis)

// webpack.config.js
const webpack = require('webpack');

module.exports = {
  plugins: [
    // Ne garde que la locale française
    new webpack.ContextReplacementPlugin(
      /moment[/]locale$/,
      /fr/
    )
  ]
};

Résultats:

Moment.js (toutes locales):
└── 230 KB (67 KB gzipped)

Moment.js (locale fr uniquement):
└── 85 KB (28 KB gzipped) -63%

Day.js (avec plugins):
└── 7 KB (3 KB gzipped) -97%

3. Code Splitting

3.1 Entry points splitting

Configuration multi-entry:

// webpack.config.js
module.exports = {
  entry: {
    main: './src/index.js',
    admin: './src/admin.js',
    vendor: ['react', 'react-dom', 'react-router-dom']
  },

  output: {
    filename: '[name].[contenthash:8].js',
    path: path.resolve(**dirname, 'dist'),
    clean: true
  },

  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[/]node_modules[/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10
        },
        common: {
          minChunks: 2,
          priority: 5,
          reuseExistingChunk: true,
          enforce: true
        }
      }
    },
    runtimeChunk: 'single' // Runtime séparé (hot reload, etc.)
  }
};

Résultat:

Avant:
└── main.js (850 KB)

Après:
├── runtime.abc123.js (2 KB) - Webpack runtime
├── vendors.def456.js (180 KB) - React, etc. [cached longterm]
├── common.ghi789.js (45 KB) - Code partagé
├── main.jkl012.js (42 KB) - App code
└── admin.mno345.js (38 KB) - Admin panel [lazy]

Benefits:
✅ Vendor code cached separately
✅ User code changes don't invalidate vendor cache
✅ Parallel downloads

3.2 Dynamic imports (route-based)

React Router lazy loading:

// App.js
import React, { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

// Eager load (always needed)
import Header from './components/Header';
import Footer from './components/Footer';

// Lazy load pages
const Home = lazy(() => import(/* webpackChunkName: "home" */ './pages/Home'));
const Blog = lazy(() => import(/* webpackChunkName: "blog" */ './pages/Blog'));
const Products = lazy(() => import(/* webpackChunkName: "products" */ './pages/Products'));
const Admin = lazy(() => import(/* webpackChunkName: "admin" */ './pages/Admin'));

// Loading fallback
const PageLoader = () => (
  <div className="page-loader">
    <div className="spinner"></div>
  </div>
);

function App() {
  return (
    <Router>
      <Header />
      <Suspense fallback={<PageLoader />}>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route path="/blog" component={Blog} />
          <Route path="/products" component={Products} />
          <Route path="/admin" component={Admin} />
        </Switch>
      </Suspense>
      <Footer />
    </Router>
  );
}

export default App;

Vue Router lazy loading:

// router/index.js
import { createRouter, createWebHistory } from 'vue-router';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import(/* webpackChunkName: "home" */ '../views/Home.vue')
  },
  {
    path: '/blog',
    name: 'Blog',
    component: () => import(/* webpackChunkName: "blog" */ '../views/Blog.vue')
  },
  {
    path: '/admin',
    name: 'Admin',
    component: () => import(/* webpackChunkName: "admin" */ '../views/Admin.vue'),
    meta: { requiresAuth: true }
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

export default router;

Résultats mesurés:

Initial bundle (sans lazy):
├── main.js: 420 KB
└── TTI: 5.2s

Avec route-based splitting:
├── main.js: 45 KB (core + home)
├── blog.chunk.js: 65 KB (chargé quand /blog visité)
├── products.chunk.js: 85 KB
├── admin.chunk.js: 120 KB
└── TTI: 1.8s (-65%)

90% des utilisateurs ne visitent jamais /admin
→ 120 KB jamais téléchargés pour eux

3.3 Component-level splitting

Lazy load de composants lourds:

// ProductPage.js
import React, { useState, lazy, Suspense } from 'react';

// Composants légers chargés immédiatement
import ProductHeader from './ProductHeader';
import ProductImages from './ProductImages';
import AddToCart from './AddToCart';

// Composants lourds chargés à la demande
const Reviews = lazy(() => import(/* webpackChunkName: "reviews" */ './Reviews'));
const RelatedProducts = lazy(() => import(/* webpackChunkName: "related" */ './RelatedProducts'));
const ProductChat = lazy(() => import(/* webpackChunkName: "chat" */ './ProductChat'));

function ProductPage({ product }) {
  const [showReviews, setShowReviews] = useState(false);
  const [showChat, setShowChat] = useState(false);

  return (
    <div className="product-page">
      <ProductHeader product={product} />
      <ProductImages images={product.images} />
      <AddToCart product={product} />

      {/* Reviews chargés seulement si cliqué */}
      <button onClick={() => setShowReviews(true)}>
        Voir les avis ({product.reviewCount})
      </button>

      {showReviews && (
        <Suspense fallback={<div>Chargement des avis...</div>}>
          <Reviews productId={product.id} />
        </Suspense>
      )}

      {/* Chat chargé seulement si ouvert */}
      {showChat && (
        <Suspense fallback={<div>Chargement du chat...</div>}>
          <ProductChat productId={product.id} />
        </Suspense>
      )}

      {/* Related products en bas de page (lazy auto) */}
      <Suspense fallback={<div>Chargement...</div>}>
        <RelatedProducts category={product.category} />
      </Suspense>
    </div>
  );
}

Avec Intersection Observer (chargement avant affichage):

// LazyComponent.js
import React, { useState, useEffect, useRef, lazy, Suspense } from 'react';

function LazyComponent({ loader, fallback, rootMargin = '100px' }) {
  const [shouldLoad, setShouldLoad] = useState(false);
  const ref = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setShouldLoad(true);
          observer.disconnect();
        }
      },
      { rootMargin }
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    return () => observer.disconnect();
  }, [rootMargin]);

  const Component = shouldLoad ? lazy(loader) : null;

  return (
    <div ref={ref}>
      {Component ? (
        <Suspense fallback={fallback || <div>Chargement...</div>}>
          <Component />
        </Suspense>
      ) : (
        fallback || <div>Chargement...</div>
      )}
    </div>
  );
}

// Usage
<LazyComponent
  loader={() => import('./HeavyChart')}
  fallback={<ChartSkeleton />}
  rootMargin="200px"
/>

3.4 Vendor splitting avancé

Stratégie par fréquence de mise à jour:

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        // Framework (change rarement) - cache longue durée
        framework: {
          test: /[/]node_modules<a href="react|react-dom|react-router-dom|redux">/</a>[/]/,
          name: 'framework',
          priority: 40,
          enforce: true
        },

        // UI libs (change occasionnellement)
        ui: {
          test: /[/]node_modules<a href="@mui|@emotion|styled-components">/</a>[/]/,
          name: 'ui',
          priority: 30,
          enforce: true
        },

        // Utils (change rarement)
        libs: {
          test: /[/]node_modules<a href="lodash-es|dayjs|axios">/</a>[/]/,
          name: 'libs',
          priority: 20,
          enforce: true
        },

        // Autres node_modules
        vendor: {
          test: /[/]node_modules[/]/,
          name: 'vendor',
          priority: 10
        },

        // Code commun de l'app
        common: {
          minChunks: 2,
          priority: 5,
          reuseExistingChunk: true
        }
      }
    }
  }
};

Résultat:

Cache strategy:

framework.js (120 KB) - Cache: 1 an
├── React, React-DOM, React-Router
└── Change: 1-2 fois/an

ui.js (80 KB) - Cache: 6 mois
├── Material-UI, Emotion
└── Change: 4-6 fois/an

libs.js (45 KB) - Cache: 3 mois
├── Lodash-es, Day.js, Axios
└── Change: rare

vendor.js (60 KB) - Cache: 1 mois
├── Autres dépendances
└── Change: variable

common.js (30 KB) - Cache: 1 semaine
├── Code app partagé
└── Change: fréquent

main.js (40 KB) - No cache
├── Entry point
└── Change: chaque deploy

Benefit:
- User visite après 1 mois: télécharge seulement main.js (40 KB)
- 90% du code servi depuis le cache

4. Webpack optimizations avancées

4.1 Module concatenation (Scope Hoisting)

// webpack.config.js
module.exports = {
  mode: 'production',
  optimization: {
    concatenateModules: true, // Activé par défaut en production
    providedExports: true,
    usedExports: true,
    sideEffects: true
  }
};

Impact:

// Avant (modules séparés):
// module1.js
export function add(a, b) { return a + b; }

// module2.js
import { add } from './module1';
export function calculate() { return add(1, 2); }

// Bundle résultant (simplifié):
function module1() { return { add: (a,b) => a+b }; }
function module2() { const m1 = module1(); return m1.add(1,2); }

// Après scope hoisting:
// Tout inliné dans une seule fonction
function bundle() {
  const add = (a,b) => a+b;
  return add(1,2);
}

// Gains: -15-20% bundle size, exécution plus rapide

4.2 Externals (CDN dependencies)

Pour librairies stables:

// webpack.config.js
module.exports = {
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM',
    'lodash': '*'
  }
};
<!-- index.html -->
<head>
  <!-- CDN avec fallback -->
  <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
  <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
  <script>
    // Fallback si CDN échoue
    window.React || document.write('<script src="/vendor/react.js"></script>');
  </script>
</head>

Avantages:

✅ Bundle plus petit (-120 KB pour React)
✅ Cache partagé entre sites
✅ Parallel download

Inconvénients:
❌ Requête supplémentaire (HTTP/2 atténue)
❌ Dépendance externe (risque de panne CDN)
❌ Version figée

Recommandation: Uniquement pour libs stables et sites multi-domaines

4.3 Preload / Prefetch hints

Magic comments Webpack:

// Preload (haute priorité, needed soon)
import(
  /* webpackPreload: true */
  /* webpackChunkName: "critical-feature" */
  './CriticalFeature'
);

// Prefetch (basse priorité, might be needed)
import(
  /* webpackPrefetch: true */
  /* webpackChunkName: "admin-panel" */
  './AdminPanel'
);

// Résultat HTML:
// <link rel="preload" href="/critical-feature.chunk.js" as="script">
// <link rel="prefetch" href="/admin-panel.chunk.js">

Pattern de prefetch intelligent:

// SmartPrefetch.js
class SmartPrefetch {
  constructor() {
    this.prefetched = new Set();
    this.setupIdlePrefetch();
    this.setupHoverPrefetch();
  }

  setupIdlePrefetch() {
    // Prefetch pendant idle time
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => {
        this.prefetchRoutes([
          () => import(/* webpackPrefetch: true */ './pages/Blog'),
          () => import(/* webpackPrefetch: true */ './pages/Products')
        ]);
      }, { timeout: 2000 });
    }
  }

  setupHoverPrefetch() {
    // Prefetch au hover (3x plus rapide que click)
    document.addEventListener('mouseover', (e) => {
      const link = e.target.closest('a[data-prefetch]');
      if (link && !this.prefetched.has(link.pathname)) {
        this.prefetchRoute(link.pathname);
        this.prefetched.add(link.pathname);
      }
    }, { passive: true });
  }

  async prefetchRoutes(loaders) {
    for (const loader of loaders) {
      try {
        await loader();
      } catch (err) {
        console.warn('Prefetch failed:', err);
      }
    }
  }

  prefetchRoute(pathname) {
    const routeLoaders = {
      '/blog': () => import('./pages/Blog'),
      '/products': () => import('./pages/Products'),
      '/admin': () => import('./pages/Admin')
    };

    const loader = routeLoaders[pathname];
    if (loader) {
      loader().catch(err => console.warn('Prefetch failed:', err));
    }
  }
}

new SmartPrefetch();

5. Performance monitoring

5.1 Bundle size budget

Configuration webpack-bundle-analyzer:

// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      reportFilename: 'bundle-report.html',
      defaultSizes: 'gzip',
      openAnalyzer: false,
      generateStatsFile: true
    })
  ],

  performance: {
    maxEntrypointSize: 250000, // 250 KB
    maxAssetSize: 100000, // 100 KB
    hints: 'error', // Fail build si dépassé
    assetFilter: (assetFilename) => {
      return assetFilename.endsWith('.js');
    }
  }
};

GitHub Action CI:

# .github/workflows/bundle-size.yml
name: Bundle Size Check
on: [pull_request]

jobs:
  check-size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Analyze bundle
        run: |
          node scripts/check-bundle-size.js

      - name: Comment PR
        uses: actions/github-script@v6
        with:
          script: |
            const fs = require('fs');
            const report = fs.readFileSync('bundle-size-report.txt', 'utf8');
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: ## Bundle Size Reportnn${report}
            });

Script de vérification:

// scripts/check-bundle-size.js
const fs = require('fs');
const path = require('path');
const { gzipSync } = require('zlib');

const BUDGETS = {
  'main': 50 * 1024,
  'vendor': 200 * 1024,
  'chunk': 100 * 1024
};

function checkBundleSize() {
  const distPath = path.join(*_dirname, '../dist');
  const files = fs.readdirSync(distPath);
  const jsFiles = files.filter(f => f.endsWith('.js'));

  let hasError = false;
  const report = [];

  jsFiles.forEach(file => {
    const filePath = path.join(distPath, file);
    const content = fs.readFileSync(filePath);
    const size = content.length;
    const gzipSize = gzipSync(content).length;

    let budget;
    if (file.includes('main')) budget = BUDGETS.main;
    else if (file.includes('vendor')) budget = BUDGETS.vendor;
    else budget = BUDGETS.chunk;

    const percentage = (gzipSize / budget * 100).toFixed(1);
    const status = gzipSize > budget ? '❌ FAIL' : '✅ PASS';

    if (gzipSize > budget) {
      hasError = true;
    }

    report.push({
      file,
      size: formatBytes(size),
      gzip: formatBytes(gzipSize),
      budget: formatBytes(budget),
      percentage: ${percentage}%,
      status
    });
  });

  // Console output
  console.table(report);

  // File output for CI
  const reportText = report
    .map(r => ${r.status} ${r.file}: ${r.gzip} / ${r.budget} (${r.percentage}))
    .join('n');

  fs.writeFileSync('bundle-size-report.txt', reportText);

  if (hasError) {
    console.error('n❌ Bundle size budget exceeded!');
    process.exit(1);
  } else {
    console.log('n✅ All bundles within budget');
  }
}

function formatBytes(bytes) {
  if (bytes === 0) return '0 B';
  const k = 1024;
  const sizes = ['B', 'KB', 'MB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

checkBundleSize();

5.2 Runtime monitoring

// performance-monitor.js
class BundlePerformanceMonitor {
  constructor() {
    this.metrics = {
      chunks: [],
      totalSize: 0,
      loadTime: 0
    };

    this.init();
  }

  init() {
    // Monitor script loads
    const observer = new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        if (entry.initiatorType === 'script') {
          this.recordChunkLoad(entry);
        }
      });
    });

    observer.observe({ entryTypes: ['resource'] });

    // Report on page fully loaded
    window.addEventListener('load', () => {
      setTimeout(() => this.generateReport(), 1000);
    });
  }

  recordChunkLoad(entry) {
    this.metrics.chunks.push({
      name: this.extractChunkName(entry.name),
      size: entry.transferSize,
      duration: entry.duration,
      cached: entry.transferSize === 0
    });

    this.metrics.totalSize += entry.transferSize;
    this.metrics.loadTime = Math.max(this.metrics.loadTime, entry.duration);
  }

  extractChunkName(url) {
    const match = url.match(//([^/]+.chunk.[a-f0-9]+.js)/);
    return match ? match[1] : path.basename(url);
  }

  generateReport() {
    const cachedCount = this.metrics.chunks.filter(c => c.cached).length;
    const totalChunks = this.metrics.chunks.length;

    console.group('Bundle Performance Report');
    console.log(Total chunks: ${totalChunks});
    console.log(Cached: ${cachedCount} (${(cachedCount/totalChunks*100).toFixed(1)}%));
    console.log(Total size: ${this.formatBytes(this.metrics.totalSize)});
    console.log(Total load time: ${this.metrics.loadTime.toFixed(0)}ms);

    console.log('nSlowest chunks:');
    this.metrics.chunks
      .sort((a, b) => b.duration - a.duration)
      .slice(0, 5)
      .forEach(chunk => {
        console.log(  ${chunk.name}: ${chunk.duration.toFixed(0)}ms (${this.formatBytes(chunk.size)}));
      });

    console.groupEnd();

    // Send to analytics
    this.sendToAnalytics();
  }

  sendToAnalytics() {
    if (navigator.sendBeacon && window.analytics) {
      const payload = {
        type: 'bundle-performance',
        metrics: this.metrics,
        timestamp: Date.now(),
        url: location.href
      };

      navigator.sendBeacon('/api/analytics', JSON.stringify(payload));
    }
  }

  formatBytes(bytes) {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  }
}

// Auto-init in development
if (process.env.NODE_ENV === 'development') {
  new BundlePerformanceMonitor();
}

6. Checklist complète

Pre-optimization

□ Bundle analyzer installé et configuré
□ Baseline metrics enregistrées
□ Performance budgets définis
□ CI/CD integration prête

Tree Shaking

□ mode: 'production' activé
□ ES modules (pas CommonJS) dans le code
□ sideEffects configuré dans package.json
□ Named imports au lieu de namespace imports
□ Lodash → lodash-es
□ Moment → Day.js (ou context replacement)
□ Babel preset-env avec modules: false

Code Splitting

□ SplitChunks configuré
□ Vendor bundle séparé
□ Route-based splitting implémenté
□ Component-level lazy loading
□ Preload/prefetch hints sur chunks critiques
□ Runtime chunk séparé

Optimization

□ Minification activée (Terser)
□ Scope hoisting activé
□ Source maps en production (séparés)
□ Cache headers appropriés
□ Contenthash dans filenames

Monitoring

□ Bundle size budget enforcé
□ GitHub Actions check bundle size
□ Runtime performance monitoring
□ Analytics sur chunk load times
□ Alertes si dégradation

7. Résultats attendus

Métriques cibles

Initial bundle (gzipped):
├── Small app: < 50 KB
├── Medium app: < 150 KB
└── Large app: < 250 KB

Vendor bundle:
└── < 200 KB (cached long-term)

Route chunks:
└── < 100 KB each

Time to Interactive:
├── 3G: < 5s
├── 4G: < 3s
└── Cable: < 1.5s

ROI mesuré

Projet e-commerce réel:

Avant:
├── Bundle: 850 KB
├── TTI: 8.2s
├── Bounce rate: 42%
└── Conversion: 2.1%

Après:
├── Bundle initial: 45 KB (-95%)
├── TTI: 2.1s (-74%)
├── Bounce rate: 28% (-33%)
└── Conversion: 2.8% (+33%)

ROI: 2 semaines de travail = +33% revenus permanents

Ressources

Outils

  • Webpack Bundle Analyzer
  • Source Map Explorer
  • Bundlephobia – Taille des packages NPM
  • Documentation

  • Webpack Code Splitting
  • Tree Shaking
  • web.dev Reduce JavaScript
  • Alternatives

  • Vite – Build tool moderne (ESM natif)
  • esbuild – Bundler ultra-rapide
  • Parcel – Zero-config bundler

  • Conclusion

    L’optimisation JavaScript est critique pour les performances modernes:

  • Impact direct sur TTI, TBT, FID
  • Gains mesurables sur conversion et engagement
  • ROI élevé (quelques jours de travail)
  • Priorisation recommandée:

  • Bundle analysis (identifier les problèmes)
  • Tree shaking (quick wins: Lodash, Moment)
  • Route-based splitting (biggest impact)
  • Performance budgets + CI (maintenir les gains)
  • Les optimisations présentées sont production-tested et donnent des résultats immédiats.

    À propos de l'auteur

    Pascal

    Pascal — SRE & Observability Engineer. J'applique les pratiques SRE (SLI/SLO, alerting, observabilité) à mon homelab Proxmox, avec les mêmes exigences qu'en production.

    Une remarque, un retour ?

    Cet article est vivant - corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisis celui qui te convient.

    Laisser un commentaire