Intermediaire 14 min de lecture · 3 066 mots

JavaScript performance : Bundle splitting et tree shaking

Estimated reading time: 15 minutes

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)
├── nodemodules/ (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:
❌ nodemodules > 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('childprocess');
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: {
            deadcode: true,
            dropconsole: true,
            dropdebugger: true,
            purefuncs: ['console.log', 'console.info']
          },
          mangle: true,
          output: {
            comments: false
          }
        }
      })
    ],
    sideEffects: false // Assume no side effects (verify in package.json)
  },

  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /nodemodules/,
        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: /[/]nodemodules[/]/,
          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 = () => (
  
); function App() { return (
}>
); } 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 (
    
{/ Reviews chargés seulement si cliqué /} {showReviews && ( Chargement des avis...
}> )} {/ Chat chargé seulement si ouvert /} {showChat && ( Chargement du chat...

Une remarque, un retour ?

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