Intermediaire 17 min de lecture · 3 607 mots

Optimisation images : Formats modernes (WebP, AVIF) et lazy loading

Estimated reading time: 18 minutes

Introduction

Les images représentent ~50% du poids total d’une page web moyenne. Une mauvaise optimisation impacte directement LCP, bandwidth, et conversion rate.

Impact business mesuré

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

1. Formats d’images modernes

1.1 Comparaison des formats

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%)

1.2 WebP: Le standard actuel

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%)

1.3 AVIF: Le futur (déjà là)

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:



  
  

  
  

  
  Hero image

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));

2. Responsive images

2.1 Comprendre srcset et sizes

srcset: Liste les variantes disponibles
sizes: Indique quelle taille sera affichée selon le viewport


Photo


Sizes avec media queries:


Product


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, ..."

2.2 Art direction avec picture

Cas d’usage: Recadrage différent selon le device.




  
  

  

  
  

  

  
  

  

  
  Hero banner

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');

3. Lazy loading

3.1 Lazy loading natif

Implémentation simple:


Description


Hero


Auto

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

3.2 Lazy loading avancé avec Intersection Observer

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é:


Photo


Photo



  
  
  Photo

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;
}

3.3 Progressive image loading (LQIP)

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:


Photo





4. CDN et optimisation automatique

4.1 Cloudflare Images

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:


Hero

4.2 imgix / Cloudinary

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)

5. Monitoring et debugging

5.1 Analyse des images chargées

// 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%       │
└────────┴─────────┴────────────┘

5.2 Lighthouse custom audits

// 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
      }
    };
  }
}

6. Cas pratique complet: Blog performant

Configuration complète

// 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');

Composant React/Vue

// 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 && (
        
      )}
      {alt}
    
  );
}

// Usage


7. Checklist finale

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

Ressources

Tools en ligne

npm install sharp         # Manipulation images
npm install imagemin      # Optimisation CLI
npm install @squoosh/lib  # Squoosh programmatique

Documentation

  • web.dev Image Optimization
  • Can I Use WebP/AVIF

  • Conclusion

    L’optimisation des images est le quick win #1 pour la performance web:

  • Gains immédiats de 60-80% sur le poids
  • Amélioration directe du LCP
  • Impact business mesurable
  • Prochaines étapes:

  • Audit des images actuelles
  • Setup pipeline d’optimisation automatique
  • Migration progressive vers WebP/AVIF
  • Monitoring continu des métriques
  • Les techniques présentées sont toutes en production sur des sites à fort trafic avec ROI prouvé.

    Une remarque, un retour ?

    Cet article est vivant — corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.