Cours JavaScript et React · Module 10: Écosystème et avenir de React

Web Workers dans React

7 min de lecture
Dans cette leçon6

Imagine que l'ordinateur de bord principal de ton vaisseau (le main thread) doive à la fois rendre le cockpit, faire tourner les systèmes de navigation et recalculer la trajectoire de vol. Si les calculs de trajectoire sont trop lourds, le cockpit se fige et le capitaine ne peut plus cliquer sur aucun bouton. La solution ? Un calculateur dédié, le Web Worker, qui travaille en parallèle et ne bloque pas le système principal.

Que sont les Web Workers ?

Les Web Workers sont un mécanisme du navigateur qui exécute du code JavaScript dans un thread séparé. Le thread principal s'occupe du rendu de l'interface, de la gestion des événements et de l'interaction avec l'utilisateur, donc des calculs lourds sur ce thread figent l'interface. Voici à quoi ressemble le problème :

1// SANS Web Worker - bloque le main thread
2function HeavyCalculation() {
3  const [result, setResult] = useState(null);
4
5  const calculate = () => {
6    // Cela va bloquer l'interface pendant plusieurs secondes !
7    let sum = 0;
8    for (let i = 0; i < 1_000_000_000; i++) {
9      sum += Math.sqrt(i);
10    }
11    setResult(sum);
12  };
13
14  return (
15    <div>
16      <button onClick={calculate}>Calculer</button>
17      <p>Résultat : {result}</p>
18    </div>
19  );
20}

Une boucle d'un milliard d'itérations occupera le thread principal pendant plusieurs secondes : le bouton ne réagira pas, et React ne rendra même pas l'état de chargement. useState ne sauve rien ici, car le problème vient du thread occupé, pas de la façon de stocker le résultat.

Créer un Web Worker

Un worker est un fichier JavaScript séparé avec sa propre portée globale, self. Il ne communique avec le thread principal que par messages : postMessage envoie, et onmessage reçoit :

1// worker.js - fichier du Worker (thread séparé)
2self.onmessage = function(event) {
3  const { type, data } = event.data;
4
5  if (type === 'CALCULATE_TRAJECTORY') {
6    const result = calculateTrajectory(data);
7    self.postMessage({ type: 'RESULT', result });
8  }
9};
10
11function calculateTrajectory(params) {
12  let trajectory = [];
13  for (let t = 0; t < params.steps; t++) {
14    const x = params.speed * t * Math.cos(params.angle);
15    const y = params.speed * t * Math.sin(params.angle) - 0.5 * 9.81 * t * t;
16    trajectory.push({ x, y, t });
17  }
18  return trajectory;
19}

Le worker attend un message de type CALCULATE_TRAJECTORY, calcule et renvoie le résultat via self.postMessage. Il n'a accès ni au DOM ni à l'état de React, il ne connaît que ce qui est arrivé dans event.data.

Côté React, tu crées le worker une seule fois, dans un effet, et tu le gardes dans useRef :

1// App.jsx - composant React (thread principal)
2import { useState, useRef, useEffect } from 'react';
3
4function TrajectoryCalculator() {
5  const [result, setResult] = useState(null);
6  const [isCalculating, setIsCalculating] = useState(false);
7  const workerRef = useRef(null);
8
9  useEffect(() => {
10    workerRef.current = new Worker(new URL('./worker.js', import.meta.url));
11
12    workerRef.current.onmessage = (event) => {
13      if (event.data.type === 'RESULT') {
14        setResult(event.data.result);
15        setIsCalculating(false);
16      }
17    };
18
19    return () => workerRef.current?.terminate();
20  }, []);
21
22  const handleCalculate = () => {
23    setIsCalculating(true);
24    workerRef.current.postMessage({
25      type: 'CALCULATE_TRAJECTORY',
26      data: { speed: 1000, angle: 0.785, steps: 100000 }
27    });
28  };
29
30  return (
31    <div>
32      <button onClick={handleCalculate} disabled={isCalculating}>
33        {isCalculating ? 'Calcul en cours...' : 'Calculer la trajectoire'}
34      </button>
35      {result && <p>Points de trajectoire : {result.length}</p>}
36    </div>
37  );
38}

La notation new Worker(new URL('./worker.js', import.meta.url)) est reconnue par les bundlers comme webpack 5 et Vite, qui incluent eux-mêmes le fichier du worker. Le cleanup appelle terminate(), donc après le démontage du composant le thread ne continue pas à travailler. Les données de postMessage sont copiées avec l'algorithme structured clone, c'est pourquoi tu ne peux envoyer ni fonctions ni éléments du DOM.

Un hook personnalisé : useWorker

Pour ne pas répéter cette logique dans chaque composant, enferme-la dans un hook réutilisable :

1import { useState, useRef, useEffect, useCallback } from 'react';
2
3function useWorker(workerFactory) {
4  const [result, setResult] = useState(null);
5  const [error, setError] = useState(null);
6  const [isRunning, setIsRunning] = useState(false);
7  const workerRef = useRef(null);
8
9  useEffect(() => {
10    workerRef.current = workerFactory();
11
12    workerRef.current.onmessage = (event) => {
13      setResult(event.data);
14      setIsRunning(false);
15    };
16
17    workerRef.current.onerror = (err) => {
18      setError(err.message);
19      setIsRunning(false);
20    };
21
22    return () => workerRef.current?.terminate();
23  }, []);
24
25  const run = useCallback((data) => {
26    setIsRunning(true);
27    setError(null);
28    workerRef.current?.postMessage(data);
29  }, []);
30
31  return { result, error, isRunning, run };
32}

run envoie les données, et le hook gère lui-même les états result, error et isRunning. Tu passes une fabrique, par exemple () => new Worker(url), car le worker doit être créé dans l'effet, et non à chaque rendu.

Déballer les messages à la main devient vite fastidieux. La bibliothèque Comlink de Google Chrome Labs permet de traiter un worker comme un objet doté de méthodes asynchrones. Dans le fichier du worker, tu exposes un objet via Comlink.expose :

1// worker.js avec Comlink
2import * as Comlink from 'comlink';
3
4const navigationComputer = {
5  async calculateRoute(origin, destination) {
6    const route = computeOptimalPath(origin, destination);
7    return route;
8  },
9  async analyzeStarField(stars) {
10    return stars.map(star => ({
11      ...star,
12      habitableZone: calculateHabitableZone(star.mass, star.luminosity)
13    }));
14  }
15};
16
17Comlink.expose(navigationComputer);

Les méthodes calculateRoute et analyzeStarField tournent toujours dans un thread séparé, seule la façon de les appeler change.

Dans le composant, tu enveloppes le worker avec Comlink.wrap et tu appelles les méthodes comme des fonctions asynchrones ordinaires :

1// App.jsx avec Comlink
2import * as Comlink from 'comlink';
3
4function NavigationPanel() {
5  const [route, setRoute] = useState(null);
6  const navComputer = useRef(null);
7
8  useEffect(() => {
9    const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
10    navComputer.current = Comlink.wrap(worker);
11    return () => worker.terminate();
12  }, []);
13
14  const planRoute = async () => {
15    // On appelle la méthode comme une fonction async ordinaire !
16    const result = await navComputer.current.calculateRoute(
17      { x: 0, y: 0, z: 0 },
18      { x: 4.2, y: 1.3, z: 0.7 }
19    );
20    setRoute(result);
21  };
22
23  return (
24    <div>
25      <button onClick={planRoute}>Planifier l'itinéraire</button>
26      {route && <RouteVisualization data={route} />}
27    </div>
28  );
29}

Sous le capot, Comlink envoie toujours postMessage, et chaque appel renvoie une promesse, c'est pourquoi il faut await. Ce fichier de worker utilise des instructions import, donc new Worker reçoit l'option { type: 'module' } et le navigateur le lance comme worker module. Le mode développement de Vite s'appuie aussi là-dessus, et au build le bundler inclut les dépendances dans le fichier du worker.

Quand utiliser les Web Workers dans React ?

Les workers sont idéaux pour les calculs mathématiques lourds, comme les simulations et l'analyse de données, et pour l'analyse de grands ensembles de données, par exemple un fichier CSV de 500 000 lignes ou un JSON de plusieurs milliers d'enregistrements. Ils conviennent aussi au traitement d'images (filtres, compression) et à la recherche approximative dans de grandes collections.

Tu n'en as pas besoin pour de simples opérations sur l'état ni pour des requêtes API avec fetch ou axios, car attendre le réseau ne bloque pas le thread. Ils n'aideront pas non plus pour la manipulation du DOM, car les workers n'y ont pas accès. Je te recommande de mesurer d'abord dans l'onglet Performance si la tâche dure plus de 50 ms, car à partir de ce seuil le navigateur la considère comme longue, et seulement ensuite de la déplacer dans un worker, de préférence via Comlink.

Résumé

  1. Web Workers - un thread séparé pour les calculs lourds
  2. Communication - postMessage / onmessage
  3. Hook personnalisé useWorker - encapsulation de la logique du worker
  4. Comlink - une API simplifiée (des méthodes async au lieu de messages)
  5. Limites - pas d'accès au DOM, une portée globale séparée

Dans la leçon suivante, React Compiler s'attaque à la performance par l'autre bout : au lieu de déplacer le travail vers un autre thread, il saute les rendus inutiles.

Retiens : un Web Worker est un deuxième ordinateur de bord - il calcule la trajectoire en arrière-plan, pendant que le cockpit continue d'écouter le capitaine.

Code de cette leçon : App.jsx
1import React, { useState, useRef, useEffect, useCallback } from 'react';
2
3// === WEB WORKERS EN REACT ===
4// Simulation de calculs lourds dans un thread séparé
5
6// Hook personnalisé useWorker - encapsulation de la logique du Worker
7function useWorker() {
8  const [result, setResult] = useState(null);
9  const [isRunning, setIsRunning] = useState(false);
10  const [progress, setProgress] = useState(0);
11
12  // Simulation du Worker (dans Sandpack on ne peut pas créer un vrai fichier Worker)
13  const run = useCallback((params) => {
14    setIsRunning(true);
15    setProgress(0);
16    setResult(null);
17
18    // Simulation de calculs dans un "thread séparé" avec setTimeout
19    const totalSteps = params.steps || 10;
20    let currentStep = 0;
21    const trajectory = [];
22
23    const step = () => {
24      for (let i = 0; i < 1000; i++) {
25        const t = currentStep * 0.01;
26        const x = params.speed * t * Math.cos(params.angle);
27        const y = params.speed * t * Math.sin(params.angle) - 0.5 * 9.81 * t * t;
28        trajectory.push({ x: +x.toFixed(2), y: +y.toFixed(2), t: +t.toFixed(3) });
29        currentStep++;
30      }
31      setProgress(Math.min(100, Math.round((currentStep / (totalSteps * 1000)) * 100)));
32
33      if (currentStep < totalSteps * 1000) {
34        setTimeout(step, 0); // Rendre la main au thread principal
35      } else {
36        setResult(trajectory);
37        setIsRunning(false);
38      }
39    };
40    setTimeout(step, 0);
41  }, []);
42
43  return { result, isRunning, progress, run };
44}
45
46// Panneau de trajectoire - visualisation
47function TrajectoryPanel({ trajectory }) {
48  if (!trajectory || trajectory.length === 0) return null;
49
50  const maxX = Math.max(...trajectory.map(p => Math.abs(p.x)));
51  const maxY = Math.max(...trajectory.map(p => Math.abs(p.y)));
52  const scale = Math.max(maxX, maxY) || 1;
53
54  const validPoints = trajectory.filter(p => p.y >= 0).slice(0, 200);
55
56  return (
57    <div style={styles.trajectoryPanel}>
58      <h3 style={styles.sectionTitle}>Trajectoire de vol</h3>
59      <div style={styles.canvas}>
60        {validPoints.map((p, i) => (
61          <div
62            key={i}
63            style={{
64              position: 'absolute',
65              left: (p.x / scale * 45 + 50) + '%',
66              bottom: (p.y / scale * 80 + 5) + '%',
67              width: '3px', height: '3px',
68              borderRadius: '50%',
69              background: `hsl(${180 + (i / validPoints.length) * 60}, 100%, 60%)`,
70            }}
71          />
72        ))}
73      </div>
74      <p style={styles.stats}>
75        Points : {trajectory.length} | Max X: {maxX.toFixed(0)}m | Max Y: {Math.max(...trajectory.map(p => p.y)).toFixed(0)}m
76      </p>
77    </div>
78  );
79}
80
81// Panneau de contrôle - interactif (interface réactive pendant les calculs)
82function ControlPanel({ isRunning }) {
83  const [clicks, setClicks] = useState(0);
84
85  return (
86    <div style={styles.controlPanel}>
87      <h3 style={styles.sectionTitle}>Panneau de contrôle (thread principal)</h3>
88      <p style={styles.hint}>
89        {isRunning
90          ? 'Le Worker calcule... mais l’interface fonctionne toujours !'
91          : 'Clique sur le bouton pendant les calculs - l’interface ne se fige pas'}
92      </p>
93      <button onClick={() => setClicks(c => c + 1)} style={styles.clickBtn}>
94        Clics : {clicks}
95      </button>
96      <div style={{ ...styles.indicator, background: isRunning ? '#ffaa00' : '#00ff88' }}>
97        {isRunning ? 'WORKER ACTIF' : 'WORKER LIBRE'}
98      </div>
99    </div>
100  );
101}
102
103function App() {
104  const { result, isRunning, progress, run } = useWorker();
105  const [speed, setSpeed] = useState(500);
106  const [angle, setAngle] = useState(45);
107  const [steps, setSteps] = useState(5);
108
109  const handleLaunch = () => {
110    run({
111      speed,
112      angle: (angle * Math.PI) / 180,
113      steps,
114    });
115  };
116
117  return (
118    <div style={styles.container}>
119      <header style={styles.header}>
120        <h1 style={styles.title}>Web Workers - Calculs de trajectoire</h1>
121        <p style={styles.subtitle}>Calculs lourds sans bloquer l’interface</p>
122      </header>
123
124      <div style={styles.grid}>
125        <div style={styles.inputPanel}>
126          <h3 style={styles.sectionTitle}>Paramètres de vol</h3>
127          <label style={styles.label}>
128            Vitesse : {speed} m/s
129            <input type="range" min="100" max="2000" value={speed}
130              onChange={e => setSpeed(+e.target.value)} style={styles.slider} />
131          </label>
132          <label style={styles.label}>
133            Angle : {angle}°
134            <input type="range" min="10" max="80" value={angle}
135              onChange={e => setAngle(+e.target.value)} style={styles.slider} />
136          </label>
137          <label style={styles.label}>
138            Complexité : {steps}K étapes
139            <input type="range" min="1" max="20" value={steps}
140              onChange={e => setSteps(+e.target.value)} style={styles.slider} />
141          </label>
142
143          <button onClick={handleLaunch} disabled={isRunning} style={styles.launchBtn}>
144            {isRunning ? 'Calcul en cours...' : 'Calculer la trajectoire'}
145          </button>
146
147          {isRunning && (
148            <div style={styles.progressBar}>
149              <div style={{ ...styles.progressFill, width: progress + '%' }} />
150              <span style={styles.progressText}>{progress}%</span>
151            </div>
152          )}
153        </div>
154
155        <ControlPanel isRunning={isRunning} />
156      </div>
157
158      <TrajectoryPanel trajectory={result} />
159
160      <div style={styles.info}>
161        <h3 style={{ color: '#ffd700', fontSize: '13px', margin: '0 0 8px' }}>Comment ça marche :</h3>
162        <ul style={{ margin: 0, paddingLeft: '20px', fontSize: '12px', color: '#8892b0' }}>
163          <li>Les calculs de trajectoire se font en "arrière-plan" (simulation de Web Worker)</li>
164          <li>Le panneau de contrôle réagit aux clics PENDANT les calculs</li>
165          <li>Dans une vraie appli : new Worker() crée un thread séparé</li>
166          <li>Communication : postMessage() / onmessage</li>
167        </ul>
168      </div>
169    </div>
170  );
171}
172
173const styles = {
174  container: { fontFamily: 'system-ui', background: '#0a0e17', color: '#e0e1dd', minHeight: '100vh', padding: '16px' },
175  header: { textAlign: 'center', marginBottom: '16px' },
176  title: { fontSize: '20px', color: '#00d4ff', margin: '0 0 4px' },
177  subtitle: { fontSize: '12px', color: '#8892b0', margin: 0 },
178  grid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' },
179  inputPanel: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '14px', border: '1px solid rgba(0,212,255,0.15)' },
180  controlPanel: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '14px', border: '1px solid rgba(0,212,255,0.15)' },
181  sectionTitle: { fontSize: '14px', color: '#00d4ff', margin: '0 0 10px' },
182  label: { display: 'block', fontSize: '12px', color: '#8892b0', marginBottom: '10px' },
183  slider: { width: '100%', marginTop: '4px' },
184  launchBtn: { width: '100%', padding: '10px', background: 'linear-gradient(135deg, #00d4ff, #0088cc)', color: '#0a0e17', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold', fontSize: '13px' },
185  progressBar: { marginTop: '8px', height: '20px', background: '#1a2a3c', borderRadius: '10px', position: 'relative', overflow: 'hidden' },
186  progressFill: { height: '100%', background: 'linear-gradient(90deg, #00d4ff, #00ff88)', borderRadius: '10px', transition: 'width 0.3s' },
187  progressText: { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: '11px', fontWeight: 'bold', color: '#0a0e17' },
188  hint: { fontSize: '12px', color: '#8892b0', marginBottom: '10px' },
189  clickBtn: { width: '100%', padding: '12px', background: '#1a3a5c', color: '#00d4ff', border: '1px solid rgba(0,212,255,0.3)', borderRadius: '6px', cursor: 'pointer', fontSize: '16px', fontWeight: 'bold', marginBottom: '10px' },
190  indicator: { textAlign: 'center', padding: '6px', borderRadius: '4px', fontSize: '11px', fontWeight: 'bold', color: '#0a0e17' },
191  trajectoryPanel: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '14px', border: '1px solid rgba(0,212,255,0.15)', marginBottom: '12px' },
192  canvas: { position: 'relative', height: '200px', background: 'rgba(0,0,0,0.4)', borderRadius: '6px', overflow: 'hidden' },
193  stats: { fontSize: '12px', color: '#8892b0', marginTop: '8px', textAlign: 'center' },
194  info: { padding: '12px', background: 'rgba(255,215,0,0.05)', border: '1px solid rgba(255,215,0,0.15)', borderRadius: '8px' },
195};
196
197export default App;

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. Quel mécanisme est utilisé pour la communication entre le thread principal et un Web Worker ?

  2. 2. Quelle tâche est un candidat IDÉAL pour être déplacée vers un Web Worker ?

Articles utiles