Cours JavaScript et TypeScript · Module 9: Design patterns et architecture

Code splitting et tree shaking

10 min de lecture
Dans cette leçon11

Dans le monde du développement web moderne, où les applications sont de plus en plus complexes, maîtriser la taille des bundles JavaScript est crucial pour les performances. Deux techniques d'optimisation fondamentales - le code splitting et le tree shaking - permettent de réduire drastiquement la quantité de code livrée aux utilisateurs.

Qu'est-ce que le code splitting ?

Le code splitting est une technique qui consiste à découper le code en fragments plus petits (chunks) pouvant être chargés à la demande (lazy loading) ou en parallèle. Au lieu d'envoyer tout le code de l'application dans un seul gros bundle, on peut le diviser en parties logiques et ne charger que ce dont on a besoin sur le moment.

Les problèmes résolus par le code splitting

1// Problem: One huge bundle
2// main.bundle.js (2.5MB)
3// - Homepage code (200KB)
4// - Admin panel code (800KB)
5// - Online store code (1.5MB)
6// A user viewing only the homepage unnecessarily downloads 2.3MB of code!
7
8// Solution: Code splitting
9// main.bundle.js (200KB) - only homepage code
10// admin.chunk.js (800KB) - loaded only when needed
11// shop.chunk.js (1.5MB) - loaded only when needed

Les types de code splitting

1. Code splitting par route (route-based)

Le type le plus simple et le plus répandu - découper le code selon les routes de l'application :

1// React Router with lazy loading
2import { lazy, Suspense } from 'react';
3import { Routes, Route } from 'react-router';
4
5// Each component is a separate chunk
6const HomePage = lazy(() => import('./pages/HomePage'));
7const ProductPage = lazy(() => import('./pages/ProductPage'));
8const AdminPanel = lazy(() => import('./pages/AdminPanel'));
9
10function App() {
11  return (
12    <Suspense fallback={<div>Loading...</div>}>
13      <Routes>
14        <Route path="/" element={<HomePage />} />
15        <Route path="/product/:id" element={<ProductPage />} />
16        <Route path="/admin" element={<AdminPanel />} />
17      </Routes>
18    </Suspense>
19  );
20}

2. Code splitting par composant (component-based)

Découper le code au niveau des composants :

1// Lazy loading individual components
2const ExpensiveChart = lazy(() => import('./components/ExpensiveChart'));
3const HeavyDataTable = lazy(() => import('./components/HeavyDataTable'));
4
5function Dashboard({ showChart, showTable }) {
6  return (
7    <div>
8      <h1>Main panel</h1>
9
10      {showChart && (
11        <Suspense fallback={<div>Loading chart...</div>}>
12          <ExpensiveChart />
13        </Suspense>
14      )}
15
16      {showTable && (
17        <Suspense fallback={<div>Loading table...</div>}>
18          <HeavyDataTable />
19        </Suspense>
20      )}
21    </div>
22  );
23}

3. Imports dynamiques

Importer du code en réaction aux actions de l'utilisateur :

1// Loading a library only when needed
2async function showAdvancedEditor() {
3  // CodeMirror is loaded only when the user clicks the button
4  const { EditorView, basicSetup } = await import('codemirror');
5  const { javascript } = await import('@codemirror/lang-javascript');
6
7  const editor = new EditorView({
8    extensions: [basicSetup, javascript()],
9    parent: document.getElementById('editor-container')
10  });
11}
12
13// Event listener on button
14document.getElementById('show-editor').addEventListener('click', showAdvancedEditor);

4. Code splitting des dépendances (vendor)

Séparer les bibliothèques externes du code de l'application :

1// webpack.config.js
2module.exports = {
3  optimization: {
4    splitChunks: {
5      chunks: 'all',
6      cacheGroups: {
7        // External libraries in a separate chunk
8        vendor: {
9          test: /[\\/]node_modules[\\/]/,
10          name: 'vendors',
11          chunks: 'all',
12        },
13        // Common application code
14        common: {
15          name: 'common',
16          minChunks: 2,
17          chunks: 'all',
18          enforce: true
19        }
20      }
21    }
22  }
23};

Qu'est-ce que le tree shaking ?

Le tree shaking est le processus consistant à éliminer le « code mort » - le code qui n'est pas utilisé dans l'application. Le nom vient de la métaphore de « secouer un arbre », où seules les feuilles inutiles (le code non utilisé) tombent.

Comment fonctionne le tree shaking ?

1// utils.js - we export many functions
2export function formatDate(date) {
3  return date.toLocaleDateString();
4}
5
6export function formatTime(date) {
7  return date.toLocaleTimeString();
8}
9
10export function formatCurrency(amount) {
11  return new Intl.NumberFormat('pl-PL', {
12    style: 'currency',
13    currency: 'PLN'
14  }).format(amount);
15}
16
17export function validateEmail(email) {
18  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
19}
20
21// main.js - we import only what we need
22import { formatDate, formatCurrency } from './utils.js';
23
24// Tree shaking will remove formatTime and validateEmail from the final bundle
25console.log(formatDate(new Date()));
26console.log(formatCurrency(100));

Conditions requises pour le tree shaking

  1. Modules ES (ESM) - le tree shaking ne fonctionne qu'avec les modules ES6 :
1// Works with tree shaking (ES modules)
2import { specificFunction } from './module.js';
3
4// Does not work with tree shaking (CommonJS)
5const { specificFunction } = require('./module.js');
  1. Imports statiques - les imports doivent être statiques :
1// Static import - tree shaking works
2import { debounce } from 'lodash-es';
3
4// Dynamic import - tree shaking does not work
5const moduleName = 'lodash-es';
6import(moduleName).then(module => {
7  const { debounce } = module;
8});
  1. Exports nommés - privilégie les exports nommés plutôt que les exports par défaut :
1// Named exports - better for tree shaking
2export const add = (a, b) => a + b;
3export const subtract = (a, b) => a - b;
4
5// Default export with object - worse for tree shaking
6export default {
7  add: (a, b) => a + b,
8  subtract: (a, b) => a - b
9};

Exemples pratiques d'optimisation

Optimisation d'une bibliothèque CSS

1// Importing the entire Bootstrap library
2import 'bootstrap/dist/css/bootstrap.css';
3
4// Importing only needed modules (your own SCSS file)
5import './bootstrap-custom.scss';
1// bootstrap-custom.scss (Bootstrap 5): Sass partials are imported from SCSS, not from JavaScript
2@import 'bootstrap/scss/functions';
3@import 'bootstrap/scss/variables';
4@import 'bootstrap/scss/variables-dark';
5@import 'bootstrap/scss/maps';
6@import 'bootstrap/scss/mixins';
7@import 'bootstrap/scss/utilities';
8
9// Keep only display (d-*), margin (m-*) and padding (p-*) classes
10$utilities: map-get-multiple($utilities, ('display', 'margin', 'padding'));
11
12@import 'bootstrap/scss/utilities/api';

Optimisation d'une bibliothèque JavaScript

1// Importing the entire Lodash library (>70KB)
2import _ from 'lodash';
3const result = _.debounce(handleSearch, 300);
4
5// Importing only the needed function from lodash-es
6import { debounce } from 'lodash-es';
7const result = debounce(handleSearch, 300);
8
9// Or even better - single function
10import debounce from 'lodash-es/debounce';
11const result = debounce(handleSearch, 300);

Tree shaking dans React

1// Namespace import - with static access (MUI.Button) the bundler still drops unused components
2import * as MUI from '@mui/material';
3
4// Importing only needed components
5import { Button, TextField, Card } from '@mui/material';
6
7// Or individual imports
8import Button from '@mui/material/Button';
9import TextField from '@mui/material/TextField';
10import Card from '@mui/material/Card';

Configuration du bundler

Webpack

1// webpack.config.js
2module.exports = {
3  mode: 'production', // Enables tree shaking automatically
4  optimization: {
5    usedExports: true, // Marks used exports
6    sideEffects: true, // Honors the "sideEffects" field in package.json (default in production mode)
7    splitChunks: {
8      chunks: 'all',
9      cacheGroups: {
10        vendor: {
11          test: /[\\/]node_modules[\\/]/,
12          name: 'vendors',
13          chunks: 'all',
14        }
15      }
16    }
17  }
18};

Vite

1// vite.config.js - Vite 8+ (Rolldown); in Vite 7 and older: build.rollupOptions.output.manualChunks
2import { defineConfig } from 'vite';
3
4export default defineConfig({
5  build: {
6    rolldownOptions: {
7      output: {
8        codeSplitting: {
9          groups: [
10            { name: 'vendor', test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/ },
11            { name: 'utils', test: /[\\/]node_modules[\\/](lodash-es|date-fns)[\\/]/ }
12          ]
13        }
14      }
15    }
16  }
17});

Rollup

1// rollup.config.js
2export default {
3  input: 'src/main.js',
4  output: {
5    dir: 'dist',
6    format: 'es',
7    manualChunks: (id) => {
8      if (id.includes('node_modules')) {
9        return 'vendor';
10      }
11    }
12  },
13  external: ['react', 'react-dom']
14};

Techniques avancées

Preloading et prefetching

1// Preload - high priority, needed immediately
2const AdminPanel = lazy(() =>
3  import(/* webpackPreload: true */ './AdminPanel')
4);
5
6// Prefetch - low priority, likely needed later
7const UserProfile = lazy(() =>
8  import(/* webpackPrefetch: true */ './UserProfile')
9);

Analyse du bundle

Le plugin webpack-bundle-analyzer écrit à chaque build un rapport HTML avec une carte du bundle : chaque module est un rectangle dont la surface est proportionnelle à sa taille, ce qui montre tout de suite ce qui prend le plus de place :

1// Bundle analysis using webpack-bundle-analyzer
2const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
3
4module.exports = {
5  plugins: [
6    new BundleAnalyzerPlugin({
7      analyzerMode: 'static',
8      openAnalyzer: false,
9      generateStatsFile: true
10    })
11  ]
12};

Stratégies de chargement personnalisées

Tu peux aussi choisir la version d'un composant selon la qualité de la connexion fournie par la Network Information API (navigator.connection). Elle ne fonctionne que dans les navigateurs basés sur Chromium, donc dans les autres le code choisit la version allégée :

1// Loading strategy based on internet connection
2async function loadComponentBasedOnConnection() {
3  const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
4
5  if (connection && connection.effectiveType === '4g') {
6    // Fast connection - load full version
7    return import('./components/FullFeaturedComponent');
8  } else {
9    // Slow connection - load simplified version
10    return import('./components/LightweightComponent');
11  }
12}

Effets de bord et package.json

Le champ sideEffects du package.json d'une bibliothèque indique au bundler si ses modules ont des effets de bord. La valeur false signifie qu'aucun fichier n'en a, donc le bundler peut ignorer entièrement un module dont tu n'importes rien :

1{
2  "name": "my-library",
3  "version": "1.0.0",
4  "sideEffects": false,
5  "main": "dist/index.js",
6  "module": "dist/index.esm.js"
7}

Les fichiers qui ont des effets de bord et que le bundler doit garder sont listés dans un tableau :

1{
2  "sideEffects": [
3    "src/polyfills.js",
4    "src/global-styles.css",
5    "**/*.css"
6  ]
7}

Conseils pratiques

1. Surveiller la taille du bundle

Avant d'ajouter une bibliothèque au projet, vérifie combien elle pèse après minification et après compression gzip :

1# Using bundlephobia to check library size
2npm install -g bundle-phobia-cli
3bundle-phobia lodash
4bundle-phobia lodash-es

2. Webpack Bundle Analyzer

1npm install --save-dev webpack-bundle-analyzer
2npx webpack --profile --json=stats.json
3npx webpack-bundle-analyzer stats.json

3. Lighthouse CI pour le monitoring

1# .github/workflows/lighthouse.yml
2- name: Lighthouse CI
3  run: |
4    npm install -g @lhci/cli
5    lhci autorun

Exemple complet d'optimisation

1// Before optimization
2import React from 'react';
3import * as MUI from '@mui/material';
4import _ from 'lodash';
5import moment from 'moment';
6import './styles.css';
7
8function App() {
9  const [users, setUsers] = React.useState([]);
10
11  const debouncedSearch = _.debounce((query) => {
12    // Search users
13  }, 300);
14
15  return (
16    <MUI.Container>
17      <MUI.TextField onChange={(e) => debouncedSearch(e.target.value)} />
18      {users.map(user => (
19        <MUI.Card key={user.id}>
20          <MUI.Typography>
21            {user.name} - {moment(user.createdAt).format('DD/MM/YYYY')}
22          </MUI.Typography>
23        </MUI.Card>
24      ))}
25    </MUI.Container>
26  );
27}
1// After optimization
2import React, { useState, lazy, Suspense } from 'react';
3import { Container, TextField } from '@mui/material';
4import { debounce } from 'lodash-es';
5import { format } from 'date-fns';
6import { pl } from 'date-fns/locale';
7
8// Lazy loading for rarely used components
9const UserCard = lazy(() => import('./components/UserCard'));
10
11function App() {
12  const [users, setUsers] = useState([]);
13
14  const debouncedSearch = debounce((query) => {
15    // Search users
16  }, 300);
17
18  return (
19    <Container>
20      <TextField onChange={(e) => debouncedSearch(e.target.value)} />
21      <Suspense fallback={<div>Loading...</div>}>
22        {users.map(user => (
23          <UserCard
24            key={user.id}
25            name={user.name}
26            date={format(new Date(user.createdAt), 'dd/MM/yyyy', { locale: pl })}
27          />
28        ))}
29      </Suspense>
30    </Container>
31  );
32}

Bénéfices et résultats

Gains sur la taille du bundle

  • Lodash : 70 Ko -> 2 Ko (une seule fonction)
  • Moment.js : 230 Ko -> 11 Ko (date-fns)
  • Material-UI : 1,2 Mo -> 200 Ko (seulement les composants utilisés)
  • Bootstrap : 150 Ko -> 30 Ko (seulement les modules nécessaires)

Impact sur les performances

1// Performance metrics
2const performanceMetrics = {
3  beforeOptimization: {
4    bundleSize: '2.5MB',
5    firstContentfulPaint: '3.2s',
6    largestContentfulPaint: '4.8s',
7    timeToInteractive: '5.2s'
8  },
9  afterOptimization: {
10    bundleSize: '450KB',
11    firstContentfulPaint: '1.1s',
12    largestContentfulPaint: '1.8s',
13    timeToInteractive: '2.1s'
14  }
15};

Résumé

Le code splitting et le tree shaking sont des techniques d'optimisation fondamentales pour les applications web modernes. Elles te permettent de :

  1. Réduire la taille initiale du bundle - les utilisateurs ne téléchargent que le code nécessaire au premier rendu
  2. Accélérer le temps de chargement - des fichiers plus petits = des téléchargements plus rapides
  3. Améliorer l'expérience utilisateur - l'application est plus rapide et plus fluide
  4. Économiser de la bande passante - particulièrement important sur mobile
  5. Permettre une meilleure mise en cache - les bibliothèques externes changent rarement

La clé du succès est une approche systématique : des audits réguliers du bundle, une surveillance de la taille des bibliothèques et des décisions réfléchies sur les dépendances que l'on ajoute au projet.

Dans l’éditeur ci-dessous, tu calculeras sur des données d’exemple combien de kilo-octets un visiteur économise au démarrage grâce au code splitting et au tree shaking.

Code de cette leçon : index.js
1// Code splitting et tree shaking en chiffres : une simulation du bundle de Jurassic Park
2// Modifie les données ci-dessous et observe combien de kilo-octets un visiteur télécharge au démarrage.
3
4// static = un import classique (le module va dans le bundle initial)
5// dynamic = import('./fichier.js') (le bundler le place dans un chunk séparé, téléchargé à la demande)
6const modules = [
7  { file: 'app.js', kb: 45, load: 'static' },
8  { file: 'park-map.js', kb: 120, load: 'static' },
9  { file: 'admin-panel.js', kb: 310, load: 'dynamic' },
10  { file: 'genetics-charts.js', kb: 180, load: 'dynamic' },
11];
12
13const totalKb = (list) => list.reduce((sum, m) => sum + m.kb, 0);
14const initial = modules.filter((m) => m.load === 'static');
15const chunks = modules.filter((m) => m.load === 'dynamic');
16
17console.log('Sans code splitting au démarrage :', totalKb(modules), 'kB');
18console.log('Avec code splitting au démarrage :', totalKb(initial), 'kB');
19console.log('Chunks téléchargés à la demande :', chunks.map((m) => m.file + ' (' + m.kb + ' kB)').join(', '));
20
21// Tree shaking : seuls les exports que tu importes vont dans le bundle (tailles en kB)
22const parkUtils = { formatDate: 2, debounce: 3, deepClone: 6, chartEngine: 95 };
23const imported = ['formatDate', 'debounce'];
24const libraryKb = Object.values(parkUtils).reduce((sum, kb) => sum + kb, 0);
25const shakenKb = imported.reduce((sum, name) => sum + parkUtils[name], 0);
26console.log('Bibliothèque entière :', libraryKb, 'kB');
27console.log('Après le tree shaking :', shakenKb, 'kB');
28
29// Essaie : passe le load de park-map.js à 'dynamic' ou ajoute 'chartEngine' à imported.

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Qu'est-ce qui caractérise une fonction pure ?

  2. 2. Qu'est-ce qu'un état immuable ?

Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri vertical

    Range les étapes d'implémentation d'un état immuable :

  • Éditeur de code

    Le fichier index.html contient un conteneur #park-app et un script avec la classe ParkInterface, qui construit le tableau de bord du parc à partir des données parkData (l'apparence des panneaux est dans style.css). Complète les blancs : ___BLANK1___ est la méthode de tableau qui transforme chaque dinosaure en chaîne avec un élément <li>, ___BLANK2___ est la méthode de tableau qui réunit ces chaînes en une seule (sans virgules entre elles), et ___BLANK3___ est la propriété de l'élément par laquelle render() insère le HTML terminé dans le conteneur. Une fois la page lancée, #park-app doit contenir trois panneaux .panel avec des titres h2, le premier avec la liste de tous les dinosaures ; render() doit aussi fonctionner pour un autre conteneur et d'autres données.

  • Clique dans l'ordre

    Range les éléments du pattern Factory :

  • Éditeur de code

    Le fichier index.js contient une classe TimeSimulation. Complète les blancs : ___BLANK1___ est le nombre d'heures dans une journée, après lequel le compteur d'heures revient à 0 (tick() après 23 h donne 0 h et le jour suivant), ___BLANK2___ est l'opérateur logique d'isNight(), car la nuit couvre les heures de 20 à 23 et de 0 à 5, et ___BLANK3___ est la fonction du navigateur qui appelle tick() toutes les intervalMs millisecondes jusqu'à clearInterval. Un deuxième appel à start() ne doit pas lancer une deuxième boucle, et stop() l'arrête.

Articles utiles