Intermediaire 14 min de lecture · 2 909 mots

JavaScript performance : Bundle splitting et tree shaking

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.

Laisser un commentaire