Bon, je vais être honnête avec vous : combien de fois votre admin WordPress s’est figé pendant un import CSV ou lors du traitement d’images lourdes ? Les Web Workers offrent une solution élégante pour déporter ces traitements côté client, sans bloquer l’interface utilisateur. On va voir ensemble comment intégrer cette technologie dans WordPress pour créer des expériences utilisateur bien plus fluides.
Comprendre les Web Workers dans le contexte WordPress
Avant de plonger dans le code, il faut bien comprendre ce qu’on manipule. Les Web Workers, c’est un peu comme avoir un assistant qui travaille en arrière-plan pendant que vous continuez vos tâches principales. Et croyez-moi, dans WordPress, on en a bien besoin !
Qu’est-ce qu’un Web Worker et pourquoi s’en servir ?
Un Web Worker, c’est un script JavaScript qui s’exécute dans un thread séparé du thread principal de votre page. Concrètement, ça veut dire que vous pouvez déporter des calculs lourds sans bloquer l’interface utilisateur.
Pourquoi c’est révolutionnaire pour WordPress ? Bon, je vais être honnête : j’ai longtemps ignoré cette technologie. Jusqu’au jour où j’ai développé un import CSV pour un client avec 50 000 produits WooCommerce. L’interface admin se figeait complètement pendant 30 secondes ! L’utilisateur ne savait plus si ça marchait ou si c’était planté.
Avec un Web Worker, le parsing du CSV et les traitements de données se font en arrière-plan. L’utilisateur peut continuer à naviguer, voir une barre de progression qui se met à jour… Bref, une expérience utilisateur digne de ce nom.
Les limitations du thread principal et l’impact sur l’UX
Le thread principal de JavaScript, c’est un peu comme une route à une seule voie : tout doit passer par là. Quand vous lancez une opération lourde, tout s’arrête. Plus de scroll, plus de clics, plus rien.
Dans WordPress, on rencontre ça typiquement avec :
- L’éditeur Gutenberg qui rame sur des articles avec 200+ blocs
- Les imports de médias qui font planter l’admin
- Le parsing de gros fichiers JSON (pensez aux exports d’Analytics)
- Les opérations canvas pour la manipulation d’images
- Les calculs de statistiques en temps réel
L’API FileReader est particulièrement bloquante. Quand vous uploadez un fichier de 50 Mo et que WordPress doit le lire côté client… c’est le drame ! Avec un Web Worker, ce traitement devient invisible pour l’utilisateur.
Architecture des Web Workers : dedicated vs shared
Il existe deux types principaux de Web Workers, et le choix n’est pas anodin :
Dedicated Workers : c’est le modèle classique. Un worker par page, qui vit et meurt avec elle. Parfait pour des tâches ponctuelles comme un import ou un calcul spécifique. Dans WordPress, c’est souvent ce qu’on utilise pour des fonctionnalités admin.
Shared Workers : là, c’est plus sophistiqué. Un seul worker partagé entre plusieurs onglets de votre site WordPress. Utile pour des tâches en continu comme la synchronisation de données ou la mise en cache côté client.
Attention cependant : les Shared Workers ne sont pas supportés par Safari (typique !). Pour une compatibilité maximale dans WordPress, on privilégie généralement les Dedicated Workers.
Le point important à retenir : les Web Workers communiquent uniquement par messages. Pas d’accès au DOM, pas de variables partagées directement. C’est à la fois une limitation et une force pour la stabilité.
Implémentation pratique : Déporter l’import de données
Bon, assez de théorie ! On va maintenant créer un vrai système d’import CSV qui utilise un Web Worker pour traiter les données sans faire planter l’admin WordPress. J’ai développé cette solution pour un client qui devait importer des milliers de produits WooCommerce… et croyez-moi, sans Web Worker, c’était l’enfer !
On va construire ça étape par étape, avec du code que vous pourrez réellement utiliser en production.
Création de la page d’administration WordPress
Commençons par créer notre page d’admin qui va héberger notre import. Dans votre plugin ou thème :
// Ajouter la page au menu admin
add_action('admin_menu', 'csv_import_add_admin_page');
function csv_import_add_admin_page() {
add_management_page(
'Import CSV avec Web Worker', // Titre de la page
'Import CSV', // Titre du menu
'manage_options', // Capacité requise
'csv-import-worker', // Slug de la page
'csv_import_admin_page' // Fonction de callback
);
}
// Enqueue nos scripts uniquement sur notre page
add_action('admin_enqueue_scripts', 'csv_import_enqueue_scripts');
function csv_import_enqueue_scripts($hook) {
// On charge seulement sur notre page pour éviter les conflits
if ($hook !== 'tools_page_csv-import-worker') {
return;
}
// Script principal de l'import
wp_enqueue_script(
'csv-import-main',
plugin_dir_url(__FILE__) . 'js/csv-import.js',
array('jquery'),
'1.0.0',
true
);
// Variables JavaScript nécessaires
wp_localize_script('csv-import-main', 'csvImportAjax', array(
'ajaxurl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('csv_import_nonce'),
'worker_url' => plugin_dir_url(__FILE__) . 'js/worker.js'
));
}
Attention : j’utilise tools_page_ comme préfixe parce que notre page est dans le menu « Outils ». Si vous l’ajoutez ailleurs, le hook changera !
Le fichier worker.js : Le cœur du traitement
Voici notre Web Worker qui va faire tout le travail lourd :
// worker.js - Notre Web Worker pour traiter le CSV
// Écouter les messages du thread principal
self.onmessage = function(e) {
const { action, data } = e.data;
switch(action) {
case 'parseCSV':
parseCSVData(data.csvContent, data.options);
break;
case 'validateRow':
validateSingleRow(data.row, data.rules);
break;
}
};
// Fonction principale de parsing CSV
function parseCSVData(csvContent, options = {}) {
try {
// Configuration par défaut
const config = {
delimiter: options.delimiter || ',',
skipHeaders: options.skipHeaders || true,
batchSize: options.batchSize || 100
};
// Diviser le contenu en lignes
const lines = csvContent.split('\n').filter(line => line.trim());
const totalLines = lines.length;
// Récupérer les en-têtes si nécessaire
let headers = [];
let startIndex = 0;
if (config.skipHeaders && lines.length > 0) {
headers = parseCSVLine(lines[0], config.delimiter);
startIndex = 1;
// Envoyer les headers au thread principal
self.postMessage({
type: 'headers',
headers: headers
});
}
// Traitement par batch pour éviter de saturer la mémoire
for (let i = startIndex; i < lines.length; i += config.batchSize) {
const batch = [];
const endIndex = Math.min(i + config.batchSize, lines.length);
// Parser chaque ligne du batch
for (let j = i; j < endIndex; j++) {
const rowData = parseCSVLine(lines[j], config.delimiter);
// Créer un objet avec les headers comme clés
const rowObject = {};
if (headers.length > 0) {
headers.forEach((header, index) => {
rowObject[header] = rowData[index] || '';
});
} else {
rowObject.data = rowData;
}
// Valider la ligne
const validation = validateRowData(rowObject);
if (validation.isValid) {
batch.push({
row: j + 1,
data: rowObject,
status: 'valid'
});
} else {
batch.push({
row: j + 1,
data: rowObject,
status: 'invalid',
errors: validation.errors
});
}
}
// Envoyer le batch traité
self.postMessage({
type: 'batch',
batch: batch,
progress: Math.round((i / totalLines) * 100)
});
// Petite pause pour ne pas monopoliser le CPU
// (même si on est dans un worker, c'est une bonne pratique)
await new Promise(resolve => setTimeout(resolve, 1));
}
// Signaler la fin du traitement
self.postMessage({
type: 'complete',
totalProcessed: lines.length - startIndex
});
} catch (error) {
// Gestion des erreurs
self.postMessage({
type: 'error',
message: error.message,
stack: error.stack
});
}
}
// Parser une ligne CSV (gestion des guillemets et échappements)
function parseCSVLine(line, delimiter = ',') {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
// Guillemet échappé
current += '"';
i++; // Skip next quote
} else {
// Toggle quote state
inQuotes = !inQuotes;
}
} else if (char === delimiter && !inQuotes) {
// Nouveau champ
result.push(current.trim());
current = '';
} else {
current += char;
}
}
// Ajouter le dernier champ
result.push(current.trim());
return result;
}
// Validation des données (adaptez selon vos besoins)
function validateRowData(rowData) {
const errors = [];
// Exemple de validation pour un import de produits
if (!rowData.name || rowData.name.trim() === '') {
errors.push('Le nom du produit est requis');
}
if (rowData.price && isNaN(parseFloat(rowData.price))) {
errors.push('Le prix doit être un nombre');
}
if (rowData.email && !isValidEmail(rowData.email)) {
errors.push('Email invalide');
}
return {
isValid: errors.length === 0,
errors: errors
};
}
// Helper pour valider les emails
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
Script principal côté WordPress
Maintenant le script qui va orchestrer tout ça dans l’admin :
// csv-import.js - Script principal dans WordPress
jQuery(document).ready(function($) {
let worker = null;
let importData = [];
let isImporting = false;
// Initialiser le worker quand on en a besoin
function initWorker() {
if (worker) {
worker.terminate(); // Nettoyer l'ancien worker
}
worker = new Worker(csvImportAjax.worker_url);
// Écouter les messages du worker
worker.onmessage = function(e) {
const { type, ...data } = e.data;
switch(type) {
case 'headers':
handleHeaders(data.headers);
break;
case 'batch':
handleBatch(data.batch, data.progress);
break;
case 'complete':
handleComplete(data.totalProcessed);
break;
case 'error':
handleError(data.message);
break;
}
};
worker.onerror = function(error) {
console.error('Erreur Worker:', error);
showMessage('Erreur du Web Worker: ' + error.message, 'error');
};
}
// Gestionnaire pour le téléchargement de fichier
$('#csv-file-input').on('change', function(e) {
const file = e.target.files[0];
if (!file) return;
// Vérifications basiques
if (!file.name.toLowerCase().endsWith('.csv')) {
showMessage('Veuillez sélectionner un fichier CSV', 'error');
return;
}
if (file.size > 10 * 1024 * 1024) { // 10MB max
showMessage('Fichier trop volumineux (max 10MB)', 'error');
return;
}
// Lire le fichier
const reader = new FileReader();
reader.onload = function(event) {
const csvContent = event.target.result;
startImportProcess(csvContent);
};
reader.onerror = function() {
showMessage('Erreur lors de la lecture du fichier', 'error');
};
reader.readAsText(file);
});
// Démarrer le processus d'import
function startImportProcess(csvContent) {
if (isImporting) {
showMessage('Un import est déjà en cours', 'warning');
return;
}
isImporting = true;
importData = [];
// Initialiser l'interface
$('#import-progress').show();
$('#progress-bar').width('0%');
$('#progress-text').text('Démarrage...');
$('#import-results').hide();
// Initialiser et démarrer le worker
initWorker();
const options = {
delimiter: $('#delimiter-select').val() || ',',
skipHeaders: $('#skip-headers').is(':checked'),
batchSize: parseInt($('#batch-size').val()) || 100
};
worker.postMessage({
action: 'parseCSV',
data: { csvContent, options }
});
}
// Gérer la réception des headers
function handleHeaders(headers) {
console.log('Headers détectés:', headers);
showMessage('En-têtes détectés: ' + headers.join(', '), 'info');
}
// Gérer chaque batch de données
function handleBatch(batch, progress) {
// Ajouter les données au tableau global
importData = importData.concat(batch);
// Mettre à jour la barre de progression
$('#progress-bar').width(progress + '%');
$('#progress-text').text(`Traitement en cours... ${progress}%`);
// Optionnel: envoyer les données valides au serveur par batch
const validRows = batch.filter(row => row.status === 'valid');
if (validRows.length > 0) {
sendBatchToServer(validRows);
}
}
// Import terminé
function handleComplete(totalProcessed) {
isImporting = false;
$('#progress-bar').width('100%');
$('#progress-text').text('Import terminé !');
// Afficher les résultats
const validCount = importData.filter(row => row.status === 'valid').length;
const invalidCount = importData.filter(row => row.status === 'invalid').length;
$('#import-results').show();
$('#valid-count').text(validCount);
$('#invalid-count').text(invalidCount);
$('#total-count').text(totalProcessed);
// Afficher les erreurs s'il y en a
if (invalidCount > 0) {
displayErrors();
}
showMessage(`Import terminé: ${validCount} lignes valides, ${invalidCount} erreurs`, 'success');
}
// Envoyer un batch au serveur WordPress
function sendBatchToServer(validRows) {
$.ajax({
url: csvImportAjax.ajaxurl,
type: 'POST',
data: {
action: 'process_csv_batch',
nonce: csvImportAjax.nonce,
batch_data: JSON.stringify(validRows)
},
success: function(response) {
if (!response.success) {
console.error('Erreur serveur:', response.data);
}
},
error: function(xhr, status, error) {
console.error('Erreur AJAX:', error);
}
});
}
// Afficher les erreurs de validation
function displayErrors() {
const errorRows = importData.filter(row => row.status === 'invalid');
let errorHtml = '<h4>Erreurs détectées:</h4><ul>';
errorRows.slice(0, 10).forEach(row => { // Limiter à 10 erreurs
errorHtml += `<li>Ligne ${row.row}: ${row.errors.join(', ')}</li>`;
});
if (errorRows.length > 10) {
errorHtml += `<li>... et ${errorRows.length - 10} autres erreurs</li>`;
}
errorHtml += '</ul>';
$('#error-details').html(errorHtml);
}
// Gérer les erreurs du worker
function handleError(message) {
isImporting = false;
showMessage('Erreur: ' + message, 'error');
console.error('Erreur Worker:', message);
}
// Helper pour afficher les messages
function showMessage(message, type = 'info') {
const alertClass = type === 'error' ? 'notice-error' :
type === 'warning' ? 'notice-warning' :
type === 'success' ? 'notice-success' : 'notice-info';
const messageHtml = `<div class="notice ${alertClass} is-dismissible"><p>${message}</p></div>`;
$('#import-messages').html(messageHtml);
// Auto-dismiss après 5 secondes pour les messages de succès
if (type === 'success' || type === 'info') {
setTimeout(() => {
$('#import-messages').fadeOut();
}, 5000);
}
}
// Nettoyer le worker quand on quitte la page
$(window).on('beforeunload', function() {
if (worker) {
worker.terminate();
}
});
});
Interface utilisateur dans WordPress
Voici le HTML de notre page d’admin :
function csv_import_admin_page() {
?>
<div class="wrap">
<h1>Import CSV avec Web Worker</h1>
<div id="import-messages"></div>
<div class="card">
<h2>Configuration</h2>
<table class="form-table">
<tr>
<th><label for="csv-file-input">Fichier CSV</label></th>
<td><input type="file" id="csv-file-input" accept=".csv" /></td>
</tr>
<tr>
<th><label for="delimiter-select">Délimiteur</label></th>
<td>
<select id="delimiter-select">
<option value=",">Virgule (,)</option>
<option value=";">Point-virgule (;)</option>
<option value="\t">Tabulation</option>
</select>
</td>
</tr>
<tr>
<th><label for="skip-headers">Ignorer la première ligne</label></th>
<td><input type="checkbox" id="skip-headers" checked /></td>
</tr>
<tr>
<th><label for="batch-size">Taille des lots</label></th>
<td><input type="number" id="batch-size" value="100" min="10" max="1000" /></td>
</tr>
</table>
</div>
<div id="import-progress" style="display:none;" class="card">
<h2>Progression</h2>
<div class="progress-wrapper">
<div id="progress-bar" style="width:0%; height:20px; background:#0073aa;"></div>
</div>
<p id="progress-text">En attente...</p>
</div>
<div id="import-results" style="display:none;" class="card">
<h2>Résultats</h2>
<ul>
<li>Lignes valides: <strong id="valid-count">0</strong></li>
<li>Lignes avec erreurs: <strong id="invalid-count">0</strong></li>
<li>Total traité: <strong id="total-count">0</strong></li>
</ul>
<div id="error-details"></div>
</div>
</div>
<style>
.progress-wrapper {
width: 100%;
background-color: #f0f0f0;
border-radius: 4px;
overflow: hidden;
margin: 10px 0;
}
#progress-bar {
height: 20px;
background: linear-gradient(90deg, #0073aa, #005a87);
transition: width 0.3s ease;
}
.card {
background: white;
padding: 20px;
margin: 20px 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.13);
}
</style>
<?php
}
Traitement côté serveur
Et enfin, la partie serveur qui reçoit les données du worker :
// Handler AJAX pour traiter les batches
add_action('wp_ajax_process_csv_batch', 'handle_csv_batch_processing');
function handle_csv_batch_processing() {
// Vérifications de sécurité
if (!check_ajax_referer('csv_import_nonce', 'nonce', false)) {
wp_send_json_error('Nonce invalide');
}
if (!current_user_can('manage_options')) {
wp_send_json_error('Permissions insuffisantes');
}
$batch_data = json_decode(stripslashes($_POST['batch_data']), true);
if (!$batch_data || !is_array($batch_data)) {
wp_send_json_error('Données invalides');
}
$processed = 0;
$errors = [];
foreach ($batch_data as $row) {
try {
// Ici, adaptez selon votre cas d'usage
// Exemple pour créer des posts
$post_data = array(
'post_title' => sanitize_text_field($row['data']['name']),
'post_content' => sanitize_textarea_field($row['data']['description']),
'post_status' => 'publish',
'post_type' => 'product' // ou votre custom post type
);
$post_id = wp_insert_post($post_data);
if (is_wp_error($post_id)) {
$errors[] = "Ligne {$row['row']}: " . $post_id->get_error_message();
} else {
// Ajouter les meta données
if (isset($row['data']['price'])) {
update_post_meta($post_id, '_price', floatval($row['data']['price']));
}
$processed++;
}
} catch (Exception $e) {
$errors[] = "Ligne {$row['row']}: " . $e->getMessage();
}
}
wp_send_json_success(array(
'processed' => $processed,
'errors' => $errors
));
}
Voilà ! Avec cette implémentation, vous avez un système d’import robuste qui ne bloquera jamais l’interface WordPress, même avec des fichiers de plusieurs milliers de lignes. Le Web Worker traite tout en arrière-plan pendant que l’utilisateur peut continuer à naviguer.
Traitement d’images et manipulation de canvas
L’un des cas d’usage les plus intéressants pour les Web Workers dans WordPress, c’est le traitement d’images avant upload. On peut carrément soulager le serveur en redimensionnant et compressant les images côté client. Et croyez-moi, vos utilisateurs vont adorer ne plus attendre 30 secondes pour uploader une photo de 8Mo !
Redimensionnement et compression côté client
Le principe est simple : on intercepte les fichiers avant qu’ils partent vers le serveur, et on les traite dans un Web Worker. Voici comment créer notre worker de traitement d’image :
// image-processor.worker.js
self.onmessage = function(e) {
const { file, maxWidth, quality, format } = e.data;
const canvas = new OffscreenCanvas(800, 600);
const ctx = canvas.getContext('2d');
createImageBitmap(file).then(bitmap => {
// Calcul des nouvelles dimensions
const scale = Math.min(maxWidth / bitmap.width, maxWidth / bitmap.height);
const newWidth = bitmap.width * scale;
const newHeight = bitmap.height * scale;
canvas.width = newWidth;
canvas.height = newHeight;
ctx.drawImage(bitmap, 0, 0, newWidth, newHeight);
// Conversion avec compression
canvas.convertToBlob({
type: format || 'image/jpeg',
quality: quality || 0.8
}).then(blob => {
self.postMessage({
success: true,
blob: blob,
originalSize: file.size,
compressedSize: blob.size,
compressionRatio: ((file.size - blob.size) / file.size * 100).toFixed(1)
});
});
}).catch(error => {
self.postMessage({ success: false, error: error.message });
});
};
Ce worker utilise OffscreenCanvas quand c’est disponible (c’est plus performant), sinon on peut faire un fallback avec un canvas classique. La compression JPEG à 0.8 donne généralement un bon compromis qualité/taille.
Intégration avec la médiathèque WordPress
Maintenant, il faut intégrer ça proprement avec wp.media. On va étendre l’uploader natif pour intercepter les fichiers :
// Dans votre fichier admin
const imageWorker = new Worker('/wp-content/themes/votre-theme/js/image-processor.worker.js');
// Extension de l'uploader WordPress
wp.Uploader.queue.on('add', function(file) {
if (file.type.indexOf('image/') === 0 && file.size > 500000) { // Si > 500Ko
// Pause de la file d'attente
this.uploader.stop();
imageWorker.postMessage({
file: file.getNative(),
maxWidth: 1920,
quality: 0.85,
format: 'image/jpeg'
});
imageWorker.onmessage = (e) => {
if (e.data.success) {
// Remplacement du fichier original
const newFile = new File([e.data.blob], file.name, {
type: e.data.blob.type
});
// Mise à jour des métadonnées
file.attachment.set({
size: e.data.compressedSize,
compression_ratio: e.data.compressionRatio
});
// Reprise de l'upload
this.uploader.start();
console.log(`Image compressée : ${e.data.compressionRatio}% d'économie`);
}
};
}
});
Bon, là je simplifie un peu, mais vous voyez l’idée. On intercepte les gros fichiers, on les traite, puis on reprend l’upload normal. L’utilisateur voit juste une petite notification « Optimisation en cours… » pendant quelques secondes.
Optimisation mémoire et gestion des gros fichiers
Attention : traiter des images de 20Mo peut faire planter le navigateur ! Il faut être malin avec la gestion mémoire :
// Gestion par chunks pour les gros fichiers
function processLargeImage(file, chunkSize = 2048) {
if (file.size > 10000000) { // > 10Mo
return processImageByChunks(file, chunkSize);
}
return processImageNormally(file);
}
function processImageByChunks(file, chunkSize) {
// Traitement par portions avec canvas temporaires
const canvas = new OffscreenCanvas(chunkSize, chunkSize);
const ctx = canvas.getContext('2d');
return createImageBitmap(file).then(bitmap => {
const chunks = [];
const cols = Math.ceil(bitmap.width / chunkSize);
const rows = Math.ceil(bitmap.height / chunkSize);
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
// Traitement chunk par chunk
const x = col * chunkSize;
const y = row * chunkSize;
const width = Math.min(chunkSize, bitmap.width - x);
const height = Math.min(chunkSize, bitmap.height - y);
canvas.width = width;
canvas.height = height;
ctx.drawImage(bitmap, x, y, width, height, 0, 0, width, height);
chunks.push(canvas.convertToBlob());
}
}
return Promise.all(chunks);
});
}
Et n’oubliez pas les fallbacks pour les navigateurs qui ne supportent pas OffscreenCanvas (IE, Safari plus anciens) :
function createWorkerWithFallback() {
if (typeof OffscreenCanvas !== 'undefined') {
return new Worker('image-processor.worker.js');
}
// Fallback : traitement sur le thread principal
return {
postMessage: function(data) {
setTimeout(() => this.processOnMainThread(data), 0);
},
processOnMainThread: function(data) {
// Même logique mais avec canvas classique
const canvas = document.createElement('canvas');
// ... traitement synchrone
}
};
}
Les métriques que je vois généralement : 60-80% de réduction de taille pour les photos, et un gain de 3-4x sur la vitesse d’upload. Pas mal, non ?
