React Hooks avancés : useEffect, useContext, et custom hooks
Introduction Les hooks React sont la fondation des applications modernes. Après avoir maîtrisé useState, il…
Les images représentent ~50% du poids total d’une page web moyenne. Une mauvaise optimisation impacte directement LCP, bandwidth, et conversion rate.
Cas réel - Site e-commerce:
├── Optimisation images: 4.2MB → 850KB (-80%)
├── LCP: 4.8s → 1.6s (-67%)
├── Bounce rate: -24%
└── Conversion: +18%
ROI: 1 semaine de travail = 18% de revenus supplémentaires
Format | Compression | Qualité | Support | Use Case
--------|-------------|---------|-----------|------------------
JPEG | Lossy | Bonne | 100% | Photos (legacy)
PNG | Lossless | Max | 100% | Logos, transparence
WebP | Both | Très bon| 97% | Tout (moderne)
AVIF | Lossy | Excellent| 84% | Photos (cutting edge)
SVG | Lossless | Parfait | 100% | Icônes, logos
Gains typiques (photo 1920x1080):
├── JPEG (quality 85): 450 KB
├── WebP (quality 85): 180 KB (-60%)
└── AVIF (quality 85): 95 KB (-79%)
Conversion avec tools en ligne de commande:
# Installation (Ubuntu/Debian)
sudo apt-get install webp
# Installation (macOS)
brew install webp
# Conversion simple
cwebp input.jpg -q 85 -o output.webp
# Conversion batch
for file in .jpg; do
cwebp "$file" -q 85 -o "${file%.jpg}.webp"
done
# Conversion avec métadonnées
cwebp input.jpg -q 85 -metadata all -o output.webp
# Optimisation aggressive
cwebp input.jpg -q 80 -m 6 -af -o output.webp
# -m 6: méthode compression max (0-6)
# -af: auto-filter
# Comparaison visuelle
dwebp output.webp -o check.png
compare input.jpg check.png -compose src diff.png
Script Node.js automatisé:
// optimize-images.js
const sharp = require('sharp');
const fs = require('fs').promises;
const path = require('path');
class ImageOptimizer {
constructor(options = {}) {
this.options = {
quality: 85,
formats: ['webp', 'avif'],
sizes: [320, 640, 1024, 1920],
outputDir: 'optimized',
...options
};
}
async processDirectory(inputDir) {
const files = await fs.readdir(inputDir);
const imageFiles = files.filter(f =>
/.(jpe?g|png)$/i.test(f)
);
console.log(Processing ${imageFiles.length} images...);
const results = await Promise.all(
imageFiles.map(file =>
this.processImage(path.join(inputDir, file))
)
);
return this.generateReport(results);
}
async processImage(inputPath) {
const filename = path.basename(inputPath, path.extname(inputPath));
const stats = await fs.stat(inputPath);
const originalSize = stats.size;
const results = {
input: inputPath,
originalSize,
outputs: []
};
// Génération des variantes
for (const width of this.options.sizes) {
for (const format of this.options.formats) {
const output = await this.generateVariant(
inputPath,
filename,
width,
format
);
results.outputs.push(output);
}
}
return results;
}
async generateVariant(inputPath, filename, width, format) {
const outputPath = path.join(
this.options.outputDir,
${filename}-${width}w.${format}
);
await fs.mkdir(this.options.outputDir, { recursive: true });
const pipeline = sharp(inputPath)
.resize(width, null, {
withoutEnlargement: true,
fit: 'inside'
});
// Configuration par format
switch (format) {
case 'webp':
pipeline.webp({
quality: this.options.quality,
effort: 6 // 0-6, plus élevé = meilleure compression
});
break;
case 'avif':
pipeline.avif({
quality: this.options.quality,
effort: 6,
chromaSubsampling: '4:2:0'
});
break;
case 'jpeg':
pipeline.jpeg({
quality: this.options.quality,
mozjpeg: true,
progressive: true
});
break;
}
await pipeline.toFile(outputPath);
const stats = await fs.stat(outputPath);
return {
path: outputPath,
size: stats.size,
width,
format
};
}
generateReport(results) {
const totalOriginal = results.reduce((sum, r) => sum + r.originalSize, 0);
const totalOptimized = results.reduce((sum, r) =>
sum + r.outputs.reduce((s, o) => s + o.size, 0), 0
);
const report = {
filesProcessed: results.length,
originalSize: totalOriginal,
optimizedSize: totalOptimized,
savings: totalOriginal - totalOptimized,
savingsPercent: ((totalOriginal - totalOptimized) / totalOriginal 100).toFixed(2),
details: results.map(r => ({
file: path.basename(r.input),
original: this.formatBytes(r.originalSize),
variants: r.outputs.map(o => ({
file: path.basename(o.path),
size: this.formatBytes(o.size),
reduction: this.formatPercent(
(r.originalSize - o.size) / r.originalSize
)
}))
}))
};
// Console output
console.log('n=== Optimization Report ===');
console.log(Files processed: ${report.filesProcessed});
console.log(Original size: ${this.formatBytes(totalOriginal)});
console.log(Optimized size: ${this.formatBytes(totalOptimized)});
console.log(Savings: ${this.formatBytes(report.savings)} (${report.savingsPercent}%));
return report;
}
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
formatPercent(ratio) {
return (ratio 100).toFixed(1) + '%';
}
}
// Utilisation
const optimizer = new ImageOptimizer({
quality: 85,
formats: ['webp', 'avif', 'jpeg'],
sizes: [320, 640, 1024, 1920, 2560],
outputDir: 'public/images/optimized'
});
optimizer.processDirectory('source-images')
.then(report => {
fs.writeFile(
'optimization-report.json',
JSON.stringify(report, null, 2)
);
});
Résultats exemple:
=== Optimization Report ===
Files processed: 24
Original size: 18.4 MB
Optimized size: 3.2 MB
Savings: 15.2 MB (82.6%)
Top conversions:
├── hero-banner.jpg (1.8MB)
│ ├── hero-banner-1920w.avif (120KB, -93%)
│ ├── hero-banner-1920w.webp (215KB, -88%)
│ └── hero-banner-1024w.webp (98KB, -95%)
└── product-photo.jpg (850KB)
├── product-photo-640w.avif (42KB, -95%)
└── product-photo-640w.webp (68KB, -92%)
Avantages AVIF:
Comparaison qualité/taille (photo 1920x1080):
├── JPEG q85: 450 KB (baseline)
├── WebP q85: 180 KB (-60%)
└── AVIF q85: 95 KB (-79%)
Mais attention au support:
├── Chrome/Edge: ✓ (depuis 2020)
├── Firefox: ✓ (depuis 2021)
├── Safari: ✓ (depuis iOS 16/macOS 13)
└── IE/anciens navigateurs: ✗
Implémentation progressive:
Detection JavaScript côté client:
// Feature detection
class FormatDetector {
static async detectSupport() {
const formats = {
avif: 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A=',
webp: 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA='
};
const results = {};
for (const [format, dataUri] of Object.entries(formats)) {
results[format] = await this.canDisplay(dataUri);
}
return results;
}
static canDisplay(dataUri) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = dataUri;
});
}
static async getBestFormat() {
const support = await this.detectSupport();
if (support.avif) return 'avif';
if (support.webp) return 'webp';
return 'jpeg';
}
}
// Utilisation avec dynamic loading
class ImageLoader {
constructor() {
this.formatPromise = FormatDetector.getBestFormat();
}
async loadImage(basePath, width) {
const format = await this.formatPromise;
const url = ${basePath}-${width}w.${format};
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
}
// Usage
const loader = new ImageLoader();
loader.loadImage('/images/hero', 1920)
.then(img => document.body.appendChild(img));
srcset: Liste les variantes disponibles
sizes: Indique quelle taille sera affichée selon le viewport
Sizes avec media queries:
Générateur automatique de sizes:
// auto-sizes.js
class ResponsiveImageHelper {
static generateSizes(breakpoints) {
// breakpoints = { 768: '100vw', 1200: '50vw', default: '33vw' }
const entries = Object.entries(breakpoints)
.filter(([key]) => key !== 'default')
.sort(([a], [b]) => Number(a) - Number(b))
.map(([bp, size]) => (max-width: ${bp}px) ${size});
entries.push(breakpoints.default || '100vw');
return entries.join(', ');
}
static calculateOptimalSizes(element) {
// Mesure la taille réelle dans différents viewports
const viewports = [375, 768, 1024, 1440, 1920];
const sizes = {};
viewports.forEach(vw => {
// Simulation (en production, utiliser device testing)
const containerWidth = this.getContainerWidth(element, vw);
const percentage = Math.round((containerWidth / vw) 100);
sizes[vw] = ${percentage}vw;
});
return sizes;
}
static generateSrcset(basePath, widths, format = 'webp') {
return widths
.map(w => ${basePath}-${w}w.${format} ${w}w)
.join(', ');
}
}
// Usage
const sizes = ResponsiveImageHelper.generateSizes({
768: '100vw',
1200: '50vw',
default: '33vw'
});
// → "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
const srcset = ResponsiveImageHelper.generateSrcset(
'/images/product',
[320, 640, 1024, 1920],
'webp'
);
// → "/images/product-320w.webp 320w, /images/product-640w.webp 640w, ..."
Cas d’usage: Recadrage différent selon le device.
Script de génération de crops:
// generate-crops.js
const sharp = require('sharp');
async function generateArtDirectionVariants(inputPath) {
const configs = [
// Mobile portrait (9:16)
{
name: 'mobile',
widths: [320, 640],
aspectRatio: 9/16,
gravity: 'center'
},
// Tablet (4:3)
{
name: 'tablet',
widths: [768, 1024],
aspectRatio: 4/3,
gravity: 'center'
},
// Desktop (16:9)
{
name: 'desktop',
widths: [1920, 2560],
aspectRatio: 16/9,
gravity: 'center'
}
];
for (const config of configs) {
for (const width of config.widths) {
const height = Math.round(width / config.aspectRatio);
await sharp(inputPath)
.resize(width, height, {
fit: 'cover',
position: config.gravity
})
.webp({ quality: 85 })
.toFile(output/hero-${config.name}-${width}w.webp);
await sharp(inputPath)
.resize(width, height, {
fit: 'cover',
position: config.gravity
})
.avif({ quality: 85 })
.toFile(output/hero-${config.name}-${width}w.avif);
console.log(Generated ${config.name} ${width}x${height});
}
}
}
generateArtDirectionVariants('source/hero.jpg');
Implémentation simple:
Quand utiliser quoi:
loading="eager":
├── Hero images
├── Logo
├── Above-the-fold content
└── Images critiques pour LCP
loading="lazy":
├── Images en bas de page
├── Galeries photos
├── Sidebar content
└── Footer images
loading="auto":
└── Laisser le navigateur optimiser (rarement utilisé)
Mesure de l’impact:
// Monitoring lazy load performance
const lazyImages = document.querySelectorAll('img[loading="lazy"]');
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.entryType === 'resource' && entry.initiatorType === 'img') {
console.log(Image loaded: ${entry.name});
console.log( Duration: ${entry.duration}ms);
console.log( Size: ${entry.transferSize} bytes);
// Analytics
if (window.gtag) {
gtag('event', 'imageload', {
url: entry.name,
duration: Math.round(entry.duration),
size: entry.transferSize
});
}
}
});
});
observer.observe({ entryTypes: ['resource'] });
Gains mesurés:
Page blog avec 20 images:
Sans lazy loading:
├── Initial page weight: 8.2 MB
├── Load time: 6.8s
├── LCP: 4.2s
└── Data transferred: 8.2 MB
Avec lazy loading:
├── Initial page weight: 1.1 MB (-87%)
├── Load time: 1.9s (-72%)
├── LCP: 1.6s (-62%)
└── Data transferred (scroll to bottom): 8.2 MB
└── Data saved (no scroll): 7.1 MB
Pour navigateurs anciens ou contrôle fin:
// advanced-lazy-load.js
class LazyLoader {
constructor(options = {}) {
this.options = {
root: null,
rootMargin: '50px', // Charger 50px avant l'apparition
threshold: 0.01,
loadingClass: 'lazy-loading',
loadedClass: 'lazy-loaded',
errorClass: 'lazy-error',
...options
};
this.observer = null;
this.init();
}
init() {
// Fallback pour anciens navigateurs
if (!('IntersectionObserver' in window)) {
this.loadAllImages();
return;
}
this.observer = new IntersectionObserver(
this.onIntersection.bind(this),
this.options
);
this.observe();
}
observe() {
const images = document.querySelectorAll('img[data-src]');
images.forEach(img => this.observer.observe(img));
// Support picture element
const pictures = document.querySelectorAll('picture');
pictures.forEach(picture => {
const img = picture.querySelector('img[data-src]');
if (img) this.observer.observe(img);
});
}
onIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
this.observer.unobserve(entry.target);
}
});
}
loadImage(img) {
img.classList.add(this.options.loadingClass);
// Performance mark
const markName = lazy-load-start-${img.dataset.src};
performance.mark(markName);
// Gestion picture element
const picture = img.closest('picture');
if (picture) {
const sources = picture.querySelectorAll('source[data-srcset]');
sources.forEach(source => {
source.srcset = source.dataset.srcset;
delete source.dataset.srcset;
});
}
// Load handlers
img.addEventListener('load', () => this.onLoad(img, markName), { once: true });
img.addEventListener('error', () => this.onError(img), { once: true });
// Set src (déclenche le chargement)
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
delete img.dataset.srcset;
}
img.src = img.dataset.src;
delete img.dataset.src;
}
onLoad(img, markName) {
img.classList.remove(this.options.loadingClass);
img.classList.add(this.options.loadedClass);
// Measure performance
performance.mark(lazy-load-end-${img.src});
performance.measure(
lazy-load-${img.src},
markName,
lazy-load-end-${img.src}
);
const measure = performance.getEntriesByName(lazy-load-${img.src})[0];
console.log(Lazy loaded: ${img.src} in ${measure.duration.toFixed(2)}ms);
// Trigger custom event
img.dispatchEvent(new CustomEvent('lazyloaded', {
bubbles: true,
detail: { duration: measure.duration }
}));
}
onError(img) {
img.classList.remove(this.options.loadingClass);
img.classList.add(this.options.errorClass);
// Fallback image
if (this.options.fallbackSrc) {
img.src = this.options.fallbackSrc;
}
console.error(Failed to load: ${img.dataset.src || img.src});
}
loadAllImages() {
// Fallback sans IntersectionObserver
const images = document.querySelectorAll('img[data-src]');
images.forEach(img => this.loadImage(img));
}
// API publique
refresh() {
this.observe();
}
destroy() {
if (this.observer) {
this.observer.disconnect();
}
}
}
// Initialisation
const lazyLoader = new LazyLoader({
rootMargin: '100px',
fallbackSrc: '/images/placeholder.svg'
});
// Event listener global
document.addEventListener('lazyloaded', (e) => {
console.log('Image loaded:', e.target.src);
});
HTML associé:
CSS pour transitions:
/ Placeholder blur effect /
img.lazy.blur {
filter: blur(10px);
transition: filter 0.3s;
}
img.lazy-loaded.blur {
filter: blur(0);
}
/ Loading state /
img.lazy-loading {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/ Fade-in loaded /
img.lazy-loaded {
animation: fadeIn 0.5s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/ Error state /
img.lazy-error {
border: 2px solid #e74c3c;
opacity: 0.5;
}
Low Quality Image Placeholder:
// lqip-generator.js
const sharp = require('sharp');
const fs = require('fs').promises;
async function generateLQIP(inputPath, quality = 20) {
const image = sharp(inputPath);
const metadata = await image.metadata();
// Tiny placeholder (< 1KB)
const lqip = await image
.resize(Math.round(metadata.width / 10), null, {
fit: 'inside'
})
.blur(1)
.jpeg({ quality, progressive: true })
.toBuffer();
// Base64 pour inline
const base64 = lqip.toString('base64');
const dataUri = data:image/jpeg;base64,${base64};
return {
dataUri,
size: lqip.length,
dimensions: {
width: metadata.width,
height: metadata.height
}
};
}
// Génération batch
async function generateAllLQIP(inputDir) {
const files = await fs.readdir(inputDir);
const results = {};
for (const file of files) {
if (/.(jpg|jpeg|png)$/i.test(file)) {
const lqip = await generateLQIP(${inputDir}/${file});
results[file] = lqip;
console.log(${file}: ${lqip.size} bytes);
}
}
await fs.writeFile(
'lqip-data.json',
JSON.stringify(results, null, 2)
);
return results;
}
generateAllLQIP('images/source');
Utilisation avec lazy loading:
Configuration:
// Cloudflare Images URL patterns
const CDNBASE = 'https://images.example.com';
function getCloudflareImageUrl(imageId, options = {}) {
const {
width = 'auto',
quality = 85,
format = 'auto',
fit = 'scale-down',
gravity = 'auto'
} = options;
return ${CDN_BASE}/cdn-cgi/image/ +
width=${width}, +
quality=${quality}, +
format=${format}, +
fit=${fit}, +
gravity=${gravity} +
/${imageId};
}
// Usage
const heroUrl = getCloudflareImageUrl('hero-banner.jpg', {
width: 1920,
quality: 85,
format: 'auto' // Sert automatiquement WebP/AVIF si supporté
});
// Responsive URLs
const srcset = [320, 640, 1024, 1920]
.map(w => ${getCloudflareImageUrl('hero.jpg', { width: w })} ${w}w)
.join(', ');
HTML avec Cloudflare Polish:
imgix example:
// imgix-helper.js
class ImgixHelper {
constructor(domain) {
this.domain = domain;
}
buildUrl(path, params = {}) {
const defaults = {
auto: 'format,compress',
q: 85
};
const merged = { ...defaults, ...params };
const query = new URLSearchParams(merged).toString();
return https://${this.domain}/${path}?${query};
}
buildSrcset(path, widths, params = {}) {
return widths
.map(w => {
const url = this.buildUrl(path, { ...params, w });
return ${url} ${w}w;
})
.join(', ');
}
buildResponsivePicture(path, breakpoints) {
// breakpoints = { mobile: {...}, tablet: {...}, desktop: {...} }
const sources = [];
for (const [name, config] of Object.entries(breakpoints)) {
sources.push({
media: config.media,
srcset: this.buildSrcset(path, config.widths, config.params),
sizes: config.sizes
});
}
return sources;
}
}
// Usage
const imgix = new ImgixHelper('myapp.imgix.net');
const heroSrcset = imgix.buildSrcset('hero.jpg', [320, 640, 1024, 1920], {
fit: 'crop',
crop: 'faces,edges',
ar: '16:9'
});
const pictureSources = imgix.buildResponsivePicture('product.jpg', {
mobile: {
media: '(max-width: 768px)',
widths: [320, 640],
params: { ar: '1:1', fit: 'crop' },
sizes: '100vw'
},
desktop: {
media: '(min-width: 769px)',
widths: [1024, 1920],
params: { ar: '16:9', fit: 'crop' },
sizes: '50vw'
}
});
Résultats performance:
Sans CDN image optimization:
├── Requests: 45
├── Total image size: 8.2 MB
├── LCP: 4.2s
└── Server load: High
Avec Cloudflare Images / imgix:
├── Requests: 45 (inchangé)
├── Total image size: 1.8 MB (-78%)
├── LCP: 1.6s (-62%)
├── Cache hit rate: 95%
└── Server load: Minimal (offloaded)
// image-performance-monitor.js
class ImagePerformanceMonitor {
constructor() {
this.images = [];
this.init();
}
init() {
// Observer Resource Timing
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.initiatorType === 'img') {
this.analyzeImageLoad(entry);
}
});
});
observer.observe({ entryTypes: ['resource'] });
// Report final
window.addEventListener('load', () => {
setTimeout(() => this.generateReport(), 1000);
});
}
analyzeImageLoad(entry) {
const image = {
url: entry.name,
size: entry.transferSize,
duration: entry.duration,
cached: entry.transferSize === 0,
timing: {
dns: entry.domainLookupEnd - entry.domainLookupStart,
tcp: entry.connectEnd - entry.connectStart,
ttfb: entry.responseStart - entry.requestStart,
download: entry.responseEnd - entry.responseStart
}
};
// Détection du format
const ext = entry.name.split('.').pop().split('?')[0];
image.format = ext.toLowerCase();
// Warnings
if (image.size > 500000) {
console.warn(Large image: ${entry.name} (${this.formatBytes(image.size)}));
}
if (image.duration > 1000) {
console.warn(Slow image load: ${entry.name} (${image.duration.toFixed(0)}ms));
}
this.images.push(image);
}
generateReport() {
const totalSize = this.images.reduce((sum, img) => sum + img.size, 0);
const totalDuration = Math.max(...this.images.map(img => img.duration));
const cachedCount = this.images.filter(img => img.cached).length;
const formatBreakdown = this.images.reduce((acc, img) => {
acc[img.format] = (acc[img.format] || 0) + img.size;
return acc;
}, {});
const report = {
summary: {
totalImages: this.images.length,
totalSize: this.formatBytes(totalSize),
longestLoad: ${totalDuration.toFixed(0)}ms,
cacheHitRate: ${((cachedCount / this.images.length) 100).toFixed(1)}%
},
formats: Object.entries(formatBreakdown).map(([format, size]) => ({
format,
size: this.formatBytes(size),
percentage: ((size / totalSize) 100).toFixed(1) + '%'
})),
slowest: this.images
.sort((a, b) => b.duration - a.duration)
.slice(0, 5)
.map(img => ({
url: img.url,
size: this.formatBytes(img.size),
duration: ${img.duration.toFixed(0)}ms
})),
largest: this.images
.sort((a, b) => b.size - a.size)
.slice(0, 5)
.map(img => ({
url: img.url,
size: this.formatBytes(img.size)
}))
};
console.table(report.summary);
console.table(report.formats);
console.log('Slowest images:', report.slowest);
console.log('Largest images:', report.largest);
return report;
}
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
// Auto-init
new ImagePerformanceMonitor();
Console output exemple:
┌─────────────┬──────────┐
│ Summary │ Value │
├─────────────┼──────────┤
│ totalImages │ 24 │
│ totalSize │ 1.8 MB │
│ longestLoad │ 420ms │
│ cacheHitRate│ 87.5% │
└─────────────┴──────────┘
┌────────┬─────────┬────────────┐
│ Format │ Size │ Percentage │
├────────┼─────────┼────────────┤
│ webp │ 1.2 MB │ 66.7% │
│ avif │ 450 KB │ 25.0% │
│ jpg │ 150 KB │ 8.3% │
└────────┴─────────┴────────────┘
// custom-image-audit.js
class ImageOptimizationAudit {
static async audit(artifacts) {
const images = artifacts.ImageElements;
const issues = [];
images.forEach(img => {
// Vérifications
if (!img.width || !img.height) {
issues.push({
type: 'missing-dimensions',
url: img.src,
message: 'Missing width/height attributes (causes CLS)'
});
}
if (img.loading !== 'lazy' && !img.isLCP) {
issues.push({
type: 'missing-lazy',
url: img.src,
message: 'Should use loading="lazy"'
});
}
if (!img.srcset && img.naturalWidth > 800) {
issues.push({
type: 'missing-responsive',
url: img.src,
message: 'Large image without srcset'
});
}
const format = img.src.split('.').pop().split('?')[0].toLowerCase();
if (['jpg', 'jpeg', 'png'].includes(format)) {
issues.push({
type: 'legacy-format',
url: img.src,
message: 'Consider using WebP/AVIF'
});
}
if (img.displayedWidth < img.naturalWidth 0.5) {
issues.push({
type: 'oversized',
url: img.src,
message: Served ${img.naturalWidth}px, displayed ${img.displayedWidth}px,
waste: ((img.naturalWidth - img.displayedWidth) / img.naturalWidth 100).toFixed(0) + '%'
});
}
});
return {
score: Math.max(0, 1 - (issues.length / images.length)),
numericValue: issues.length,
details: {
items: issues
}
};
}
}
// build-optimized-blog.js
const sharp = require('sharp');
const fs = require('fs').promises;
const path = require('path');
class BlogImageOptimizer {
constructor() {
this.formats = ['avif', 'webp', 'jpeg'];
this.sizes = {
thumbnail: [320, 480],
medium: [640, 960],
large: [1024, 1536],
hero: [1920, 2560]
};
}
async processArticleImages(articleDir) {
const images = await fs.readdir(articleDir);
for (const image of images) {
if (!/.(jpg|jpeg|png)$/i.test(image)) continue;
const inputPath = path.join(articleDir, image);
const basename = path.basename(image, path.extname(image));
// Générer toutes les variantes
for (const [sizeName, widths] of Object.entries(this.sizes)) {
for (const width of widths) {
await this.generateVariants(
inputPath,
basename,
sizeName,
width
);
}
}
// LQIP
await this.generateLQIP(inputPath, basename);
}
// Générer le manifest
await this.generateManifest(articleDir);
}
async generateVariants(input, basename, sizeName, width) {
const outputDir = path.join(path.dirname(input), 'optimized');
await fs.mkdir(outputDir, { recursive: true });
for (const format of this.formats) {
const outputPath = path.join(
outputDir,
${basename}-${sizeName}-${width}w.${format}
);
const pipeline = sharp(input).resize(width, null, {
withoutEnlargement: true,
fit: 'inside'
});
switch (format) {
case 'avif':
await pipeline.avif({ quality: 80, effort: 6 }).toFile(outputPath);
break;
case 'webp':
await pipeline.webp({ quality: 85, effort: 6 }).toFile(outputPath);
break;
case 'jpeg':
await pipeline.jpeg({ quality: 85, mozjpeg: true, progressive: true }).toFile(outputPath);
break;
}
console.log(Generated: ${path.basename(outputPath)});
}
}
async generateLQIP(input, basename) {
const outputDir = path.join(path.dirname(input), 'optimized');
const lqip = await sharp(input)
.resize(20, null, { fit: 'inside' })
.blur(1)
.jpeg({ quality: 20 })
.toBuffer();
const base64 = lqip.toString('base64');
await fs.writeFile(
path.join(outputDir, ${basename}-lqip.txt),
data:image/jpeg;base64,${base64}
);
}
async generateManifest(articleDir) {
// JSON manifest pour le frontend
const optimizedDir = path.join(articleDir, 'optimized');
const files = await fs.readdir(optimizedDir);
const manifest = {};
files.forEach(file => {
const match = file.match(/^(.+?)-(thumbnail|medium|large|hero)-(d+)w.(avif|webp|jpeg)$/);
if (match) {
const [, basename, size, width, format] = match;
if (!manifest[basename]) manifest[basename] = {};
if (!manifest[basename][size]) manifest[basename][size] = {};
if (!manifest[basename][size][format]) manifest[basename][size][format] = [];
manifest[basename][size][format].push({
width: parseInt(width),
path: /images/optimized/${file}
});
}
});
await fs.writeFile(
path.join(optimizedDir, 'manifest.json'),
JSON.stringify(manifest, null, 2)
);
return manifest;
}
}
// Utilisation
const optimizer = new BlogImageOptimizer();
optimizer.processArticleImages('content/blog/article-1/images');
// ResponsiveImage.jsx
import React, { useState, useEffect } from 'react';
import manifest from './images/optimized/manifest.json';
export function ResponsiveImage({ src, alt, size = 'large', loading = 'lazy' }) {
const [lqip, setLqip] = useState(null);
const basename = src.split('/').pop().replace(/.[^.]+$/, '');
const imageData = manifest[basename]?.[size];
useEffect(() => {
// Load LQIP
fetch(/images/optimized/${basename}-lqip.txt)
.then(r => r.text())
.then(data => setLqip(data));
}, [basename]);
if (!imageData) return null;
const buildSrcset = (format) => {
return imageData[format]
?.map(img => ${img.path} ${img.width}w)
.join(', ');
};
const sizes = {
thumbnail: '(max-width: 640px) 100vw, 320px',
medium: '(max-width: 1024px) 100vw, 640px',
large: '(max-width: 1920px) 100vw, 1024px',
hero: '100vw'
};
return (
{imageData.avif && (
)}
{imageData.webp && (
)}
);
}
// Usage
Pre-production:
□ Toutes les images converties en WebP minimum
□ AVIF pour les images critiques (support 84%)
□ Dimensions (width/height) sur toutes les images
□ srcset avec minimum 3 tailles (320w, 640w, 1024w)
□ sizes attribut configuré correctement
□ loading="lazy" sauf above-the-fold
□ LQIP ou placeholders pour améliorer perception
□ Alt text descriptifs (SEO + accessibilité)
Production:
□ CDN activé avec compression automatique
□ Cache headers appropriés (1 an pour images)
□ Monitoring images chargées (RUM)
□ Alertes si LCP > 2.5s
□ Budget performance défini et respecté
Outils:
□ Lighthouse CI dans pipeline
□ ImageOptim / Squoosh pour vérifications manuelles
□ WebPageTest pour tests réseaux lents
□ Chrome DevTools pour profiling
npm install sharp # Manipulation images
npm install imagemin # Optimisation CLI
npm install @squoosh/lib # Squoosh programmatique
L’optimisation des images est le quick win #1 pour la performance web:
Prochaines étapes:
Les techniques présentées sont toutes en production sur des sites à fort trafic avec ROI prouvé.
Cet article est vivant — corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.