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.
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
- ✅ Minimiser les mismatches : Utiliser
onMountedpour le code client-only - ✅ Lazy load les composants lourds : Utiliser
defineAsyncComponent - ✅ Optimiser le payload : Ne sérialiser que les données nécessaires
- ✅ Utiliser les Web Workers : Pour l'hydration en background
- ✅ Monitorer les performances : Core Web Vitals + métriques custom
- ✅ 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.