Cours JavaScript et TypeScript · Module 10: TypeScript en pratique

Web Workers pour les calculs lourds

29 min de lecture
Dans cette leçon7

Imagine que le laboratoire du parc séquence l'ADN du T-Rex et que, pendant ce temps, la salle de contrôle se fige : les caméras sont bloquées et le bouton d'alarme ne répond plus. C'est ainsi que se comporte le navigateur quand des calculs lourds atterrissent dans le thread principal. Les Web Workers sont une technologie qui permet d'exécuter du JavaScript dans des threads séparés, indépendamment du thread principal de l'UI (User Interface), ce qui garde l'interface réactive.

Ce que tu vas apprendre :

  • déplacer des calculs vers un thread séparé et dialoguer avec lui via postMessage
  • reconnaître les limites d'un worker et distinguer Dedicated, Shared et Service Worker
  • construire un pool de workers et partager de la mémoire grâce à SharedArrayBuffer
  • mesurer les performances et gérer les pannes des workers

Pourquoi les Web Workers sont-ils nécessaires ?

Le problème de la nature monothread de JavaScript

Ton code s'exécute dans un seul thread principal, qui gère aussi les clics, le défilement et le dessin de la page. Regarde ce qu'une longue boucle lui fait subir :

1// Problème : des calculs bloquants dans le thread principal
2function heavyCalculation(n) {
3  console.log('Début des calculs lourds...');
4
5  // Simulation de calculs intensifs
6  let result = 0;
7  for (let i = 0; i < n; i++) {
8    for (let j = 0; j < 1000000; j++) {
9      result += Math.sqrt(i * j);
10    }
11  }
12
13  console.log('Calculs terminés');
14  return result;
15}
16
17// Cela va bloquer l'UI pendant plusieurs secondes !
18document.getElementById('calculate-button').addEventListener('click', () => {
19  const result = heavyCalculation(5000);
20  document.getElementById('result').textContent = result;
21});
22
23// Pendant les calculs, l'utilisateur ne peut pas :
24// - Cliquer sur d'autres boutons
25// - Faire défiler la page
26// - Profiter des animations
27// - Saisir du texte dans les formulaires

Tant que les calculs durent, la boucle d'événements (event loop) ne traite ni clic ni image d'animation. C'est précisément ce problème que résolvent les Web Workers.

Comparaison des performances

Un worker ne calcule pas plus vite, car c'est le même moteur JavaScript. La différence tient à qui attend. Cette ébauche de benchmark compare les deux approches :

1// Benchmark: Main Thread vs Web Worker
2const performanceTest = {
3  // Test sans Web Worker - bloque l'UI
4  async testMainThread() {
5    console.time('Main Thread Calculation');
6
7    const start = performance.now();
8    const result = this.calculatePrimes(100000);
9    const end = performance.now();
10
11    console.timeEnd('Main Thread Calculation');
12
13    return {
14      result: result.length,
15      time: end - start,
16      blocked: true
17    };
18  },
19
20  // Test avec Web Worker - ne bloque pas l'UI
21  async testWebWorker() {
22    console.time('Web Worker Calculation');
23
24    const start = performance.now();
25    const result = await this.calculatePrimesWorker(100000);
26    const end = performance.now();
27
28    console.timeEnd('Web Worker Calculation');
29
30    return {
31      result: result.length,
32      time: end - start,
33      blocked: false
34    };
35  },
36
37  calculatePrimes(max) {
38    const primes = [];
39    for (let i = 2; i <= max; i++) {
40      if (this.isPrime(i)) {
41        primes.push(i);
42      }
43    }
44    return primes;
45  },
46
47  isPrime(num) {
48    for (let i = 2; i <= Math.sqrt(num); i++) {
49      if (num % i === 0) return false;
50    }
51    return true;
52  }
53};

Le temps est parfois similaire, mais avec un worker, l'interface continue de réagir du début à la fin.

Implémentations de base des Web Workers

Créer un Web Worker simple

Tu crées un worker avec le constructeur new Worker('worker.js'), qui lance le script dans un thread séparé. L'ordre compte : installe d'abord l'écoute onmessage, envoie ensuite les données avec postMessage, et termine enfin le travail avec la méthode terminate() :

1// main.js - le thread principal
2class WebWorkerManager {
3  constructor() {
4    this.worker = null;
5    this.setupWorker();
6  }
7
8  setupWorker() {
9    // Vérification de la prise en charge des Web Workers
10    if (typeof Worker !== 'undefined') {
11      this.worker = new Worker('worker.js');
12
13      // Gestion des messages venant du worker
14      this.worker.onmessage = (event) => {
15        this.handleWorkerMessage(event.data);
16      };
17
18      // Gestion des erreurs
19      this.worker.onerror = (error) => {
20        console.error('Worker error:', error);
21      };
22
23      console.log('Le Web Worker a été créé');
24    } else {
25      console.warn('Les Web Workers ne sont pas pris en charge par ce navigateur');
26    }
27  }
28
29  // Envoi d'une tâche au worker
30  sendTask(taskType, data) {
31    if (this.worker) {
32      this.worker.postMessage({
33        type: taskType,
34        data: data,
35        timestamp: Date.now()
36      });
37    }
38  }
39
40  // Gestion des réponses du worker
41  handleWorkerMessage(message) {
42    switch (message.type) {
43      case 'CALCULATION_COMPLETE':
44        this.displayResult(message.result);
45        break;
46      case 'PROGRESS_UPDATE':
47        this.updateProgress(message.progress);
48        break;
49      case 'ERROR':
50        this.handleError(message.error);
51        break;
52    }
53  }
54
55  // Fin du travail du worker
56  terminateWorker() {
57    if (this.worker) {
58      this.worker.terminate();
59      this.worker = null;
60      console.log('Le Web Worker a été arrêté');
61    }
62  }
63}
64
65// Utilisation
66const workerManager = new WebWorkerManager();
67
68// Envoi d'une tâche de calcul
69document.getElementById('start-calculation').addEventListener('click', () => {
70  workerManager.sendTask('CALCULATE_PRIMES', { max: 100000 });
71});

Tu lis la réponse dans event.data, en affectant une fonction à onmessage ou avec addEventListener('message', ...). Les données ne sont pas partagées : le navigateur les copie avec l'algorithme structured clone, donc tu ne peux envoyer ni fonctions ni éléments du DOM.

Implémentation de worker.js

De l'autre côté, le worker écoute via self.onmessage, répond via self.postMessage et peut signaler sa progression :

1// worker.js - le code exécuté dans un thread séparé
2class WorkerCalculations {
3  constructor() {
4    this.setupMessageHandler();
5  }
6
7  setupMessageHandler() {
8    // Handler principal des messages venant du thread principal
9    self.onmessage = (event) => {
10      const { type, data, timestamp } = event.data;
11
12      try {
13        this.handleTask(type, data, timestamp);
14      } catch (error) {
15        this.sendError(error.message);
16      }
17    };
18  }
19
20  handleTask(type, data, timestamp) {
21    switch (type) {
22      case 'CALCULATE_PRIMES':
23        this.calculatePrimes(data.max);
24        break;
25      case 'PROCESS_IMAGE':
26        this.processImage(data.imageData);
27        break;
28      case 'SORT_LARGE_ARRAY':
29        this.sortArray(data.array);
30        break;
31      case 'MONTE_CARLO_PI':
32        this.calculatePiMonteCarlo(data.iterations);
33        break;
34      default:
35        this.sendError(`Unknown task type: ${type}`);
36    }
37  }
38
39  // Calcul des nombres premiers avec suivi de la progression
40  calculatePrimes(max) {
41    const primes = [];
42    const updateInterval = Math.floor(max / 100); // Mise à jour tous les 1 %
43
44    for (let i = 2; i <= max; i++) {
45      if (this.isPrime(i)) {
46        primes.push(i);
47      }
48
49      // Signalement de la progression
50      if (i % updateInterval === 0) {
51        const progress = (i / max) * 100;
52        this.sendProgress(progress);
53      }
54    }
55
56    this.sendResult('CALCULATION_COMPLETE', {
57      primes: primes,
58      count: primes.length,
59      executionTime: performance.now()
60    });
61  }
62
63  isPrime(num) {
64    if (num < 2) return false;
65    for (let i = 2; i <= Math.sqrt(num); i++) {
66      if (num % i === 0) return false;
67    }
68    return true;
69  }
70
71  // Traitement d'image (filtrage, transformations)
72  processImage(imageData) {
73    const { data, width, height, filterType } = imageData;
74
75    switch (filterType) {
76      case 'grayscale':
77        this.applyGrayscaleFilter(data);
78        break;
79      case 'blur':
80        this.applyBlurFilter(data, width, height);
81        break;
82      case 'edge':
83        this.applyEdgeDetection(data, width, height);
84        break;
85    }
86
87    this.sendResult('IMAGE_PROCESSED', {
88      processedData: data,
89      filterApplied: filterType
90    });
91  }
92
93  applyGrayscaleFilter(data) {
94    for (let i = 0; i < data.length; i += 4) {
95      const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114;
96      data[i] = gray;     // Red
97      data[i + 1] = gray; // Green
98      data[i + 2] = gray; // Blue
99      // Alpha (i + 3) remains unchanged
100    }
101  }
102
103  // Tri de grands tableaux
104  sortArray(array) {
105    const startTime = performance.now();
106
107    // Implémentation du merge sort pour de meilleures performances
108    const sortedArray = this.mergeSort(array);
109
110    const endTime = performance.now();
111
112    this.sendResult('SORT_COMPLETE', {
113      sortedArray: sortedArray,
114      originalLength: array.length,
115      executionTime: endTime - startTime
116    });
117  }
118
119  mergeSort(arr) {
120    if (arr.length <= 1) return arr;
121
122    const mid = Math.floor(arr.length / 2);
123    const left = this.mergeSort(arr.slice(0, mid));
124    const right = this.mergeSort(arr.slice(mid));
125
126    return this.merge(left, right);
127  }
128
129  merge(left, right) {
130    const result = [];
131    let leftIndex = 0;
132    let rightIndex = 0;
133
134    while (leftIndex < left.length && rightIndex < right.length) {
135      if (left[leftIndex] <= right[rightIndex]) {
136        result.push(left[leftIndex]);
137        leftIndex++;
138      } else {
139        result.push(right[rightIndex]);
140        rightIndex++;
141      }
142    }
143
144    return result.concat(left.slice(leftIndex), right.slice(rightIndex));
145  }
146
147  // Calcul de Pi par la méthode de Monte-Carlo
148  calculatePiMonteCarlo(iterations) {
149    let pointsInCircle = 0;
150    const updateInterval = Math.floor(iterations / 100);
151
152    for (let i = 0; i < iterations; i++) {
153      const x = Math.random() * 2 - 1; // de -1 à 1
154      const y = Math.random() * 2 - 1; // de -1 à 1
155
156      if (x * x + y * y <= 1) {
157        pointsInCircle++;
158      }
159
160      if (i % updateInterval === 0) {
161        const currentPi = (pointsInCircle / i) * 4;
162        const progress = (i / iterations) * 100;
163
164        this.sendProgress(progress, { currentPi, iteration: i });
165      }
166    }
167
168    const finalPi = (pointsInCircle / iterations) * 4;
169
170    this.sendResult('PI_CALCULATION_COMPLETE', {
171      pi: finalPi,
172      iterations: iterations,
173      accuracy: Math.abs(Math.PI - finalPi)
174    });
175  }
176
177  // Méthodes auxiliaires pour la communication
178  sendResult(type, result) {
179    self.postMessage({
180      type: type,
181      result: result,
182      timestamp: Date.now()
183    });
184  }
185
186  sendProgress(progress, additionalData = {}) {
187    self.postMessage({
188      type: 'PROGRESS_UPDATE',
189      progress: progress,
190      ...additionalData,
191      timestamp: Date.now()
192    });
193  }
194
195  sendError(errorMessage) {
196    self.postMessage({
197      type: 'ERROR',
198      error: errorMessage,
199      timestamp: Date.now()
200    });
201  }
202}
203
204// Initialisation du worker
205new WorkerCalculations();

Le worker a sa propre portée globale (self) et n'a accès ni au DOM, ni à document, ni à window. En revanche, il peut utiliser fetch, XMLHttpRequest, setTimeout, setInterval et IndexedDB. Le cycle de vie se présente donc ainsi : création, postMessage(), travail dans le thread du worker et réception du résultat dans onmessage.

Patterns avancés des Web Workers

Worker Pool pour les calculs parallèles

Un pool crée plusieurs workers, généralement autant que de cœurs indiqués par navigator.hardwareConcurrency, et leur répartit les tâches d'une file d'attente :

1// WorkerPool.js - gestion de plusieurs workers
2class WorkerPool {
3  constructor(workerScript, poolSize = navigator.hardwareConcurrency || 4) {
4    this.workerScript = workerScript;
5    this.poolSize = poolSize;
6    this.workers = [];
7    this.taskQueue = [];
8    this.activeTasksCount = 0;
9
10    this.initializePool();
11  }
12
13  initializePool() {
14    for (let i = 0; i < this.poolSize; i++) {
15      const worker = {
16        instance: new Worker(this.workerScript),
17        busy: false,
18        id: i
19      };
20
21      worker.instance.onmessage = (event) => {
22        this.handleWorkerMessage(worker, event.data);
23      };
24
25      worker.instance.onerror = (error) => {
26        this.handleWorkerError(worker, error);
27      };
28
29      this.workers.push(worker);
30    }
31
32    console.log(`Worker pool initialized with ${this.poolSize} workers`);
33  }
34
35  // Exécution d'une tâche à l'aide du pool
36  execute(taskData) {
37    return new Promise((resolve, reject) => {
38      const task = {
39        data: taskData,
40        resolve: resolve,
41        reject: reject,
42        id: this.generateTaskId()
43      };
44
45      const availableWorker = this.getAvailableWorker();
46
47      if (availableWorker) {
48        this.assignTaskToWorker(availableWorker, task);
49      } else {
50        this.taskQueue.push(task);
51      }
52    });
53  }
54
55  getAvailableWorker() {
56    return this.workers.find(worker => !worker.busy);
57  }
58
59  assignTaskToWorker(worker, task) {
60    worker.busy = true;
61    worker.currentTask = task;
62    this.activeTasksCount++;
63
64    worker.instance.postMessage({
65      taskId: task.id,
66      ...task.data
67    });
68  }
69
70  handleWorkerMessage(worker, message) {
71    const task = worker.currentTask;
72
73    if (message.type === 'TASK_COMPLETE') {
74      task.resolve(message.result);
75      this.releaseWorker(worker);
76    } else if (message.type === 'TASK_ERROR') {
77      task.reject(new Error(message.error));
78      this.releaseWorker(worker);
79    } else if (message.type === 'PROGRESS_UPDATE') {
80      // Transmission de la mise à jour de progression (optionnel)
81      if (task.onProgress) {
82        task.onProgress(message.progress);
83      }
84    }
85  }
86
87  releaseWorker(worker) {
88    worker.busy = false;
89    worker.currentTask = null;
90    this.activeTasksCount--;
91
92    // Vérifier s'il reste des tâches dans la file
93    if (this.taskQueue.length > 0) {
94      const nextTask = this.taskQueue.shift();
95      this.assignTaskToWorker(worker, nextTask);
96    }
97  }
98
99  handleWorkerError(worker, error) {
100    console.error(`Worker ${worker.id} error:`, error);
101
102    if (worker.currentTask) {
103      worker.currentTask.reject(error);
104      this.releaseWorker(worker);
105    }
106  }
107
108  // Traitement parallèle de grands ensembles de données
109  async processInParallel(dataArray, chunkProcessor) {
110    const chunkSize = Math.ceil(dataArray.length / this.poolSize);
111    const chunks = [];
112
113    // Découpage des données en chunks
114    for (let i = 0; i < dataArray.length; i += chunkSize) {
115      chunks.push(dataArray.slice(i, i + chunkSize));
116    }
117
118    // Traitement parallèle des chunks
119    const promises = chunks.map(chunk =>
120      this.execute({
121        type: 'PROCESS_CHUNK',
122        chunk: chunk,
123        processor: chunkProcessor.toString()
124      })
125    );
126
127    const results = await Promise.all(promises);
128
129    // Fusion des résultats
130    return results.flat();
131  }
132
133  generateTaskId() {
134    return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
135  }
136
137  // Arrêt de tous les workers
138  terminate() {
139    this.workers.forEach(worker => {
140      worker.instance.terminate();
141    });
142
143    this.workers = [];
144    this.taskQueue = [];
145    console.log('Worker pool terminated');
146  }
147
148  // Statistiques du pool
149  getStats() {
150    return {
151      poolSize: this.poolSize,
152      activeWorkers: this.workers.filter(w => w.busy).length,
153      queueLength: this.taskQueue.length,
154      activeTasks: this.activeTasksCount
155    };
156  }
157}
158
159// Utilisation du Worker Pool
160const workerPool = new WorkerPool('calculation-worker.js', 8);
161
162// Exemple de traitement parallèle
163async function processLargeDataset() {
164  const largeArray = Array.from({ length: 1000000 }, (_, i) => Math.random() * 1000);
165
166  try {
167    const results = await workerPool.processInParallel(largeArray, (chunk) => {
168      // La fonction de traitement sera exécutée dans un worker
169      return chunk.map(x => Math.sqrt(x * x + 1));
170    });
171
172    console.log('Traitement terminé :', results.length);
173    console.log('Stats:', workerPool.getStats());
174  } catch (error) {
175    console.error('Erreur de traitement :', error);
176  }
177}

Quand tous les workers sont occupés, la tâche attend dans la file, et releaseWorker transmet aussitôt la suivante. chunkProcessor arrive dans le worker sous forme de texte, car postMessage ne transporte pas les fonctions.

Shared Array Buffer pour une communication efficace

SharedArrayBuffer est une mémoire visible par plusieurs threads à la fois, donc les workers écrivent les résultats de la multiplication de matrices directement dans le tampon commun, sans copie :

1// SharedArrayBufferWorker.js - mémoire partagée entre les workers
2class SharedArrayBufferManager {
3  constructor() {
4    this.checkSupport();
5  }
6
7  checkSupport() {
8    if (typeof SharedArrayBuffer === 'undefined') {
9      console.warn("SharedArrayBuffer n'est pas pris en charge");
10      this.fallbackToTransferableObjects();
11      return false;
12    }
13    return true;
14  }
15
16  // Création d'une mémoire partagée pour les calculs de matrices
17  createSharedMatrixBuffer(rows, cols) {
18    const bufferSize = rows * cols * Float64Array.BYTES_PER_ELEMENT;
19    const sharedBuffer = new SharedArrayBuffer(bufferSize);
20    const matrix = new Float64Array(sharedBuffer);
21
22    return {
23      buffer: sharedBuffer,
24      matrix: matrix,
25      rows: rows,
26      cols: cols,
27
28      // Méthodes auxiliaires (elles utilisent la variable matrix, car dans une fonction fléchée, this désigne le gestionnaire)
29      get: (row, col) => {
30        return matrix[row * cols + col];
31      },
32
33      set: (row, col, value) => {
34        matrix[row * cols + col] = value;
35      },
36
37      fill: (value) => {
38        matrix.fill(value);
39      }
40    };
41  }
42
43  // Multiplication de matrices en parallèle
44  async parallelMatrixMultiply(matrixA, matrixB, workerCount = 4) {
45    const rowsA = matrixA.rows;
46    const colsA = matrixA.cols;
47    const colsB = matrixB.cols;
48
49    // Création de la matrice de résultat partagée
50    const resultMatrix = this.createSharedMatrixBuffer(rowsA, colsB);
51
52    // Répartition du travail entre les workers
53    const rowsPerWorker = Math.ceil(rowsA / workerCount);
54    const workers = [];
55
56    for (let i = 0; i < workerCount; i++) {
57      const startRow = i * rowsPerWorker;
58      const endRow = Math.min(startRow + rowsPerWorker, rowsA);
59
60      if (startRow < rowsA) {
61        const worker = new Worker('matrix-worker.js');
62
63        const promise = new Promise((resolve, reject) => {
64          worker.onmessage = (event) => {
65            if (event.data.type === 'MATRIX_COMPLETE') {
66              worker.terminate();
67              resolve(event.data.result);
68            }
69          };
70
71          worker.onerror = reject;
72        });
73
74        worker.postMessage({
75          type: 'MULTIPLY_MATRIX_ROWS',
76          matrixABuffer: matrixA.buffer,
77          matrixBBuffer: matrixB.buffer,
78          resultBuffer: resultMatrix.buffer,
79          dimensions: {
80            rowsA: rowsA,
81            colsA: colsA,
82            colsB: colsB
83          },
84          rowRange: { start: startRow, end: endRow }
85        });
86
87        workers.push(promise);
88      }
89    }
90
91    await Promise.all(workers);
92    return resultMatrix;
93  }
94
95  fallbackToTransferableObjects() {
96    console.log('Utilisation des Transferable Objects comme alternative');
97    // Implémentation avec des Transferable Objects
98  }
99}
100
101// matrix-worker.js - worker pour les calculs de matrices
102self.onmessage = function(event) {
103  const { type, matrixABuffer, matrixBBuffer, resultBuffer, dimensions, rowRange } = event.data;
104
105  if (type === 'MULTIPLY_MATRIX_ROWS') {
106    const matrixA = new Float64Array(matrixABuffer);
107    const matrixB = new Float64Array(matrixBBuffer);
108    const result = new Float64Array(resultBuffer);
109
110    const { rowsA, colsA, colsB } = dimensions;
111    const { start, end } = rowRange;
112
113    // Multiplication de matrices pour les lignes attribuées
114    for (let i = start; i < end; i++) {
115      for (let j = 0; j < colsB; j++) {
116        let sum = 0;
117        for (let k = 0; k < colsA; k++) {
118          sum += matrixA[i * colsA + k] * matrixB[k * colsB + j];
119        }
120        result[i * colsB + j] = sum;
121      }
122    }
123
124    self.postMessage({
125      type: 'MATRIX_COMPLETE',
126      result: `Rows ${start}-${end} completed`
127    });
128  }
129};

Chaque worker calcule des lignes différentes, ils ne se marchent donc pas sur les pieds. Quand des threads modifient les mêmes cellules, utilise Atomics (par exemple Atomics.add et Atomics.wait), dont les opérations sont indivisibles. Les navigateurs ne donnent accès à SharedArrayBuffer qu'aux pages isolées par les en-têtes Cross-Origin-Opener-Policy: same-origin et Cross-Origin-Embedder-Policy: require-corp.

Service Worker pour le Background Processing

En plus du worker ordinaire (dedicated), il en existe deux autres types. Le Shared Worker, créé avec new SharedWorker(), est partagé par les onglets et les fenêtres de la même origine (origin). Le Service Worker se place entre la page et le réseau, et gère le cache, le mode hors ligne ainsi que la synchronisation en arrière-plan :

1// ServiceWorkerManager.js - gestion des tâches longues en arrière-plan
2class ServiceWorkerManager {
3  constructor() {
4    this.serviceWorker = null;
5    this.messageChannel = null;
6    this.setupServiceWorker();
7  }
8
9  async setupServiceWorker() {
10    if ('serviceWorker' in navigator) {
11      try {
12        const registration = await navigator.serviceWorker.register('background-worker.js');
13
14        // Attente de l'activation du service worker
15        await navigator.serviceWorker.ready;
16
17        this.serviceWorker = registration.active || registration.waiting || registration.installing;
18        this.setupMessageChannel();
19
20        console.log('Service Worker enregistré et prêt');
21      } catch (error) {
22        console.error("Erreur d'enregistrement du Service Worker :", error);
23      }
24    }
25  }
26
27  setupMessageChannel() {
28    this.messageChannel = new MessageChannel();
29
30    // Gestion des messages venant du Service Worker
31    this.messageChannel.port1.onmessage = (event) => {
32      this.handleServiceWorkerMessage(event.data);
33    };
34
35    // Envoi du port au Service Worker
36    navigator.serviceWorker.controller?.postMessage(
37      { type: 'INIT_PORT' },
38      [this.messageChannel.port2]
39    );
40  }
41
42  // Confier une tâche longue au Service Worker
43  scheduleBackgroundTask(taskConfig) {
44    if (this.messageChannel) {
45      this.messageChannel.port1.postMessage({
46        type: 'SCHEDULE_TASK',
47        task: {
48          id: this.generateTaskId(),
49          ...taskConfig,
50          scheduledAt: Date.now()
51        }
52      });
53    }
54  }
55
56  // Exemple : synchronisation périodique des données
57  scheduleDataSync(interval = 300000) { // 5 minutes
58    this.scheduleBackgroundTask({
59      type: 'DATA_SYNC',
60      interval: interval,
61      endpoint: '/api/sync',
62      recurring: true
63    });
64  }
65
66  // Exemple : traitement de fichiers en arrière-plan
67  scheduleFileProcessing(files) {
68    files.forEach(file => {
69      this.scheduleBackgroundTask({
70        type: 'FILE_PROCESSING',
71        fileName: file.name,
72        fileData: file,
73        processor: 'image-compression'
74      });
75    });
76  }
77
78  handleServiceWorkerMessage(message) {
79    switch (message.type) {
80      case 'TASK_COMPLETED':
81        this.onTaskCompleted(message.task, message.result);
82        break;
83      case 'TASK_FAILED':
84        this.onTaskFailed(message.task, message.error);
85        break;
86      case 'SYNC_COMPLETED':
87        this.onSyncCompleted(message.result);
88        break;
89    }
90  }
91
92  onTaskCompleted(task, result) {
93    console.log(`Tâche ${task.id} terminée :`, result);
94
95    // Mise à jour de l'UI ou de l'état de l'application
96    this.updateApplicationState(task, result);
97  }
98
99  generateTaskId() {
100    return `bg_task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
101  }
102}
103
104// background-worker.js - Service Worker
105class BackgroundTaskProcessor {
106  constructor() {
107    this.activeTasks = new Map();
108    this.messagePort = null;
109    this.setupEventListeners();
110  }
111
112  setupEventListeners() {
113    self.addEventListener('message', (event) => {
114      if (event.data.type === 'INIT_PORT') {
115        this.messagePort = event.ports[0];
116        this.setupPortListener();
117      }
118    });
119
120    // Background Sync
121    self.addEventListener('sync', (event) => {
122      if (event.tag === 'background-data-sync') {
123        event.waitUntil(this.performDataSync());
124      }
125    });
126  }
127
128  setupPortListener() {
129    this.messagePort.onmessage = (event) => {
130      this.handleTask(event.data);
131    };
132  }
133
134  async handleTask(message) {
135    if (message.type === 'SCHEDULE_TASK') {
136      const task = message.task;
137
138      try {
139        const result = await this.executeTask(task);
140
141        this.messagePort.postMessage({
142          type: 'TASK_COMPLETED',
143          task: task,
144          result: result
145        });
146      } catch (error) {
147        this.messagePort.postMessage({
148          type: 'TASK_FAILED',
149          task: task,
150          error: error.message
151        });
152      }
153    }
154  }
155
156  async executeTask(task) {
157    switch (task.type) {
158      case 'DATA_SYNC':
159        return await this.performDataSync(task);
160      case 'FILE_PROCESSING':
161        return await this.processFile(task);
162      case 'BATCH_CALCULATION':
163        return await this.performBatchCalculation(task);
164      default:
165        throw new Error(`Unknown task type: ${task.type}`);
166    }
167  }
168
169  async performDataSync(task) {
170    try {
171      const response = await fetch(task.endpoint, {
172        method: 'POST',
173        headers: { 'Content-Type': 'application/json' },
174        body: JSON.stringify({ timestamp: Date.now() })
175      });
176
177      if (!response.ok) {
178        throw new Error(`Sync failed: ${response.status}`);
179      }
180
181      const data = await response.json();
182
183      // Enregistrement dans IndexedDB ou dans la Cache API
184      await this.storeDataLocally(data);
185
186      return { success: true, syncedRecords: data.length };
187    } catch (error) {
188      throw new Error(`Data sync failed: ${error.message}`);
189    }
190  }
191
192  async processFile(task) {
193    // Simulation du traitement d'un fichier
194    const { fileName, processor } = task;
195
196    switch (processor) {
197      case 'image-compression':
198        return await this.compressImage(task.fileData);
199      case 'video-transcoding':
200        return await this.transcodeVideo(task.fileData);
201      default:
202        throw new Error(`Unknown processor: ${processor}`);
203    }
204  }
205
206  async storeDataLocally(data) {
207    // Implémentation de l'enregistrement dans IndexedDB
208    return new Promise((resolve, reject) => {
209      const request = indexedDB.open('AppDatabase', 1);
210
211      request.onsuccess = (event) => {
212        const db = event.target.result;
213        const transaction = db.transaction(['syncedData'], 'readwrite');
214        const store = transaction.objectStore('syncedData');
215
216        store.put({ id: 'latest', data: data, timestamp: Date.now() });
217
218        transaction.oncomplete = () => resolve();
219        transaction.onerror = () => reject(transaction.error);
220      };
221
222      request.onerror = () => reject(request.error);
223    });
224  }
225}
226
227new BackgroundTaskProcessor();

Le Service Worker ne convient pas aux longs calculs, car le navigateur l'endort dès qu'il ne traite plus d'événements. Lors de la première visite, navigator.serviceWorker.controller vaut parfois null, d'où l'opérateur ?. dans le code.

Performance monitoring et debugging

Le moniteur remplace postMessage et onmessage pour enregistrer la durée de chaque tâche :

1// WorkerPerformanceMonitor.js
2class WorkerPerformanceMonitor {
3  constructor() {
4    this.metrics = {
5      taskExecutionTimes: [],
6      memoryUsage: [],
7      errorRates: {},
8      throughput: []
9    };
10
11    this.startTime = performance.now();
12  }
13
14  // Surveillance des performances du worker
15  monitorWorkerPerformance(worker, taskId) {
16    const startTime = performance.now();
17    let memoryBefore = null;
18
19    // Mesure de la mémoire (si disponible)
20    if (performance.memory) {
21      memoryBefore = performance.memory.usedJSHeapSize;
22    }
23
24    const originalPostMessage = worker.postMessage.bind(worker);
25    const originalOnMessage = worker.onmessage;
26
27    // Intercept postMessage
28    worker.postMessage = (message) => {
29      console.log(`[Worker] Sending task ${taskId}:`, message);
30      originalPostMessage(message);
31    };
32
33    // Intercept onmessage
34    worker.onmessage = (event) => {
35      const endTime = performance.now();
36      const executionTime = endTime - startTime;
37
38      // Enregistrement de la métrique de temps d'exécution
39      this.metrics.taskExecutionTimes.push({
40        taskId: taskId,
41        executionTime: executionTime,
42        timestamp: Date.now()
43      });
44
45      // Mesure de la mémoire après l'exécution
46      if (performance.memory && memoryBefore) {
47        const memoryAfter = performance.memory.usedJSHeapSize;
48        const memoryDelta = memoryAfter - memoryBefore;
49
50        this.metrics.memoryUsage.push({
51          taskId: taskId,
52          memoryDelta: memoryDelta,
53          timestamp: Date.now()
54        });
55      }
56
57      console.log(`[Worker] Task ${taskId} completed in ${executionTime.toFixed(2)}ms`);
58
59      if (originalOnMessage) {
60        originalOnMessage(event);
61      }
62    };
63
64    return worker;
65  }
66
67  // Analyse des performances
68  generatePerformanceReport() {
69    const report = {
70      totalRuntime: performance.now() - this.startTime,
71      taskCount: this.metrics.taskExecutionTimes.length,
72      averageExecutionTime: this.calculateAverageExecutionTime(),
73      memoryTrends: this.analyzeMemoryTrends(),
74      throughputAnalysis: this.calculateThroughput(),
75      recommendations: this.generateRecommendations()
76    };
77
78    return report;
79  }
80
81  calculateAverageExecutionTime() {
82    if (this.metrics.taskExecutionTimes.length === 0) return 0;
83
84    const total = this.metrics.taskExecutionTimes.reduce(
85      (sum, metric) => sum + metric.executionTime, 0
86    );
87
88    return total / this.metrics.taskExecutionTimes.length;
89  }
90
91  analyzeMemoryTrends() {
92    if (this.metrics.memoryUsage.length === 0) return null;
93
94    const memoryDeltas = this.metrics.memoryUsage.map(m => m.memoryDelta);
95    const averageMemoryDelta = memoryDeltas.reduce((a, b) => a + b, 0) / memoryDeltas.length;
96
97    return {
98      averageMemoryDelta: averageMemoryDelta,
99      maxMemoryDelta: Math.max(...memoryDeltas),
100      minMemoryDelta: Math.min(...memoryDeltas),
101      potentialMemoryLeak: averageMemoryDelta > 1024 * 1024 // > 1MB average
102    };
103  }
104
105  calculateThroughput() {
106    const timeWindow = 60000; // 1 minute
107    const now = Date.now();
108    const recentTasks = this.metrics.taskExecutionTimes.filter(
109      task => now - task.timestamp < timeWindow
110    );
111
112    return {
113      tasksPerMinute: recentTasks.length,
114      averageTaskTime: recentTasks.length > 0
115        ? recentTasks.reduce((sum, task) => sum + task.executionTime, 0) / recentTasks.length
116        : 0
117    };
118  }
119
120  generateRecommendations() {
121    const recommendations = [];
122    const avgTime = this.calculateAverageExecutionTime();
123    const memoryTrends = this.analyzeMemoryTrends();
124
125    if (avgTime > 5000) { // > 5 secondes
126      recommendations.push('Envisager de découper les tâches longues en parties plus petites');
127    }
128
129    if (memoryTrends?.potentialMemoryLeak) {
130      recommendations.push('Fuite de mémoire potentielle détectée - vérifier la gestion de la mémoire');
131    }
132
133    if (this.metrics.taskExecutionTimes.length > 1000) {
134      recommendations.push("Grand nombre de tâches - envisager d'implémenter un Worker Pool");
135    }
136
137    return recommendations;
138  }
139}
140
141// Utilisation du monitoring
142const performanceMonitor = new WorkerPerformanceMonitor();
143
144// Surveillance d'un worker précis
145const worker = new Worker('calculation-worker.js');
146const monitoredWorker = performanceMonitor.monitorWorkerPerformance(worker, 'task_001');
147
148// Génération du rapport au bout d'un certain temps
149setTimeout(() => {
150  const report = performanceMonitor.generatePerformanceReport();
151  console.log('Performance Report:', report);
152}, 30000);

performance.memory est une API non standard disponible uniquement dans Chromium, alors considère ces mesures comme indicatives.

Best practices et optimisations

Une communication optimale avec les workers

Chaque message a un coût de copie, donc on regroupe les petits messages en lots, on compresse les gros et on transmet les tampons sans copie :

1// OptimizedWorkerCommunication.js
2class OptimizedWorkerCommunication {
3  constructor() {
4    this.messageQueue = [];
5    this.batchSize = 10;
6    this.batchTimeout = 16; // ~60fps
7    this.compressionThreshold = 1024; // 1KB
8  }
9
10  // Batching des messages pour de meilleures performances
11  sendBatchedMessage(worker, message) {
12    this.messageQueue.push({ worker, message });
13
14    if (this.messageQueue.length >= this.batchSize) {
15      this.flushMessageQueue();
16    } else {
17      // Flush après le timeout si le lot n'est pas plein
18      setTimeout(() => {
19        if (this.messageQueue.length > 0) {
20          this.flushMessageQueue();
21        }
22      }, this.batchTimeout);
23    }
24  }
25
26  flushMessageQueue() {
27    const workerMessages = new Map();
28
29    // Regroupement des messages par worker
30    this.messageQueue.forEach(({ worker, message }) => {
31      if (!workerMessages.has(worker)) {
32        workerMessages.set(worker, []);
33      }
34      workerMessages.get(worker).push(message);
35    });
36
37    // Envoi des messages regroupés en lots
38    workerMessages.forEach((messages, worker) => {
39      worker.postMessage({
40        type: 'BATCH_MESSAGES',
41        messages: messages,
42        timestamp: Date.now()
43      });
44    });
45
46    this.messageQueue = [];
47  }
48
49  // Compression des grosses données avant l'envoi
50  async sendCompressedData(worker, data) {
51    const serializedData = JSON.stringify(data);
52
53    if (serializedData.length > this.compressionThreshold) {
54      try {
55        // Utilisation de la Compression Streams API (si disponible)
56        const compressedData = await this.compressData(serializedData);
57
58        worker.postMessage({
59          type: 'COMPRESSED_DATA',
60          data: compressedData,
61          compressed: true,
62          originalSize: serializedData.length
63        });
64      } catch (error) {
65        // Repli sur les données non compressées
66        worker.postMessage({
67          type: 'RAW_DATA',
68          data: data,
69          compressed: false
70        });
71      }
72    } else {
73      worker.postMessage({
74        type: 'RAW_DATA',
75        data: data,
76        compressed: false
77      });
78    }
79  }
80
81  async compressData(data) {
82    // Implémentation de la compression (exemple avec Compression Streams)
83    if ('CompressionStream' in window) {
84      const stream = new CompressionStream('gzip');
85      const writer = stream.writable.getWriter();
86      const reader = stream.readable.getReader();
87
88      writer.write(new TextEncoder().encode(data));
89      writer.close();
90
91      const chunks = [];
92      let done = false;
93
94      while (!done) {
95        const { value, done: readerDone } = await reader.read();
96        done = readerDone;
97        if (value) {
98          chunks.push(value);
99        }
100      }
101
102      return new Uint8Array(chunks.reduce((acc, chunk) => [...acc, ...chunk], []));
103    }
104
105    // Repli - on renvoie les données d'origine
106    return new TextEncoder().encode(data);
107  }
108
109  // Transferable Objects pour un transfert efficace
110  sendTransferableData(worker, arrayBuffer) {
111    worker.postMessage({
112      type: 'TRANSFERABLE_DATA',
113      data: arrayBuffer
114    }, [arrayBuffer]); // Transfer ownership
115
116    console.log('Transferred ArrayBuffer to worker (zero-copy)');
117  }
118}

postMessage(data, [arrayBuffer]) transmet le tampon comme objet transférable (Transferable) : au lieu d'une copie, c'est le propriétaire qui change, et dans le thread principal, le tampon a ensuite une longueur de 0.

Error handling et résilience

Un worker peut tomber à cause d'une erreur dans son script. Un gestionnaire résilient relance les tâches avec un délai croissant (exponential backoff), surveille les délais d'expiration et recrée les workers morts :

1// ResilientWorkerManager.js
2class ResilientWorkerManager {
3  constructor(workerScript, options = {}) {
4    this.workerScript = workerScript;
5    this.options = {
6      maxRetries: 3,
7      retryDelay: 1000,
8      healthCheckInterval: 30000,
9      taskTimeout: 60000,
10      ...options
11    };
12
13    this.workers = new Map();
14    this.failedTasks = new Map();
15    this.healthCheckTimer = null;
16
17    this.startHealthChecking();
18  }
19
20  // Création d'un worker avec gestion des erreurs
21  createResilientWorker(workerId) {
22    const worker = new Worker(this.workerScript);
23    const workerInfo = {
24      instance: worker,
25      id: workerId,
26      healthy: true,
27      taskCount: 0,
28      errorCount: 0,
29      lastActivity: Date.now(),
30      activeTasks: new Map()
31    };
32
33    // Error handling
34    worker.onerror = (error) => {
35      this.handleWorkerError(workerInfo, error);
36    };
37
38    // Gestion des messages avec timeout
39    worker.onmessage = (event) => {
40      this.handleWorkerMessage(workerInfo, event.data);
41    };
42
43    this.workers.set(workerId, workerInfo);
44    return workerInfo;
45  }
46
47  // Exécution d'une tâche avec logique de retry
48  async executeTaskWithRetry(taskData, workerId = null) {
49    let attempts = 0;
50    let lastError = null;
51
52    while (attempts < this.options.maxRetries) {
53      try {
54        const result = await this.executeTask(taskData, workerId);
55
56        // Remise à zéro du compteur de tâches échouées après un succès
57        if (this.failedTasks.has(taskData.id)) {
58          this.failedTasks.delete(taskData.id);
59        }
60
61        return result;
62      } catch (error) {
63        attempts++;
64        lastError = error;
65
66        console.warn(`Task ${taskData.id} failed (attempt ${attempts}/${this.options.maxRetries}):`, error);
67
68        if (attempts < this.options.maxRetries) {
69          // Exponential backoff
70          const delay = this.options.retryDelay * Math.pow(2, attempts - 1);
71          await this.sleep(delay);
72
73          // Recréer le worker si nécessaire
74          if (error.type === 'WORKER_DIED') {
75            await this.recreateWorker(workerId);
76          }
77        }
78      }
79    }
80
81    // Enregistrement de la tâche comme échouée
82    this.failedTasks.set(taskData.id, {
83      taskData: taskData,
84      attempts: attempts,
85      lastError: lastError,
86      failedAt: Date.now()
87    });
88
89    throw new Error(`Task ${taskData.id} failed after ${attempts} attempts: ${lastError.message}`);
90  }
91
92  executeTask(taskData, workerId) {
93    return new Promise((resolve, reject) => {
94      const worker = workerId ? this.workers.get(workerId) : this.getHealthyWorker();
95
96      if (!worker || !worker.healthy) {
97        reject(new Error('No healthy worker available'));
98        return;
99      }
100
101      const taskId = taskData.id || this.generateTaskId();
102      const timeoutId = setTimeout(() => {
103        worker.activeTasks.delete(taskId);
104        reject(new Error(`Task ${taskId} timed out`));
105      }, this.options.taskTimeout);
106
107      // Enregistrement de la tâche comme active
108      worker.activeTasks.set(taskId, {
109        resolve: resolve,
110        reject: reject,
111        timeoutId: timeoutId,
112        startTime: Date.now()
113      });
114
115      worker.taskCount++;
116      worker.lastActivity = Date.now();
117
118      worker.instance.postMessage({
119        ...taskData,
120        taskId: taskId
121      });
122    });
123  }
124
125  handleWorkerMessage(workerInfo, message) {
126    const { taskId, type, result, error } = message;
127
128    if (workerInfo.activeTasks.has(taskId)) {
129      const task = workerInfo.activeTasks.get(taskId);
130
131      clearTimeout(task.timeoutId);
132      workerInfo.activeTasks.delete(taskId);
133      workerInfo.lastActivity = Date.now();
134
135      if (type === 'TASK_SUCCESS') {
136        task.resolve(result);
137      } else if (type === 'TASK_ERROR') {
138        workerInfo.errorCount++;
139        task.reject(new Error(error));
140      }
141    }
142  }
143
144  handleWorkerError(workerInfo, error) {
145    console.error(`Worker ${workerInfo.id} error:`, error);
146
147    workerInfo.healthy = false;
148    workerInfo.errorCount++;
149
150    // Rejet de toutes les tâches actives
151    workerInfo.activeTasks.forEach(task => {
152      clearTimeout(task.timeoutId);
153      task.reject(new Error('Worker died'));
154    });
155
156    workerInfo.activeTasks.clear();
157
158    // Auto-restart worker
159    setTimeout(() => {
160      this.recreateWorker(workerInfo.id);
161    }, this.options.retryDelay);
162  }
163
164  async recreateWorker(workerId) {
165    const oldWorker = this.workers.get(workerId);
166
167    if (oldWorker) {
168      oldWorker.instance.terminate();
169      this.workers.delete(workerId);
170    }
171
172    console.log(`Recreating worker ${workerId}`);
173    const newWorker = this.createResilientWorker(workerId);
174
175    // Health check pour le nouveau worker
176    await this.waitForWorkerReady(newWorker);
177  }
178
179  waitForWorkerReady(workerInfo, timeout = 5000) {
180    return new Promise((resolve, reject) => {
181      const timeoutId = setTimeout(() => {
182        reject(new Error(`Worker ${workerInfo.id} failed to initialize`));
183      }, timeout);
184
185      // Ping test
186      const pingTest = () => {
187        workerInfo.instance.postMessage({
188          type: 'PING',
189          timestamp: Date.now()
190        });
191      };
192
193      const handlePong = (event) => {
194        if (event.data.type === 'PONG') {
195          clearTimeout(timeoutId);
196          workerInfo.instance.removeEventListener('message', handlePong);
197          workerInfo.healthy = true;
198          resolve(workerInfo);
199        }
200      };
201
202      workerInfo.instance.addEventListener('message', handlePong);
203      pingTest();
204    });
205  }
206
207  // Vérification périodique de la santé des workers
208  startHealthChecking() {
209    this.healthCheckTimer = setInterval(() => {
210      this.performHealthCheck();
211    }, this.options.healthCheckInterval);
212  }
213
214  performHealthCheck() {
215    this.workers.forEach((workerInfo, workerId) => {
216      const timeSinceLastActivity = Date.now() - workerInfo.lastActivity;
217
218      // Vérifier que le worker répond
219      if (timeSinceLastActivity > this.options.healthCheckInterval * 2) {
220        console.warn(`Worker ${workerId} appears unresponsive`);
221        this.recreateWorker(workerId);
222      }
223
224      // Vérification du taux d'erreur
225      const errorRate = workerInfo.errorCount / Math.max(workerInfo.taskCount, 1);
226      if (errorRate > 0.1) { // > 10% error rate
227        console.warn(`Worker ${workerId} has high error rate: ${(errorRate * 100).toFixed(2)}%`);
228      }
229    });
230  }
231
232  getHealthyWorker() {
233    const healthyWorkers = Array.from(this.workers.values()).filter(w => w.healthy);
234
235    if (healthyWorkers.length === 0) {
236      return null;
237    }
238
239    // Choix du worker qui a le moins de tâches actives
240    return healthyWorkers.reduce((best, current) =>
241      current.activeTasks.size < best.activeTasks.size ? current : best
242    );
243  }
244
245  sleep(ms) {
246    return new Promise(resolve => setTimeout(resolve, ms));
247  }
248
249  generateTaskId() {
250    return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
251  }
252
253  // Nettoyage des ressources
254  terminate() {
255    if (this.healthCheckTimer) {
256      clearInterval(this.healthCheckTimer);
257    }
258
259    this.workers.forEach(workerInfo => {
260      workerInfo.instance.terminate();
261    });
262
263    this.workers.clear();
264  }
265
266  // Statistiques du système
267  getSystemStats() {
268    const workers = Array.from(this.workers.values());
269
270    return {
271      totalWorkers: workers.length,
272      healthyWorkers: workers.filter(w => w.healthy).length,
273      totalTasks: workers.reduce((sum, w) => sum + w.taskCount, 0),
274      activeTasks: workers.reduce((sum, w) => sum + w.activeTasks.size, 0),
275      totalErrors: workers.reduce((sum, w) => sum + w.errorCount, 0),
276      failedTasksCount: this.failedTasks.size,
277      averageErrorRate: workers.length > 0
278        ? (workers.reduce((sum, w) => sum + (w.errorCount / Math.max(w.taskCount, 1)), 0) / workers.length * 100).toFixed(2) + '%'
279        : '0%'
280    };
281  }
282}

Le délai d'expiration de chaque tâche protège contre un worker bloqué, et PING et PONG vérifient que le nouveau worker répond.

Exemple pratique : le rendu 3D

Pour finir, un moteur de rendu découpe l'écran en tuiles de 64 sur 64 pixels et les confie à un pool de workers, qui calculent les couleurs par lancer de rayons (ray tracing) :

1// 3DRenderingApp.js
2class WebWorker3DRenderer {
3  constructor(canvasId) {
4    this.canvas = document.getElementById(canvasId);
5    this.ctx = this.canvas.getContext('2d');
6    this.workerPool = new WorkerPool('3d-renderer-worker.js', 4);
7
8    this.scene = {
9      objects: [],
10      camera: { x: 0, y: 0, z: -10 },
11      lights: [{ x: 5, y: 5, z: -5, intensity: 1 }]
12    };
13
14    this.setupScene();
15  }
16
17  setupScene() {
18    // Ajout d'objets 3D à la scène
19    this.scene.objects = [
20      { type: 'cube', position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 } },
21      { type: 'sphere', position: { x: 3, y: 0, z: 2 }, radius: 1 },
22      { type: 'cylinder', position: { x: -3, y: 0, z: 1 }, height: 2, radius: 0.5 }
23    ];
24  }
25
26  async render() {
27    const startTime = performance.now();
28
29    // Découpage de l'écran en tiles pour un rendu parallèle
30    const tileSize = 64;
31    const tilesX = Math.ceil(this.canvas.width / tileSize);
32    const tilesY = Math.ceil(this.canvas.height / tileSize);
33
34    const renderPromises = [];
35
36    for (let tileY = 0; tileY < tilesY; tileY++) {
37      for (let tileX = 0; tileX < tilesX; tileX++) {
38        const tileData = {
39          type: 'RENDER_TILE',
40          tile: {
41            x: tileX * tileSize,
42            y: tileY * tileSize,
43            width: Math.min(tileSize, this.canvas.width - tileX * tileSize),
44            height: Math.min(tileSize, this.canvas.height - tileY * tileSize)
45          },
46          scene: this.scene,
47          screenDimensions: {
48            width: this.canvas.width,
49            height: this.canvas.height
50          }
51        };
52
53        renderPromises.push(
54          this.workerPool.execute(tileData).then(result => ({
55            ...result,
56            tileX: tileX,
57            tileY: tileY
58          }))
59        );
60      }
61    }
62
63    // Attente de toutes les tiles
64    const renderedTiles = await Promise.all(renderPromises);
65
66    // Assemblage des tiles sur le canvas
67    renderedTiles.forEach(tile => {
68      const imageData = new ImageData(
69        new Uint8ClampedArray(tile.pixelData),
70        tile.width,
71        tile.height
72      );
73
74      this.ctx.putImageData(imageData, tile.x, tile.y);
75    });
76
77    const renderTime = performance.now() - startTime;
78    console.log(`Frame rendered in ${renderTime.toFixed(2)}ms`);
79  }
80
81  // Animation de la scène
82  animate() {
83    this.scene.objects.forEach(obj => {
84      if (obj.rotation) {
85        obj.rotation.y += 0.02;
86        obj.rotation.x += 0.01;
87      }
88    });
89
90    this.render().then(() => {
91      requestAnimationFrame(() => this.animate());
92    });
93  }
94}
95
96// 3d-renderer-worker.js
97class Tile3DRenderer {
98  constructor() {
99    self.onmessage = (event) => {
100      this.handleRenderTask(event.data);
101    };
102  }
103
104  handleRenderTask(data) {
105    if (data.type === 'RENDER_TILE') {
106      const result = this.renderTile(data.tile, data.scene, data.screenDimensions);
107
108      self.postMessage({
109        type: 'TASK_COMPLETE',
110        result: result
111      });
112    }
113  }
114
115  renderTile(tile, scene, screenDimensions) {
116    const { x, y, width, height } = tile;
117    const pixelData = new Uint8ClampedArray(width * height * 4);
118
119    for (let py = 0; py < height; py++) {
120      for (let px = 0; px < width; px++) {
121        const screenX = x + px;
122        const screenY = y + py;
123
124        // Conversion des coordonnées de l'écran en rayon
125        const ray = this.screenToRay(screenX, screenY, screenDimensions, scene.camera);
126
127        // Ray tracing
128        const color = this.traceRay(ray, scene);
129
130        const pixelIndex = (py * width + px) * 4;
131        pixelData[pixelIndex] = color.r;     // Red
132        pixelData[pixelIndex + 1] = color.g; // Green
133        pixelData[pixelIndex + 2] = color.b; // Blue
134        pixelData[pixelIndex + 3] = 255;     // Alpha
135      }
136    }
137
138    return {
139      x: x,
140      y: y,
141      width: width,
142      height: height,
143      pixelData: Array.from(pixelData)
144    };
145  }
146
147  screenToRay(screenX, screenY, screenDimensions, camera) {
148    const normalizedX = (screenX / screenDimensions.width) * 2 - 1;
149    const normalizedY = 1 - (screenY / screenDimensions.height) * 2;
150
151    return {
152      origin: camera,
153      direction: {
154        x: normalizedX,
155        y: normalizedY,
156        z: 1
157      }
158    };
159  }
160
161  traceRay(ray, scene) {
162    let closestDistance = Infinity;
163    let hitColor = { r: 0, g: 0, b: 50 }; // Arrière-plan
164
165    // Détection des collisions avec les objets
166    scene.objects.forEach(obj => {
167      const distance = this.rayObjectIntersection(ray, obj);
168
169      if (distance > 0 && distance < closestDistance) {
170        closestDistance = distance;
171        hitColor = this.calculateObjectColor(obj, ray, distance, scene.lights);
172      }
173    });
174
175    return hitColor;
176  }
177
178  rayObjectIntersection(ray, obj) {
179    switch (obj.type) {
180      case 'sphere':
181        return this.raySphereIntersection(ray, obj);
182      case 'cube':
183        return this.rayCubeIntersection(ray, obj);
184      default:
185        return -1;
186    }
187  }
188
189  raySphereIntersection(ray, sphere) {
190    const dx = ray.origin.x - sphere.position.x;
191    const dy = ray.origin.y - sphere.position.y;
192    const dz = ray.origin.z - sphere.position.z;
193
194    const a = ray.direction.x * ray.direction.x +
195              ray.direction.y * ray.direction.y +
196              ray.direction.z * ray.direction.z;
197
198    const b = 2 * (dx * ray.direction.x + dy * ray.direction.y + dz * ray.direction.z);
199    const c = dx * dx + dy * dy + dz * dz - sphere.radius * sphere.radius;
200
201    const discriminant = b * b - 4 * a * c;
202
203    if (discriminant < 0) return -1;
204
205    const t1 = (-b - Math.sqrt(discriminant)) / (2 * a);
206    const t2 = (-b + Math.sqrt(discriminant)) / (2 * a);
207
208    return t1 > 0 ? t1 : (t2 > 0 ? t2 : -1);
209  }
210
211  calculateObjectColor(obj, ray, distance, lights) {
212    // Éclairage simplifié
213    const baseColor = obj.type === 'sphere'
214      ? { r: 255, g: 100, b: 100 }
215      : { r: 100, g: 255, b: 100 };
216
217    // Simulation de l'éclairage
218    const lightIntensity = 0.7 + 0.3 * Math.sin(distance * 0.1);
219
220    return {
221      r: Math.floor(baseColor.r * lightIntensity),
222      g: Math.floor(baseColor.g * lightIntensity),
223      b: Math.floor(baseColor.b * lightIntensity)
224    };
225  }
226}
227
228new Tile3DRenderer();

Le thread principal se contente d'assembler les tuiles sur le canvas. Ce serait plus rapide de transmettre les tampons de pixels comme Transferable au lieu de les convertir en tableaux avec Array.from.

Récapitulatif

Les Web Workers permettent :

  1. D'exécuter des calculs lourds sans bloquer l'UI
  2. De traiter en parallèle de grands ensembles de données
  3. D'améliorer la réactivité des applications web
  4. De monter en charge grâce au Worker Pool
  5. De faire du background processing avec les Service Workers

Tu y auras recours pour les calculs mathématiques et scientifiques, le traitement d'images et de vidéos, le rendu 3D, le tri et le filtrage de grands ensembles de données, la compression, la cryptographie et le hachage, ainsi que pour exécuter des modèles de machine learning. Commence par un seul worker en mode module (new Worker('worker.js', { type: 'module' })), et ne construis un pool que lorsque les mesures montrent que cela ne suffit plus. Dans l'éditeur ci-dessous t'attend une simulation de worker, et dans la leçon suivante, nous jetterons un œil à la mémoire et au garbage collector.

Retiens : un worker est un laboratoire séparé derrière une vitre, qui travaille en parallèle, mais avec lequel tu ne communiques que par le guichet postMessage.

Code de cette leçon : index.js
1// Web Workers - exemple de calcul de nombres premiers
2// Thread principal
3console.log("Jurassic Park - Système d'analyse ADN");
4console.log("Démarrage de calculs lourds sans bloquer l'UI\n");
5
6// Simulation d'un Web Worker (un vrai worker nécessite un fichier séparé)
7function simulateWorker() {
8  // Fonction qui vérifie si un nombre est premier
9  function isPrime(n) {
10    if (n <= 1) return false;
11    if (n <= 3) return true;
12    if (n % 2 === 0 || n % 3 === 0) return false;
13
14    for (let i = 5; i * i <= n; i += 6) {
15      if (n % i === 0 || n % (i + 2) === 0) return false;
16    }
17    return true;
18  }
19
20  // Trouver les nombres premiers dans une plage
21  function findPrimes(start, end) {
22    const primes = [];
23    for (let i = start; i <= end; i++) {
24      if (isPrime(i)) {
25        primes.push(i);
26      }
27    }
28    return primes;
29  }
30
31  return findPrimes;
32}
33
34// Fonction principale
35async function calculatePrimesInBackground() {
36  console.log("Je démarre les calculs en arrière-plan...");
37  console.log("L'UI reste réactive !\n");
38
39  const findPrimes = simulateWorker();
40
41  // Simulation de calculs longs
42  const start = 1;
43  const end = 10000;
44
45  console.time("Calculation time");
46  const primes = findPrimes(start, end);
47  console.timeEnd("Calculation time");
48
49  console.log(`\nTrouvé ${primes.length} nombres premiers`);
50  console.log(`Les 20 premiers : ${primes.slice(0, 20).join(', ')}`);
51  console.log(`Les 20 derniers : ${primes.slice(-20).join(', ')}`);
52}
53
54// Structure de l'API Web Worker (pour référence)
55console.log("=== Structure de l'API Web Worker ===");
56console.log("1. Créer un worker :");
57console.log("   const worker = new Worker('worker.js');");
58console.log("\n2. Envoyer des données au worker :");
59console.log("   worker.postMessage({ action: 'calculate', data: 100 });");
60console.log("\n3. Recevoir les résultats :");
61console.log("   worker.onmessage = (event) => console.log(event.data);");
62console.log("\n4. Gestion des erreurs :");
63console.log("   worker.onerror = (error) => console.error(error);");
64console.log("\n5. Fermer le worker :");
65console.log("   worker.terminate();\n");
66
67// Lance les calculs
68calculatePrimesInBackground();
69
70// Démonstration du fonctionnement simultané de l'UI
71console.log("\nL'UI fonctionne normalement pendant les calculs !");
72console.log("D'autres opérations peuvent être effectuées...");
73console.log("Clics, animations, tout reste réactif !");
74
75// Exemple de « worker » avec une promesse
76class PrimeWorkerSimulator {
77  calculate(start, end) {
78    return new Promise((resolve) => {
79      setTimeout(() => {
80        const primes = [];
81        for (let i = start; i <= end; i++) {
82          if (this.isPrime(i)) primes.push(i);
83        }
84        resolve(primes);
85      }, 0); // Async execution
86    });
87  }
88
89  isPrime(n) {
90    if (n <= 1) return false;
91    if (n <= 3) return true;
92    if (n % 2 === 0 || n % 3 === 0) return false;
93
94    for (let i = 5; i * i <= n; i += 6) {
95      if (n % i === 0 || n % (i + 2) === 0) return false;
96    }
97    return true;
98  }
99}
100
101// Test avec un worker basé sur une promesse
102const workerSim = new PrimeWorkerSimulator();
103workerSim.calculate(10000, 11000).then(primes => {
104  console.log(`\nCalculs terminés ! Trouvé ${primes.length} nombres premiers entre 10000 et 11000`);
105});

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 est le principal problème que résolvent les Web Workers ?

  2. 2. Quelle méthode utilise-t-on pour envoyer des données à un Web Worker ?

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

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée un worker pour calculer des nombres premiers.

  • Clique dans l'ordre

    Ordonne les étapes de la communication avec un Web Worker.

  • Tri horizontal

    Ordonne les éléments pour créer un Web Worker :

  • Clique dans l'ordre

    Ordonne dans le bon ordre les éléments de création d'un Web Worker :

  • Éditeur de code

    Utilise SharedArrayBuffer pour la communication entre threads.

  • Tri vertical

    Ordonne dans le bon ordre les étapes du cycle de vie d'un Web Worker :

  • Clique dans l'ordre

    Ordonne les éléments de l'envoi d'un message à un Worker :

Articles utiles