Introduction
JavaScript est souvent le plus gros bottleneck de performance web moderne. Un bundle mal optimisé impacte directement TTI, TBT et FID.
Table of Contents
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...