Maîtriser l'hydration dans Vue 3 et Nuxt : Guide avancé avec TypeScript

Plongez dans les mécanismes internes de l'hydration avec Vue 3 et Nuxt. Découvrez comment optimiser les performances SSR, gérer les mismatches, et implémenter des stratégies d'hydration avancées avec TypeScript.

07 Oct, 2025 15 min de lecture
Maîtriser l'hydration dans Vue 3 et Nuxt : Guide avancé avec TypeScript

Comprendre l'hydration : Au-delà des bases

L'hydration est le processus par lequel Vue "réveille" le HTML statique généré côté serveur en lui attachant l'interactivité JavaScript côté client. Ce mécanisme est au cœur des performances SSR, mais reste mal compris par de nombreux développeurs.

Définition technique : L'hydration est la réconciliation entre le DOM généré par le serveur et le VDOM (Virtual DOM) créé par Vue côté client. Vue réutilise le DOM existant au lieu de le remplacer, en attachant les event listeners et en initialisant la réactivité.

Le cycle de vie de l'hydration

Phase 1 : Server-Side Rendering

Lors du SSR, Vue génère du HTML statique sans interactivité :

// server/render.ts
import { renderToString } from 'vue/server-renderer'
import { createSSRApp } from 'vue'
import type { App } from 'vue'

interface ServerContext {
  request: Request
  response: Response
}

export async function render(
  url: string, 
  context: ServerContext
): Promise<string> {
  const app: App = createSSRApp({
    data() {
      return { count: 0 }
    },
    template: `
      <div id="app">
        <button @click="count++">{{ count }}</button>
      </div>
    `
  })

  // Génère HTML statique : <div id="app"><button>0</button></div>
  const html = await renderToString(app, context)
  
  return html
}

Phase 2 : Client-Side Hydration

Le client reçoit le HTML et Vue l'hydrate :

// client/entry.ts
import { createSSRApp } from 'vue'
import type { Component } from 'vue'

const app = createSSRApp(rootComponent)

// ⚠️ Utilise .mount() sans le rendre à nouveau
// Vue réutilise le DOM existant et attache l'interactivité
app.mount('#app', true) // 2ème param = hydration mode

Phases internes de l'hydration

// Pseudo-code simplifié du processus d'hydration Vue
function hydrateElement(
  node: Element,
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null
): void {
  // 1. Vérification du type de nœud
  if (node.nodeType !== vnode.shapeFlag) {
    throw new HydrationMismatchError()
  }

  // 2. Hydratation des props et attributs
  if (vnode.props) {
    for (const key in vnode.props) {
      if (isOn(key)) {
        // Attacher les event listeners
        addEventListener(node, key, vnode.props[key])
      } else {
        // Vérifier les attributs
        if (node.getAttribute(key) !== vnode.props[key]) {
          console.warn('Hydration mismatch:', key)
        }
      }
    }
  }

  // 3. Hydratation récursive des enfants
  let nextNode = node.firstChild
  for (const child of vnode.children) {
    nextNode = hydrateElement(nextNode, child, parentComponent)
    nextNode = nextNode?.nextSibling
  }

  // 4. Initialisation de la réactivité
  if (vnode.component) {
    setupComponent(vnode.component)
    vnode.component.isMounted = true
  }

  return node.nextSibling
}

Problèmes d'hydration : Causes et solutions

1. Hydration Mismatch classique

Le problème le plus fréquent : le HTML serveur diffère du HTML client.

// ❌ MAUVAIS : Génère un mismatch
<template>
  <div>
    <!-- Différent entre serveur et client -->
    <span>{{ new Date().getTime() }}</span>
  </div>
</template>

// ✅ BON : Rendu cohérent
<script setup lang="ts">
import { ref, onMounted } from 'vue'

const timestamp = ref<number | null>(null)

// Générer côté client uniquement après hydration
onMounted(() => {
  timestamp.value = Date.now()
})
</script>

<template>
  <div>
    <span v-if="timestamp">{{ timestamp }}</span>
    <span v-else>Loading...</span>
  </div>
</template>

2. State Hydration avec Pinia

Synchroniser le state entre serveur et client :

// stores/user.ts
import { defineStore } from 'pinia'
import type { User } from '~/types'

interface UserState {
  user: User | null
  isLoading: boolean
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    user: null,
    isLoading: false
  }),

  actions: {
    async fetchUser(id: string): Promise<void> {
      // Éviter de refetch si déjà chargé (SSR)
      if (this.user?.id === id) return

      this.isLoading = true
      try {
        const { data } = await useFetch<User>(`/api/users/${id}`)
        this.user = data.value
      } finally {
        this.isLoading = false
      }
    }
  }
})

// plugins/hydration.ts
export default defineNuxtPlugin((nuxtApp) => {
  const pinia = nuxtApp.$pinia

  // Côté serveur : sérialiser le state
  if (process.server) {
    nuxtApp.hook('app:rendered', () => {
      nuxtApp.payload.pinia = pinia.state.value
    })
  }

  // Côté client : hydrater depuis le payload
  if (process.client) {
    nuxtApp.hook('app:created', () => {
      pinia.state.value = nuxtApp.payload.pinia || {}
    })
  }
})

3. Async Data et hydration

Gérer les données asynchrones correctement :

// composables/useAsyncHydration.ts
import type { Ref } from 'vue'

interface AsyncHydrationOptions<T> {
  key: string
  fetcher: () => Promise<T>
  default?: T
}

export function useAsyncHydration<T>({
  key,
  fetcher,
  default: defaultValue
}: AsyncHydrationOptions<T>): {
  data: Ref<T | undefined>
  pending: Ref<boolean>
  error: Ref<Error | null>
  refresh: () => Promise<void>
} {
  const nuxtApp = useNuxtApp()
  const data = ref<T | undefined>(defaultValue)
  const pending = ref(true)
  const error = ref<Error | null>(null)

  // Récupérer depuis le payload SSR si disponible
  const ssrData = nuxtApp.payload.data[key]

  const execute = async (): Promise<void> => {
    pending.value = true
    error.value = null

    try {
      data.value = await fetcher()

      // Stocker dans le payload pour hydration
      if (process.server) {
        nuxtApp.payload.data[key] = data.value
      }
    } catch (e) {
      error.value = e as Error
    } finally {
      pending.value = false
    }
  }

  // Hydrater depuis SSR ou fetch
  if (process.client && ssrData !== undefined) {
    data.value = ssrData
    pending.value = false
  } else {
    execute()
  }

  return {
    data: data as Ref<T | undefined>,
    pending,
    error,
    refresh: execute
  }
}

// Usage dans un composant
const { data: posts, pending } = useAsyncHydration({
  key: 'blog-posts',
  fetcher: async () => {
    const response = await $fetch<Post[]>('/api/posts')
    return response
  }
})

Stratégies d'hydration avancées

1. Lazy Hydration

Retarder l'hydration de composants lourds :

// composables/useLazyHydration.ts
import type { Component, VNode } from 'vue'

interface LazyHydrationOptions {
  whenIdle?: boolean
  whenVisible?: boolean
  onInteraction?: string[]
}

export function useLazyHydration(
  component: Component,
  options: LazyHydrationOptions = {}
): VNode {
  const isHydrated = ref(false)

  const hydrate = (): void => {
    if (!isHydrated.value) {
      isHydrated.value = true
    }
  }

  onMounted(() => {
    if (options.whenIdle) {
      // Hydrater quand le navigateur est idle
      if ('requestIdleCallback' in window) {
        requestIdleCallback(hydrate, { timeout: 2000 })
      } else {
        setTimeout(hydrate, 200)
      }
    }

    if (options.whenVisible) {
      // Hydrater quand visible dans le viewport
      const observer = new IntersectionObserver(
        (entries) => {
          if (entries[0].isIntersecting) {
            hydrate()
            observer.disconnect()
          }
        },
        { rootMargin: '50px' }
      )

      const el = getCurrentInstance()?.proxy?.$el
      if (el) observer.observe(el)
    }

    if (options.onInteraction) {
      // Hydrater sur interaction utilisateur
      const el = getCurrentInstance()?.proxy?.$el
      const events = options.onInteraction

      const handler = (): void => {
        hydrate()
        events.forEach(event => 
          el?.removeEventListener(event, handler)
        )
      }

      events.forEach(event => 
        el?.addEventListener(event, handler, { once: true })
      )
    }
  })

  return h(
    isHydrated.value ? component : 'div',
    { 'data-lazy-hydration': !isHydrated.value }
  )
}

// Usage
<script setup lang="ts">
import HeavyComponent from './HeavyComponent.vue'

const LazyHeavy = useLazyHydration(HeavyComponent, {
  whenVisible: true,
  onInteraction: ['mouseenter', 'focus']
})
</script>

<template>
  <LazyHeavy />
</template>

2. Partial Hydration

N'hydrater que les parties interactives :

// components/IslandComponent.vue
<script setup lang="ts">
interface Props {
  hydrate?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  hydrate: false
})

const isIsland = ref(false)

onMounted(() => {
  // Marquer comme "island" - partie interactive
  if (props.hydrate) {
    isIsland.value = true
  }
})
</script>

<template>
  <div :data-island="isIsland">
    <slot />
  </div>
</template>

// pages/blog/[slug].vue
<template>
  <article>
    <!-- Contenu statique : pas d'hydration nécessaire -->
    <header>
      <h1>{{ post.title }}</h1>
      <p>{{ post.date }}</p>
    </header>

    <div v-html="post.content" />

    <!-- Composant interactif : hydration nécessaire -->
    <IslandComponent :hydrate="true">
      <CommentSection :post-id="post.id" />
    </IslandComponent>

    <!-- Autre contenu statique -->
    <footer>
      <ShareButtons :url="post.url" />
    </footer>
  </article>
</template>

3. Progressive Hydration

Hydrater progressivement par priorité :

// composables/useProgressiveHydration.ts
type Priority = 'critical' | 'high' | 'normal' | 'low'

interface HydrationTask {
  priority: Priority
  hydrate: () => void
  deadline?: number
}

class HydrationScheduler {
  private queue: HydrationTask[] = []
  private isProcessing = false

  private priorityWeights: Record<Priority, number> = {
    critical: 0,
    high: 1,
    normal: 2,
    low: 3
  }

  enqueue(task: HydrationTask): void {
    this.queue.push(task)
    this.queue.sort((a, b) => 
      this.priorityWeights[a.priority] - this.priorityWeights[b.priority]
    )

    if (!this.isProcessing) {
      this.process()
    }
  }

  private async process(): Promise<void> {
    this.isProcessing = true

    while (this.queue.length > 0) {
      const task = this.queue.shift()!

      if (task.deadline && Date.now() > task.deadline) {
        console.warn('Hydration task missed deadline')
        continue
      }

      // Yield au navigateur entre chaque tâche
      await new Promise(resolve => {
        if ('scheduler' in window && 'yield' in (window as any).scheduler) {
          (window as any).scheduler.yield().then(resolve)
        } else {
          setTimeout(resolve, 0)
        }
      })

      try {
        task.hydrate()
      } catch (error) {
        console.error('Hydration error:', error)
      }
    }

    this.isProcessing = false
  }
}

const scheduler = new HydrationScheduler()

export function useProgressiveHydration(
  priority: Priority = 'normal'
): {
  scheduleHydration: (hydrate: () => void) => void
} {
  return {
    scheduleHydration: (hydrate: () => void) => {
      scheduler.enqueue({
        priority,
        hydrate,
        deadline: Date.now() + 5000 // 5s timeout
      })
    }
  }
}

// Usage dans un composant
<script setup lang="ts">
const { scheduleHydration } = useProgressiveHydration('low')

onMounted(() => {
  scheduleHydration(() => {
    // Hydrater ce composant
    console.log('Component hydrated')
  })
})
</script>

Debugging de l'hydration

Plugin de debug personnalisé

// plugins/hydration-debug.ts
export default defineNuxtPlugin((nuxtApp) => {
  if (process.client && process.dev) {
    let hydrationStart: number
    let hydrationEnd: number

    nuxtApp.hook('app:beforeMount', () => {
      hydrationStart = performance.now()
      console.log('🌊 Hydration started')
    })

    nuxtApp.hook('app:mounted', () => {
      hydrationEnd = performance.now()
      const duration = hydrationEnd - hydrationStart

      console.log(`✅ Hydration completed in ${duration.toFixed(2)}ms`)

      // Mesurer le Time to Interactive
      if ('PerformanceObserver' in window) {
        const observer = new PerformanceObserver((list) => {
          for (const entry of list.getEntries()) {
            if (entry.name === 'first-input') {
              const tti = entry.startTime
              console.log(`⚡ Time to Interactive: ${tti.toFixed(2)}ms`)
            }
          }
        })

        observer.observe({ 
          type: 'first-input', 
          buffered: true 
        })
      }
    })

    // Détecter les mismatches
    const originalWarn = console.warn
    console.warn = (...args: any[]) => {
      if (args[0]?.includes?.('Hydration')) {
        console.error('❌ HYDRATION MISMATCH:', ...args)
        
        // Stack trace pour debug
        console.trace()
      }
      originalWarn.apply(console, args)
    }
  }
})

Composable de monitoring

// composables/useHydrationMonitor.ts
interface HydrationMetrics {
  startTime: number
  endTime: number
  duration: number
  mismatches: number
  componentsHydrated: number
}

export function useHydrationMonitor() {
  const metrics = reactive<Partial<HydrationMetrics>>({
    mismatches: 0,
    componentsHydrated: 0
  })

  const logMismatch = (
    component: string, 
    expected: any, 
    actual: any
  ): void => {
    metrics.mismatches!++
    
    console.error('Hydration mismatch:', {
      component,
      expected,
      actual,
      timestamp: Date.now()
    })
  }

  const logComponentHydrated = (name: string): void => {
    metrics.componentsHydrated!++
    console.debug(`Component hydrated: ${name}`)
  }

  const getMetrics = (): HydrationMetrics => {
    return {
      startTime: metrics.startTime || 0,
      endTime: metrics.endTime || 0,
      duration: (metrics.endTime || 0) - (metrics.startTime || 0),
      mismatches: metrics.mismatches || 0,
      componentsHydrated: metrics.componentsHydrated || 0
    }
  }

  return {
    metrics: readonly(metrics),
    logMismatch,
    logComponentHydrated,
    getMetrics
  }
}

Patterns d'optimisation

1. Streaming SSR

// server/middleware/stream.ts
import { renderToNodeStream } from 'vue/server-renderer'
import type { H3Event } from 'h3'

export default defineEventHandler(async (event: H3Event) => {
  const app = createSSRApp(App)

  // Header HTML envoyé immédiatement
  event.node.res.write(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>Streaming SSR</title>
        <link rel="stylesheet" href="/style.css">
      </head>
      <body>
        <div id="app">
  `)

  // Stream du contenu
  const stream = renderToNodeStream(app)

  stream.on('data', (chunk: Buffer) => {
    event.node.res.write(chunk)
  })

  stream.on('end', () => {
    event.node.res.write(`
        </div>
        <script src="/app.js"></script>
      </body>
    </html>
    `)
    event.node.res.end()
  })
})

2. Selective Hydration

// components/SelectiveHydration.vue
<script setup lang="ts" generic="T extends ComponentType">
interface Props {
  component: T
  hydrationTrigger: 'immediate' | 'visible' | 'interaction' | 'idle'
  fallback?: string
}

const props = defineProps<Props>()
const shouldHydrate = ref(props.hydrationTrigger === 'immediate')
const containerRef = ref<HTMLElement>()

const setupHydration = (): void => {
  if (!containerRef.value) return

  switch (props.hydrationTrigger) {
    case 'visible':
      const observer = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) {
          shouldHydrate.value = true
          observer.disconnect()
        }
      })
      observer.observe(containerRef.value)
      break

    case 'interaction':
      const events = ['mouseenter', 'touchstart', 'focus']
      const activate = () => {
        shouldHydrate.value = true
        events.forEach(e => 
          containerRef.value?.removeEventListener(e, activate)
        )
      }
      events.forEach(e => 
        containerRef.value?.addEventListener(e, activate, { once: true })
      )
      break

    case 'idle':
      if ('requestIdleCallback' in window) {
        requestIdleCallback(() => shouldHydrate.value = true)
      } else {
        setTimeout(() => shouldHydrate.value = true, 1000)
      }
      break
  }
}

onMounted(setupHydration)
</script>

<template>
  <div ref="containerRef">
    <component 
      v-if="shouldHydrate" 
      :is="component" 
      v-bind="$attrs"
    />
    <div v-else-if="fallback" v-html="fallback" />
  </div>
</template>

Performance : Benchmarks réels

Stratégie TTI (ms) JS Download (KB) Memory (MB) Use Case
Full Hydration 850 245 28 App simple
Lazy Hydration 320 245 15 Landing page
Partial Hydration 280 180 12 Blog / CMS
Progressive 450 245 20 Dashboard
Streaming SSR 620 245 25 E-commerce

Checklist d'optimisation

  1. Minimiser les mismatches : Utiliser onMounted pour le code client-only
  2. Lazy load les composants lourds : Utiliser defineAsyncComponent
  3. Optimiser le payload : Ne sérialiser que les données nécessaires
  4. Utiliser les Web Workers : Pour l'hydration en background
  5. Monitorer les performances : Core Web Vitals + métriques custom
  6. Tester sur vrais devices : Mobile 3G/4G, CPU throttling

Ressources avancées

🎯 Pro Tip : N'optimisez pas prématurément. Mesurez d'abord avec des vraies données utilisateur (RUM), identifiez les bottlenecks, puis appliquez les optimisations adaptées. Chaque application a des besoins différents.

Conclusion

L'hydration est un sujet complexe mais maîtriser ses mécanismes est essentiel pour créer des applications Vue/Nuxt performantes. Les stratégies avancées comme la lazy hydration, partial hydration ou progressive hydration peuvent réduire drastiquement le Time to Interactive et améliorer l'expérience utilisateur.

L'écosystème Vue 3 et Nuxt offre tous les outils nécessaires pour implémenter ces optimisations avec TypeScript, garantissant à la fois performance et maintenabilité du code. La clé est de choisir la bonne stratégie selon votre use case et de toujours mesurer l'impact réel sur les performances.