Éliminer le CLS en optimisant les images avec Vite

J'adore Vite, non seulement parce qu'il est rapide, mais aussi pour son incroyable système de plugins.

Au fond, un plugin Vite vous permet de modifier à la volée n'importe quel fichier importé, qu'il existe ou non, dans votre projet.

Une fois que vous avez compris ça, tout devient possible. Tout est un plugin Vite !


Depuis des années, je construis des sites orientés contenu où les articles sont écrits en Markdown. J'aime aussi utiliser des images pour illustrer mon contenu.

Cependant, les images sont délicates. Pour améliorer les performances, vous voulez les charger en différé (lazy loading), mais le lazy loading provoque inévitablement du Cumulative Layout Shift (CLS) si vous ne réservez pas d'espace. Pour réserver cet espace, le navigateur doit connaître à l'avance les dimensions de l'image. Mais vous ne voulez pas toujours les mêmes dimensions partout, donc pour chaque image vous avez besoin de ses dimensions afin que le navigateur puisse réserver la bonne quantité d'espace.

Au-delà de ça, les images peuvent rapidement devenir volumineuses et les héberger dans votre dépôt n'est pas idéal. Vous voudrez peut-être les transférer dans un bucket dédié. Elles seront alors servies depuis un autre domaine, ce qui, combiné au lazy loading, même avec les bonnes dimensions, laissera un espace vide jusqu'à leur chargement complet.

Exemple de Cumulative Layout Shift (CLS) lors du lazy loading d'une image sans espace réservé.

C'est un problème solvable, mais il demande beaucoup de travail manuel. Je n'aime pas le travail manuel et répétitif qui peut être automatisé !

Note

Dans cet article, on traitera de l'HTML. Un autre article expliquera comment gérer le contenu Markdown avec Markdown Exit.

Vite à la rescousse

La première question qui vient à l'esprit est la suivante : comment savoir si un plugin Vite est l'outil adapté pour ce travail ?

C'est une question importante. Je pourrais simplement vous donner la solution, mais vous ne pourriez pas appliquer une approche similaire à vos propres problèmes. Malheureusement, la plupart des tutoriels passent à côté de cette étape.

Analysons le problème en regardant ses entrées et ses sorties.

L'entrée est probablement un extrait HTML brut comme celui-ci :

html
<div>
  <img src="/path/to/image.jpg" alt="An image">
</div>

La sortie devrait être un extrait HTML optimisé comme celui-ci :

html
<div>
  <img
    src="https://cdn.example.com/path/to/image-optimized.jpg"
    alt="An image"
    width="600"
    height="400"
    loading="lazy"
    style="background-image: url('data:image/svg+xml;base64,...'); background-size: cover;"
  >
</div>

Vite est construit autour d'un pipeline qui traite les fichiers au moment où ils sont importés. Cela signifie que nous pouvons nous brancher sur le pipeline, détecter quand le code contient une image et la transformer en conséquence.

Notre cas d'usage correspond parfaitement aux capacités de Vite. Les plugins Vite peuvent avoir des effets de bord sur les fichiers importés. Ils ouvrent des cas d'usage très puissants comme la compression et le redimensionnement automatiques des images, ...

Note

Cet article suppose que les images sont disponibles localement. La gestion des images distantes ne sera pas couverte ici, mais je donnerai quelques pistes à la fin de l'article.

Construire le plugin

Quel est le plan ? C'est bien d'avoir une idée de ce qu'on veut obtenir, mais comment y parvenir ?

Nous devons nous brancher sur le pipeline de Vite et chercher les templates Vue. Ensuite, nous devons trouver toutes les balises <img>, extraire leurs attributs src, charger les images, récupérer leurs dimensions, générer le placeholder flou, et enfin remplacer les balises <img> originales par des versions optimisées.

Note

J'utilise Vue pour tous mes projets, mais la même approche s'applique à n'importe quel framework front pris en charge par Vite.

Plus facile à dire qu'à faire, surtout quand on ne sait pas par où commencer ni quel outil utiliser.

Pour tout le traitement d'image, j'utilise unpic. C'est un excellent ensemble de primitives pour tout ce qui touche aux images. Pour manipuler le code et générer des source maps, j'utilise MagicString.

C'est tout ce dont on a besoin !

Maintenant, on peut créer un plugin Vite qui fait exactement ce qu'on veut.

Note

Vous pouvez utiliser un projet Vite + Vue comme point de départ. Le code source du plugin final est disponible sur GitHub.

Dans le fichier vite.config.ts de notre projet, commençons par créer un plugin :

ts
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    vue(),
    (() => {
      return {
        name: 'unpic',
      }
    })()
  ],
})

Au minimum, un plugin Vite doit avoir un nom. Notre plugin est une IIFE (Immediately Invoked Function Expression). Cela nous permet d'avoir un contexte pour stocker de l'état sans dépendre de variables globales.

Ensuite, nous devons nous brancher au bon endroit du pipeline. Il existe beaucoup de hooks, mais en pratique le plus utile est transform. Ce hook est appelé pour chaque fichier importé dans le projet. Il reçoit le code du fichier et son id (chemin). Nous ne voulons traiter que les fichiers Vue, donc nous pouvons filtrer par extension.

ts
(() => {
  return {
    name: 'unpic',
    async transform(code, id) {
      if (!id.endsWith('.vue')) 
        return

      // Some magic will happen here

      return {
        code,
        map: null,
      }
    }
  }
})()

Dernière chose avant de commencer : nous devons nous assurer que le plugin s'exécute avant le plugin Vue. Vue transforme les fichiers Vue en fichiers JavaScript, et les composants monofichiers (SFC) sont bien plus simples à traiter. Pour cela, nous avons deux options :

  1. Placer notre plugin avant le plugin Vue dans le tableau plugins.
  2. Utiliser l'option enforce: 'pre' dans la définition du plugin.
ts
(() => {
  return {
    name: 'unpic',
    enforce: 'pre', 
    async transform(code, id) {
      // ...
    }
  }
})()

Maintenant, nous pouvons commencer à traiter les fichiers Vue pour améliorer les balises <img>.

Transformer le code

Dans le hook transform, il faut :

  1. Analyser le code pour trouver toutes les balises <img>.
  2. Pour chaque balise <img>, extraire l'attribut src.
  3. Lire le fichier depuis le système de fichiers.
  4. Traiter l'image avec unpic pour obtenir ses dimensions et un placeholder flou.
  5. Générer et remplacer la balise <img> originale par une balise <img> optimisée avec tous les attributs nécessaires.

Avant de commencer, nous devons installer les dépendances :

bash
pnpm install -D @unpic/pixels @unpic/placeholder blurhash magic-string

Implémentons cela étape par étape.

  1. Analyser le code pour trouver toutes les balises <img>.
ts
const imgTagRegex = /<img\s[^>]*src=["']([^"']+)["'][^>]*>/g
// eslint-disable-next-line prefer-const
let match = imgTagRegex.exec(code)
  1. Pour chaque balise <img>, extraire l'attribut src.
ts
do {
  const srcValue = match[1]

  match = imgTagRegex.exec(code)
} while (match !== null)
  1. Lire le fichier depuis le système de fichiers.
ts
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'

const img = await readFile(join(publicDir, srcValue))
  1. Traiter l'image avec unpic pour obtenir ses dimensions et un placeholder flou.
ts
import { getPixels } from '@unpic/pixels'
import { blurhashToDataUri } from '@unpic/placeholder'
import { encode } from 'blurhash'

const data = await getPixels(img)
const blurhash = encode(Uint8ClampedArray.from(data.data), data.width, data.height, 4, 4)
  1. Générer et remplacer la balise <img> originale par une balise <img> optimisée avec tous les attributs nécessaires.
ts
import { blurhashToDataUri } from '@unpic/placeholder'

const newImgTag = match[0].replace(
  /<img(\s+)/,
  `<img$1width="${data.width}" height="${data.height}" style="background-size: cover; background-image: url(${blurhashToDataUri(blurhash)});" loading="lazy" `
)

Après avoir implémenté toutes ces étapes, notre plugin final ressemble à ceci :

ts
(() => {
  const publicDir = join(cwd(), 'public')
  return {
    name: 'unpic',
    enforce: 'pre',
    async transform(code, id) {
      if (!id.endsWith('.vue'))
        return

      const s = new MagicString(code)

      const imgTagRegex = /<img\s[^>]*src=["']([^"']+)["'][^>]*>/g
      let match = imgTagRegex.exec(code)

      if (!match) {
        return {
          code,
          map: null
        }
      }

      do {
        const srcValue = match[1]

        const img = await readFile(join(publicDir, srcValue))
        const data = await getPixels(img)
        const blurhash = encode(Uint8ClampedArray.from(data.data), data.width, data.height, 4, 4)

        const imgTagStart = match.index
        const imgTagEnd = imgTagStart + match[0].length

        const newImgTag = match[0].replace(
          /<img(\s+)/,
          `<img$1width="${data.width}" height="${data.height}" style="background-size: cover; background-image: url(${blurhashToDataUri(blurhash)});" loading="lazy" `
        )

        s.overwrite(imgTagStart, imgTagEnd, newImgTag)

        match = imgTagRegex.exec(code)
      } while (match !== null)

      return {
        code: s.toString(),
        map: s.generateMap({ hires: true })
      }
    }
  }
})()

Le code source complet est également disponible sur Barbapapazes/vite-vue-unpic.

Avec ce plugin, nous offrons une meilleure expérience utilisateur sans ajouter de travail manuel à notre processus de création de contenu.

Exemple d'image optimisée avec espace réservé et lazy loading.

Au chargement de la page, l'espace de l'image est déjà réservé grâce aux attributs width et height, et un placeholder flou est affiché via le style background-image jusqu'au chargement complet de l'image. Cela élimine efficacement les problèmes de CLS liés aux images chargées en différé et améliore l'expérience utilisateur en fournissant un repère visuel pendant le chargement.

Observer le pipeline

Nous pouvons observer comment notre plugin interagit avec le pipeline de Vite en utilisant vite-plugin-inspect.

sh
pnpm add -D vite-plugin-inspect

Ensuite, ajoutez-le au fichier vite.config.ts :

ts
import { defineConfig } from 'vite'
import inspect from 'vite-plugin-inspect'

export default defineConfig({
  plugins: [
    inspect()

    // ...
  ]
})

Puis lancez le serveur de développement :

sh
pnpm dev

Et ouvrez le panneau d'inspection à l'adresse http://localhost:5173/__inspect/.

Vous pouvez voir tous les fichiers traités par Vite. En cliquant sur App.vue, vous pouvez voir comment notre plugin a transformé le code étape par étape.

Vite Plugin Inspect - Image Transformation Steps
Vite Plugin Inspect - Image Transformation Steps

Aller plus loin

À travers cet exemple relativement simple mais utile, j'espère vous avoir montré la puissance des plugins Vite.

Une fois que vous avez compris l'état d'esprit et les bases, vous pouvez quasiment tout automatiser dans votre projet. Depuis que j'ai appris à créer des plugins Vite, je les utilise tout le temps pour résoudre des problèmes dont je n'imaginais même pas qu'ils pouvaient l'être autrement.

Pour en savoir plus sur les plugins, je recommande vivement de lire le code source de plugins populaires comme vite-plugin-vue ou unplugin-icons ainsi que ceux de VitePress. Le vite-plugin-inspect aide vraiment à comprendre comment les plugins interagissent entre eux et modifient le code.

Ceci dit, c'est un exemple simple. Si vous avez beaucoup d'images, vous voudrez peut-être ajouter du cache et du traitement en parallèle pour accélérer le temps de build. Vous pouvez aussi gérer les images distantes en les téléchargeant avant de les traiter.

À propos des images distantes, au lieu de lire le système de fichiers, vous pouvez récupérer les images depuis votre bucket, les traiter, puis préfixer l'attribut src avec l'URL de votre CDN. Cela évite d'avoir à committer des images dans votre dépôt et garde l'URL du CDN à un seul endroit.


J'envisage de préparer un petit talk sur les plugins Vite. Ils sont sous-estimés et peu documentés. Dites-moi si cela vous intéresse !

Pd

Merci de me lire ! Je m'appelle Estéban, et j'adore écrire sur le développement web et le parcours humain qui l'entoure.

Je code depuis plusieurs années maintenant, et j'apprends encore de nouvelles choses chaque jour. J'aime partager mes connaissances avec les autres, car j'aurais aimé avoir accès à des ressources aussi claires et complètes lorsque j'ai commencé à apprendre la programmation.

Si vous avez des questions ou souhaitez discuter, n'hésitez pas à commenter ci-dessous ou à me contacter sur Bluesky, X, et LinkedIn.

J'espère que vous avez apprécié cet article et appris quelque chose de nouveau. N'hésitez pas à le partager avec vos amis ou sur les réseaux sociaux, et laissez un commentaire ou une réaction ci-dessous, cela me ferait très plaisir ! Si vous souhaitez soutenir mon travail, vous pouvez me sponsoriser sur GitHub !

Réactions

Discussions

Ajouter un commentaire

Vous devez être connecté pour accéder à cette fonctionnalité.

Soutenez mon travail
Suivez-moi sur