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
Documentation
Alternatives
Conclusion
L’optimisation JavaScript est critique pour les performances modernes:
Priorisation recommandée:
Les optimisations présentées sont production-tested et donnent des résultats immédiats.