Retour au blog

Web Workers : Comment Utiliser des Threads en JavaScript Pour Améliorer la Performance

Salut HaWkers, JavaScript est single-threaded par design. Cela signifie que les opérations lourdes bloquent le thread principal, causant des freezes de l'interface. Les Web Workers résolvent ce problème en permettant d'exécuter du code sur des threads séparés.

Explorons comment utiliser les Web Workers pour créer des applications plus réactives et performantes.

Le Problème du Thread Principal

Le thread principal de JavaScript est responsable de :

  • Exécuter votre code
  • Traiter les événements du DOM
  • Rendre les mises à jour visuelles
  • Répondre aux interactions utilisateur

Quand vous exécutez du code lourd, toutes ces tâches sont bloquées :

// ❌ MAUVAIS - Bloque le thread principal
function traiterDonneesLourdes(donnees) {
  // Simulation de traitement lourd
  const resultat = [];
  for (let i = 0; i < 10000000; i++) {
    resultat.push(Math.sqrt(donnees[i % donnees.length]) * Math.random());
  }
  return resultat;
}

// Pendant cette exécution, l'UI est gelée
// Les clics ne répondent pas, les animations s'arrêtent
const resultat = traiterDonneesLourdes(mesDonnees);

Symptômes d'un thread principal bloqué :

  • Interface gelée
  • Animations en pause
  • Inputs qui ne répondent pas
  • Scroll saccadé
  • "Page Unresponsive" dans le navigateur

Comment les Web Workers Fonctionnent

Les Web Workers exécutent du JavaScript sur un thread séparé, complètement isolé du thread principal.

Communication via Messages

Les workers communiquent avec le thread principal via des messages :

// main.js - Thread principal
const worker = new Worker('worker.js');

// Envoyer des données au worker
worker.postMessage({ donnees: mesDonnees, type: 'traiter' });

// Recevoir le résultat du worker
worker.onmessage = (event) => {
  console.log('Résultat:', event.data);
  // Mettre à jour l'UI avec le résultat
  mettreAJourInterface(event.data);
};

// Gérer les erreurs
worker.onerror = (error) => {
  console.error('Erreur dans le worker:', error.message);
};
// worker.js - Thread séparé
self.onmessage = (event) => {
  const { donnees, type } = event.data;

  if (type === 'traiter') {
    // Traitement lourd - ne bloque pas l'UI
    const resultat = traiterDonnees(donnees);

    // Envoyer le résultat
    self.postMessage(resultat);
  }
};

function traiterDonnees(donnees) {
  const resultat = [];
  for (let i = 0; i < 10000000; i++) {
    resultat.push(Math.sqrt(donnees[i % donnees.length]) * Math.random());
  }
  return resultat;
}

Types de Workers

Dedicated Workers

Le type le plus courant. Exclusif à un script spécifique :

// Créer un worker dédié
const worker = new Worker('worker.js');

// Terminer le worker quand plus nécessaire
worker.terminate();

Shared Workers

Partagé entre plusieurs tabs/fenêtres de la même origine :

// main.js
const sharedWorker = new SharedWorker('shared-worker.js');

sharedWorker.port.onmessage = (event) => {
  console.log('Message reçu:', event.data);
};

sharedWorker.port.postMessage('Hello from tab');
// shared-worker.js
const connections = [];

self.onconnect = (event) => {
  const port = event.ports[0];
  connections.push(port);

  port.onmessage = (event) => {
    // Broadcast à toutes les connexions
    connections.forEach(conn => {
      conn.postMessage(`Broadcast: ${event.data}`);
    });
  };

  port.start();
};

Service Workers

Pour le cache, les push notifications et la fonctionnalité offline (non couvert en détail ici).

Cas d'Usage Pratiques

1. Traitement d'Images

// main.js
const imageWorker = new Worker('image-worker.js');

async function traiterImage(imageFile) {
  const bitmap = await createImageBitmap(imageFile);

  // Utiliser OffscreenCanvas pour traiter dans le worker
  const canvas = document.createElement('canvas');
  canvas.width = bitmap.width;
  canvas.height = bitmap.height;

  const offscreen = canvas.transferControlToOffscreen();

  // Transférer le canvas au worker (ne copie pas, transfère)
  imageWorker.postMessage(
    { canvas: offscreen, bitmap },
    [offscreen, bitmap]
  );
}

imageWorker.onmessage = (event) => {
  if (event.data.type === 'progres') {
    mettreAJourBarreProgression(event.data.pourcentage);
  } else if (event.data.type === 'complete') {
    afficherResultat(event.data.imageData);
  }
};
// image-worker.js
self.onmessage = async (event) => {
  const { canvas, bitmap } = event.data;
  const ctx = canvas.getContext('2d');

  ctx.drawImage(bitmap, 0, 0);
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

  // Appliquer un filtre (exemple : grayscale)
  const pixels = imageData.data;
  for (let i = 0; i < pixels.length; i += 4) {
    const avg = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
    pixels[i] = avg;     // R
    pixels[i + 1] = avg; // G
    pixels[i + 2] = avg; // B

    // Reporter le progrès
    if (i % 100000 === 0) {
      self.postMessage({
        type: 'progres',
        pourcentage: Math.round((i / pixels.length) * 100)
      });
    }
  }

  ctx.putImageData(imageData, 0, 0);

  self.postMessage({
    type: 'complete',
    imageData: ctx.getImageData(0, 0, canvas.width, canvas.height)
  });
};

2. Parsing de Fichiers Volumineux

// main.js
const csvWorker = new Worker('csv-worker.js');

async function traiterCSV(file) {
  const text = await file.text();

  csvWorker.postMessage({ csv: text, options: { delimiter: ',' } });
}

csvWorker.onmessage = (event) => {
  const { type, donnees, erreur } = event.data;

  if (type === 'succes') {
    console.log(`Traitées ${donnees.length} lignes`);
    afficherTableau(donnees);
  } else if (type === 'erreur') {
    console.error('Erreur lors du traitement CSV:', erreur);
  }
};
// csv-worker.js
self.onmessage = (event) => {
  try {
    const { csv, options } = event.data;
    const lignes = csv.split('\n');
    const headers = lignes[0].split(options.delimiter);

    const donnees = [];
    for (let i = 1; i < lignes.length; i++) {
      if (!lignes[i].trim()) continue;

      const valeurs = lignes[i].split(options.delimiter);
      const objet = {};

      headers.forEach((header, index) => {
        objet[header.trim()] = valeurs[index]?.trim();
      });

      donnees.push(objet);
    }

    self.postMessage({ type: 'succes', donnees });
  } catch (erreur) {
    self.postMessage({ type: 'erreur', erreur: erreur.message });
  }
};

3. Calculs Mathématiques Complexes

// main.js
const mathWorker = new Worker('math-worker.js');

function calculerStatistiques(nombres) {
  return new Promise((resolve, reject) => {
    mathWorker.postMessage({ nombres });

    mathWorker.onmessage = (event) => {
      resolve(event.data);
    };

    mathWorker.onerror = (error) => {
      reject(error);
    };
  });
}

// Utilisation
const stats = await calculerStatistiques(grandTableauDeNombres);
console.log('Moyenne:', stats.moyenne);
console.log('Écart-type:', stats.ecartType);
// math-worker.js
self.onmessage = (event) => {
  const { nombres } = event.data;

  // Moyenne
  const somme = nombres.reduce((acc, n) => acc + n, 0);
  const moyenne = somme / nombres.length;

  // Variance
  const sommeCarres = nombres.reduce((acc, n) => {
    return acc + Math.pow(n - moyenne, 2);
  }, 0);
  const variance = sommeCarres / nombres.length;

  // Écart-type
  const ecartType = Math.sqrt(variance);

  // Médiane
  const sorted = [...nombres].sort((a, b) => a - b);
  const milieu = Math.floor(sorted.length / 2);
  const mediane = sorted.length % 2
    ? sorted[milieu]
    : (sorted[milieu - 1] + sorted[milieu]) / 2;

  self.postMessage({
    moyenne,
    mediane,
    variance,
    ecartType,
    min: Math.min(...nombres),
    max: Math.max(...nombres),
  });
};

Worker avec TypeScript et Bundlers

Configuration avec Vite

// src/workers/data-processor.worker.ts
self.onmessage = (event: MessageEvent<{ data: number[] }>) => {
  const { data } = event.data;
  const result = data.map(n => n * 2);
  self.postMessage(result);
};

export {}; // Nécessaire pour que TypeScript traite comme module
// src/main.ts
// Vite supporte les workers nativement avec ?worker
import DataWorker from './workers/data-processor.worker?worker';

const worker = new DataWorker();

worker.postMessage({ data: [1, 2, 3, 4, 5] });

worker.onmessage = (event) => {
  console.log('Résultat:', event.data); // [2, 4, 6, 8, 10]
};

Configuration avec Webpack

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.worker\.ts$/,
        use: {
          loader: 'worker-loader',
          options: {
            filename: '[name].[contenthash].worker.js',
          },
        },
      },
    ],
  },
};

Transferable Objects

Pour les données volumineuses, utilisez les Transferable Objects pour éviter les copies :

// main.js
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB

// ❌ Copie les données (lent pour données volumineuses)
worker.postMessage({ buffer });

// ✅ Transfère l'ownership (instantané)
worker.postMessage({ buffer }, [buffer]);
// Après cela, `buffer` n'est plus disponible dans le thread principal

Types Transférables

// ArrayBuffer
const arrayBuffer = new ArrayBuffer(1024);
worker.postMessage({ data: arrayBuffer }, [arrayBuffer]);

// MessagePort
const channel = new MessageChannel();
worker.postMessage({ port: channel.port2 }, [channel.port2]);

// ImageBitmap
const bitmap = await createImageBitmap(imageBlob);
worker.postMessage({ image: bitmap }, [bitmap]);

// OffscreenCanvas
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);

Pool de Workers

Pour des tâches parallèles, utilisez un pool de workers :

// worker-pool.js
class WorkerPool {
  constructor(workerScript, poolSize = navigator.hardwareConcurrency || 4) {
    this.workers = [];
    this.queue = [];
    this.activeWorkers = new Set();

    for (let i = 0; i < poolSize; i++) {
      const worker = new Worker(workerScript);
      worker.id = i;
      this.workers.push(worker);
    }
  }

  execute(data) {
    return new Promise((resolve, reject) => {
      const task = { data, resolve, reject };

      const availableWorker = this.workers.find(
        w => !this.activeWorkers.has(w.id)
      );

      if (availableWorker) {
        this.runTask(availableWorker, task);
      } else {
        this.queue.push(task);
      }
    });
  }

  runTask(worker, task) {
    this.activeWorkers.add(worker.id);

    worker.onmessage = (event) => {
      task.resolve(event.data);
      this.activeWorkers.delete(worker.id);
      this.processQueue();
    };

    worker.onerror = (error) => {
      task.reject(error);
      this.activeWorkers.delete(worker.id);
      this.processQueue();
    };

    worker.postMessage(task.data);
  }

  processQueue() {
    if (this.queue.length === 0) return;

    const availableWorker = this.workers.find(
      w => !this.activeWorkers.has(w.id)
    );

    if (availableWorker) {
      const task = this.queue.shift();
      this.runTask(availableWorker, task);
    }
  }

  terminate() {
    this.workers.forEach(w => w.terminate());
  }
}

// Utilisation
const pool = new WorkerPool('processor.worker.js', 4);

const results = await Promise.all([
  pool.execute({ chunk: data.slice(0, 1000) }),
  pool.execute({ chunk: data.slice(1000, 2000) }),
  pool.execute({ chunk: data.slice(2000, 3000) }),
  pool.execute({ chunk: data.slice(3000, 4000) }),
]);

Limitations des Web Workers

Ce Qui N'est PAS Disponible

// worker.js

// ❌ Le DOM n'est pas disponible
document.getElementById('quelquechose'); // Error

// ❌ window n'existe pas
window.localStorage; // Error

// ❌ alert, confirm, prompt n'existent pas
alert('test'); // Error

// ✅ Mais vous pouvez utiliser :
fetch('/api/data'); // OK
setTimeout(() => {}, 1000); // OK
console.log('test'); // OK
crypto.randomUUID(); // OK
self.indexedDB; // OK

Workarounds Courants

// Si vous avez besoin de données du DOM, extrayez-les avant d'envoyer au worker

// main.js
const elementsData = Array.from(document.querySelectorAll('.item'))
  .map(el => ({
    texte: el.textContent,
    dataset: { ...el.dataset }
  }));

worker.postMessage({ elements: elementsData });

Conclusion

Les Web Workers sont un outil puissant pour maintenir vos applications réactives. Utilisez-les pour :

  • Traitement de données lourdes
  • Parsing de fichiers volumineux
  • Calculs mathématiques complexes
  • Traitement d'images
  • Toute opération qui prend plus de 50ms

Rappelez-vous des bonnes pratiques :

  • Utilisez les Transferable Objects pour les données volumineuses
  • Implémentez un pool de workers pour les tâches parallèles
  • Gérez les erreurs correctement
  • Terminez les workers quand vous n'en avez plus besoin

Si vous êtes intéressé par d'autres techniques de performance, je recommande de consulter l'article Debugging JavaScript : Techniques Avancées avec DevTools où nous explorons comment identifier les goulots d'étranglement de performance dans votre application.

C'est parti ! 🦅

Commentaires (0)

Cet article n'a pas encore de commentaires. Soyez le premier!

Ajouter des commentaires