core-web-vitals

Par mkurman · zorai

Optimisez les Core Web Vitals (LCP, INP, CLS) pour améliorer l'expérience de page et le classement dans les moteurs de recherche. À utiliser lorsqu'on vous demande d'« améliorer les Core Web Vitals », de « corriger le LCP », de « réduire le CLS », d'« optimiser l'INP », d'« optimiser l'expérience de page » ou de « corriger les décalages de mise en page ».

npx skills add https://github.com/mkurman/zorai --skill core-web-vitals

LCP : Largest Contentful Paint

LCP mesure le moment où l'élément de contenu visible le plus grand s'affiche. Il s'agit généralement de :

  • Une image ou vidéo hero
  • Un grand bloc de texte
  • Une image de fond
  • Un élément <svg>

Problèmes LCP courants

1. Réponse serveur lente (TTFB > 800ms)

Fix: CDN, caching, backend optimisé, edge rendering

2. Ressources bloquantes pour le rendu

<!-- ❌ Bloque le rendu -->
<link rel="stylesheet" href="/all-styles.css">

<!-- ✅ CSS critique inséré, reste différé -->
<style>/* CSS critique above-fold */</style>
<link rel="preload" href="/styles.css" as="style" 
      onload="this.onload=null;this.rel='stylesheet'">

3. Temps de chargement de ressources lent

<!-- ❌ Pas d'indices, découvert tard -->
<img src="/hero.jpg" alt="Hero">

<!-- ✅ Préchargé avec priorité haute -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<img src="/hero.webp" alt="Hero" fetchpriority="high">

4. Délais de rendu côté client

// ❌ Contenu chargé après JavaScript
useEffect(() => {
  fetch('/api/hero-text').then(r => r.json()).then(setHeroText);
}, []);

// ✅ Rendu côté serveur ou statique
// Utilisez SSR, SSG, ou streaming pour envoyer du HTML avec le contenu
export async function getServerSideProps() {
  const heroText = await fetchHeroText();
  return { props: { heroText } };
}

Liste de contrôle d'optimisation LCP

- [ ] TTFB < 800ms (utiliser CDN, edge caching)
- [ ] Image LCP préchargée avec fetchpriority="high"
- [ ] Image LCP optimisée (WebP/AVIF, taille correcte)
- [ ] CSS critique inséré (< 14 KB)
- [ ] Aucun JavaScript bloquant le rendu dans <head>
- [ ] Les polices n'interfèrent pas avec le rendu du texte (font-display: swap)
- [ ] Élément LCP dans le HTML initial (pas rendu par JS)

Identification de l'élément LCP

// Trouvez votre élément LCP
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  console.log('LCP element:', lastEntry.element);
  console.log('LCP time:', lastEntry.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });

INP : Interaction to Next Paint

INP mesure la réactivité sur TOUTES les interactions (clics, appuis, pressions clavier) lors d'une visite de page. Il rapporte la pire interaction (au 98e percentile pour les pages à fort trafic).

Décomposition INP

INP total = Input Delay + Processing Time + Presentation Delay

Phase Cible Optimisation
Input Delay < 50ms Réduire le blocage du thread principal
Processing < 100ms Optimiser les gestionnaires d'événements
Presentation < 50ms Minimiser le travail de rendu

Problèmes INP courants

1. Tâches longues bloquant le thread principal

// ❌ Tâche synchrone longue
function processLargeArray(items) {
  items.forEach(item => expensiveOperation(item));
}

// ✅ Diviser en chunks avec cession du contrôle
async function processLargeArray(items) {
  const CHUNK_SIZE = 100;
  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    chunk.forEach(item => expensiveOperation(item));

    // Cédez le thread principal
    await new Promise(r => setTimeout(r, 0));
    // Ou utilisez scheduler.yield() si disponible
  }
}

2. Gestionnaires d'événements lourds

// ❌ Tout le travail dans le gestionnaire
button.addEventListener('click', () => {
  // Calcul lourd
  const result = calculateComplexThing();
  // Mises à jour DOM
  updateUI(result);
  // Analytics
  trackEvent('click');
});

// ✅ Prioriser le retour visuel
button.addEventListener('click', () => {
  // Retour visuel immédiat
  button.classList.add('loading');

  // Différer le travail non critique
  requestAnimationFrame(() => {
    const result = calculateComplexThing();
    updateUI(result);
  });

  // Utiliser requestIdleCallback pour analytics
  requestIdleCallback(() => trackEvent('click'));
});

3. Scripts tiers

// ❌ Chargé activement, bloque les interactions
<script src="https://heavy-widget.com/widget.js"></script>

// ✅ Chargé paresseusement à l'interaction ou visibilité
const loadWidget = () => {
  import('https://heavy-widget.com/widget.js')
    .then(widget => widget.init());
};
button.addEventListener('click', loadWidget, { once: true });

4. Re-rendus excessifs (React/Vue)

// ❌ Re-rend toute l'arborescence
function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <Counter count={count} />
      <ExpensiveComponent /> {/* Re-rend à chaque changement de count */}
    </div>
  );
}

// ✅ Composants coûteux mémorisés
const MemoizedExpensive = React.memo(ExpensiveComponent);

function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <Counter count={count} />
      <MemoizedExpensive />
    </div>
  );
}

Liste de contrôle d'optimisation INP

- [ ] Aucune tâche > 50ms sur le thread principal
- [ ] Les gestionnaires d'événements se complètent rapidement (< 100ms)
- [ ] Retour visuel fourni immédiatement
- [ ] Travail lourd différé avec requestIdleCallback
- [ ] Scripts tiers ne bloquent pas les interactions
- [ ] Gestionnaires d'entrée debounced le cas échéant
- [ ] Web Workers pour les opérations gourmandes en CPU

Débogage INP

// Identifier les interactions lentes
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 200) {
      console.warn('Slow interaction:', {
        type: entry.name,
        duration: entry.duration,
        processingStart: entry.processingStart,
        processingEnd: entry.processingEnd,
        target: entry.target
      });
    }
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });

CLS : Cumulative Layout Shift

CLS mesure les changements de mise en page inattendus. Un décalage se produit quand un élément visible change de position entre les images sans interaction utilisateur.

Formule CLS : impact fraction × distance fraction

Causes CLS courantes

1. Images sans dimensions

<!-- ❌ Provoque un changement de mise en page au chargement -->
<img src="photo.jpg" alt="Photo">

<!-- ✅ Espace réservé -->
<img src="photo.jpg" alt="Photo" width="800" height="600">

<!-- ✅ Ou utiliser aspect-ratio -->
<img src="photo.jpg" alt="Photo" style="aspect-ratio: 4/3; width: 100%;">

2. Annonces, intégrations et iframes

<!-- ❌ Taille inconnue jusqu'au chargement -->
<iframe src="https://ad-network.com/ad"></iframe>

<!-- ✅ Réserver l'espace avec min-height -->
<div style="min-height: 250px;">
  <iframe src="https://ad-network.com/ad" height="250"></iframe>
</div>

<!-- ✅ Ou utiliser un conteneur aspect-ratio -->
<div style="aspect-ratio: 16/9;">
  <iframe src="https://youtube.com/embed/..." 
          style="width: 100%; height: 100%;"></iframe>
</div>

3. Contenu injecté dynamiquement

// ❌ Insère du contenu au-dessus de la fenêtre
notifications.prepend(newNotification);

// ✅ Insérer au-dessous ou utiliser transform
const insertBelow = viewport.bottom < newNotification.top;
if (insertBelow) {
  notifications.prepend(newNotification);
} else {
  // Animer sans décaler
  newNotification.style.transform = 'translateY(-100%)';
  notifications.prepend(newNotification);
  requestAnimationFrame(() => {
    newNotification.style.transform = '';
  });
}

4. Polices web causant FOUT

/* ❌ Échange de police décale le texte */
@font-face {
  font-family: 'Custom';
  src: url('custom.woff2') format('woff2');
}

/* ✅ Police optionnelle (pas de décalage si lente) */
@font-face {
  font-family: 'Custom';
  src: url('custom.woff2') format('woff2');
  font-display: optional;
}

/* ✅ Ou correspondre aux métriques de secours */
@font-face {
  font-family: 'Custom';
  src: url('custom.woff2') format('woff2');
  font-display: swap;
  size-adjust: 105%; /* Correspondre à la taille de secours */
  ascent-override: 95%;
  descent-override: 20%;
}

5. Animations déclenchant la mise en page

/* ❌ Anime les propriétés de mise en page */
.animate {
  transition: height 0.3s, width 0.3s;
}

/* ✅ Utiliser transform à la place */
.animate {
  transition: transform 0.3s;
}
.animate.expanded {
  transform: scale(1.2);
}

Liste de contrôle d'optimisation CLS

- [ ] Toutes les images ont width/height ou aspect-ratio
- [ ] Toutes les vidéos/intégrations ont de l'espace réservé
- [ ] Les annonces ont des conteneurs min-height
- [ ] Les polices utilisent font-display: optional ou métriques correspondantes
- [ ] Contenu dynamique inséré au-dessous de la fenêtre
- [ ] Les animations utilisent uniquement transform/opacity
- [ ] Aucun contenu injecté au-dessus du contenu existant

Débogage CLS

// Suivre les changements de mise en page
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      console.log('Layout shift:', entry.value);
      entry.sources?.forEach(source => {
        console.log('  Shifted element:', source.node);
        console.log('  Previous rect:', source.previousRect);
        console.log('  Current rect:', source.currentRect);
      });
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

Outils de mesure

Tests en laboratoire

  • Chrome DevTools → Panneau Performance, Lighthouse
  • WebPageTest → Waterfall détaillé, filmstrip
  • Lighthouse CLInpx lighthouse <url>

Données de terrain (utilisateurs réels)

  • Chrome User Experience Report (CrUX) → BigQuery ou API
  • Search Console → Rapport Core Web Vitals
  • web-vitals library → Envoyer à votre analytics
import {onLCP, onINP, onCLS} from 'web-vitals';

function sendToAnalytics({name, value, rating}) {
  gtag('event', name, {
    event_category: 'Web Vitals',
    value: Math.round(name === 'CLS' ? value * 1000 : value),
    event_label: rating
  });
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);

Corrections rapides par framework

Next.js

// LCP: Utiliser next/image avec priority
import Image from 'next/image';
<Image src="/hero.jpg" priority fill alt="Hero" />

// INP: Utiliser les imports dynamiques
const HeavyComponent = dynamic(() => import('./Heavy'), { ssr: false });

// CLS: Le composant Image gère les dimensions automatiquement

React

// LCP: Précharger dans le head
<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high" />

// INP: Mémoriser et useTransition
const [isPending, startTransition] = useTransition();
startTransition(() => setExpensiveState(newValue));

// CLS: Toujours spécifier les dimensions dans les tags img

Vue/Nuxt

<!-- LCP: Utiliser nuxt/image avec preload -->
<NuxtImg src="/hero.jpg" preload loading="eager" />

<!-- INP: Utiliser les composants async -->
<component :is="() => import('./Heavy.vue')" />

<!-- CLS: Utiliser aspect-ratio CSS -->
<img :style="{ aspectRatio: '16/9' }" />

Références

Skills similaires