Pendant une décennie, le DOM virtuel a été le prix à payer pour disposer d'interfaces déclaratives. Vous écrivez un template, le framework construit un arbre d'objets JavaScript décrivant le DOM, le compare à l'arbre précédent, puis applique les différences. Cela fonctionne — mais chaque mise à jour alloue des objets, parcourt un arbre et effectue un travail proportionnel à la taille du composant plutôt qu'à celle du changement.
Vue 3.6 introduit le Vapor Mode, une stratégie de compilation alternative qui supprime cette couche intermédiaire. Un composant vapor est compilé en JavaScript ordinaire qui crée les nœuds DOM une seule fois, puis met à jour uniquement les nœuds texte, attributs et gestionnaires d'événements qui dépendent de l'état réactif modifié. Pas d'arbre virtuel. Pas de diff. Pas de re-rendu au niveau du composant.
Le meilleur : l'activation se fait composant par composant. Vous ne réécrivez pas votre application. Vous marquez les composants situés sur vos chemins critiques — le tableau géant, la légende d'un graphique temps réel, la liste virtualisée — et vous laissez le reste intact.
Dans ce tutoriel, vous construirez un « tableau de bord d'exploitation » temps réel affichant des milliers de lignes mises à jour par un minuteur, d'abord en mode classique puis en Vapor Mode, afin d'observer la différence dans votre propre profileur.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé (l'outillage de Vue 3.6 suppose un runtime moderne)
- Des bases en Vue 3 — Composition API,
ref,computed,v-for - Une familiarité avec Vite et le terminal
- Un éditeur avec l'extension Vue - Official (Volar)
Aucune expérience préalable des signaux, des compilateurs ou des internes de Vue n'est nécessaire.
Ce que vous allez construire
Une page unique affichant un tableau de 5 000 instances de service, chacune avec un statut, une latence et une barre de charge. Un minuteur modifie une fraction aléatoire des lignes toutes les 200 ms. Vous allez :
- Créer un projet Vue 3.6 avec Vite et activer le compilateur vapor
- Écrire votre premier composant vapor et inspecter le code généré
- Construire le tableau et le soumettre à une charge de 5 000 lignes
- Mélanger composants vapor et classiques via le plugin d'interopérabilité
- Mesurer le coût des mises à jour dans Chrome DevTools et interpréter les chiffres
Pourquoi le DOM virtuel vous coûte cher
Prenons un composant qui affiche une ligne. En Vue classique, un changement d'état relance sa fonction de rendu. Celle-ci retourne un nouvel arbre de vnodes : des objets pour la ligne, pour chaque cellule, pour chaque nœud texte. Le runtime parcourt ensuite en parallèle l'ancien et le nouvel arbre, compare les props et applique un patch au DOM réel là où ils diffèrent.
Le patch final sur le DOM est minuscule. Tout ce qui le précède est du surcoût : allocation, déchets pour le ramasse-miettes, et parcours d'arbre proportionnel à la taille du template.
Le Vapor Mode inverse cette logique. Le compilateur sait déjà, au moment du build, exactement quel nœud DOM alimente une expression donnée. Il émet donc du code qui relie directement cette expression à ce nœud via un effet réactif. Quand row.latency change, un seul effet se déclenche et un seul nœud texte est écrit. Le reste de la ligne n'est même pas visité.
Deux conséquences comptent davantage que la vitesse brute :
- Un runtime plus léger. Une application entièrement vapor n'embarque pas du tout le moteur de rendu du DOM virtuel, ce qui réduit sensiblement le bundle de base.
- Le coût de mise à jour suit le changement, pas le template. Une ligne avec 40 liaisons coûte autant à mettre à jour qu'une ligne avec 3, si une seule liaison a réellement changé.
Vue 3.6 a par ailleurs reconstruit son cœur de réactivité sur l'algorithme alien-signals, ce qui abaisse le coût de gestion de chaque effet — vapor ou non. Le mode classique en profite aussi ; le Vapor Mode est ce qui vous permet d'encaisser le gain complet.
Étape 1 : configuration du projet
Créez un projet Vue avec Vite :
npm create vite@latest vapor-dashboard -- --template vue-ts
cd vapor-dashboard
npm installAssurez-vous d'être sur Vue 3.6 ou plus récent, avec une version du plugin qui comprend vapor :
npm install vue@^3.6.0
npm install -D @vitejs/plugin-vue@latest vite@latestVérifiez ce que vous avez réellement obtenu — le support de vapor dépend des versions exactes :
npm ls vue @vitejs/plugin-vueÉtape 2 : activer le compilateur Vapor
La compilation vapor est un drapeau du compilateur, pas un plugin séparé. Activez-le dans vite.config.ts :
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue({
features: {
// [!code highlight]
vapor: true, // autorise les composants à opter pour la compilation vapor
},
}),
],
})Ce drapeau ne rend pas tous vos composants vapor. Il indique simplement au compilateur SFC d'accepter le marqueur vapor sur les composants qui le demandent. Tout composant non marqué est compilé exactement comme avant : c'est ce qui rend l'adoption incrémentale.
Étape 3 : votre premier composant vapor
Un composant s'inscrit en ajoutant l'attribut vapor à son bloc script. Créez src/components/StatusBadge.vue :
<script setup vapor lang="ts">
const props = defineProps<{
status: 'healthy' | 'degraded' | 'down'
}>()
const LABELS = {
healthy: 'Sain',
degraded: 'Dégradé',
down: 'Hors service',
} as const
</script>
<template>
<span class="badge" :class="`badge--${props.status}`">
{{ LABELS[props.status] }}
</span>
</template>
<style scoped>
.badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.badge--healthy { background: #dcfce7; color: #166534; }
.badge--degraded { background: #fef9c3; color: #854d0e; }
.badge--down { background: #fee2e2; color: #991b1b; }
</style>Voilà toute la surface d'API : un mot, vapor, dans la balise script. Les props, defineProps, les styles scopés et la Composition API fonctionnent comme vous en avez l'habitude.
Une règle stricte à intégrer : les composants vapor sont exclusivement en Composition API. L'Options API (data(), methods, mounted()) est un concept de l'ère du DOM virtuel, non supporté dans un composant vapor. Si votre base de code l'utilise encore, ces composants restent simplement classiques — et c'est très bien ainsi.
Inspecter ce que le compilateur a produit
C'est l'étape que la plupart des tutoriels sautent, et c'est celle qui fait comprendre vapor. Demandez à Vite de sortir le module transformé :
npx vite build --mode development --minify falseCherchez ensuite votre composant dans le chunk généré. Au lieu d'une fonction render() retournant des appels à createElementVNode(...), vous trouverez du code impératif : créer un span, insérer un nœud texte, et enregistrer un effet de rendu qui écrit dans ce nœud texte précis quand la prop status change. La forme du template est figée dans la sortie ; seules les parties dynamiques restent vivantes.
Étape 4 : construire la ligne du tableau
Passons au composant qui subira réellement la charge. Créez src/components/InstanceRow.vue :
<script setup vapor lang="ts">
import StatusBadge from './StatusBadge.vue'
import type { Instance } from '../types'
const props = defineProps<{ instance: Instance }>()
const emit = defineEmits<{
(e: 'restart', id: string): void
}>()
function loadColor(load: number): string {
if (load > 85) return '#ef4444'
if (load > 60) return '#f59e0b'
return '#10b981'
}
</script>
<template>
<tr>
<td class="mono">{{ props.instance.id }}</td>
<td>{{ props.instance.region }}</td>
<td><StatusBadge :status="props.instance.status" /></td>
<td class="mono">{{ props.instance.latency }} ms</td>
<td>
<div class="bar">
<div
class="bar__fill"
:style="{
width: props.instance.load + '%',
background: loadColor(props.instance.load),
}"
/>
</div>
</td>
<td>
<button @click="emit('restart', props.instance.id)">Redémarrer</button>
</td>
</tr>
</template>
<style scoped>
.mono { font-family: ui-monospace, monospace; }
.bar {
width: 120px;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.bar__fill { height: 100%; transition: width 150ms linear; }
</style>Remarquez que StatusBadge — lui-même un composant vapor — s'utilise dans un autre composant vapor sans cérémonie. La composition vapor vers vapor est le chemin rapide : visez des sous-arbres entiers en vapor plutôt qu'une alternance entre les deux modes.
Définissez le type partagé dans src/types.ts :
export interface Instance {
id: string
region: string
status: 'healthy' | 'degraded' | 'down'
latency: number
load: number
}Étape 5 : générer et muter les données
Créez src/composables/useInstances.ts. Il s'agit de Composition API pure — rien de spécifique à vapor, car la réactivité est partagée entre les deux modes :
import { shallowRef, onUnmounted } from 'vue'
import type { Instance } from '../types'
const REGIONS = ['tunis-1', 'paris-2', 'riyadh-1', 'dubai-3', 'frankfurt-1']
const STATUSES = ['healthy', 'healthy', 'healthy', 'degraded', 'down'] as const
function makeInstance(i: number): Instance {
return {
id: `svc-${String(i).padStart(5, '0')}`,
region: REGIONS[i % REGIONS.length],
status: STATUSES[i % STATUSES.length],
latency: 20 + Math.floor(Math.random() * 180),
load: Math.floor(Math.random() * 100),
}
}
export function useInstances(count = 5000, tickMs = 200) {
// shallowRef : on remplace la référence du tableau, sans suivre en profondeur 5000 objets
const instances = shallowRef<Instance[]>(
Array.from({ length: count }, (_, i) => makeInstance(i)),
)
// Muter environ 2 % des lignes à chaque tick pour simuler un flux temps réel
const timer = setInterval(() => {
const next = instances.value.slice()
const churn = Math.max(1, Math.floor(count * 0.02))
for (let n = 0; n < churn; n++) {
const idx = Math.floor(Math.random() * next.length)
const row = next[idx]
next[idx] = {
...row,
latency: 20 + Math.floor(Math.random() * 180),
load: Math.floor(Math.random() * 100),
status: STATUSES[Math.floor(Math.random() * STATUSES.length)],
}
}
instances.value = next
}, tickMs)
onUnmounted(() => clearInterval(timer))
return { instances }
}L'usage de shallowRef est délibéré. Des proxies profondément réactifs sur 5 000 objets coûtent de la mémoire et du temps d'initialisation dans les deux modes de rendu ; remplacer des objets de ligne immuables garde le graphe réactif peu profond et fait que les liaisons d'une ligne ne sont réévaluées que lorsque l'identité de son objet change.
Étape 6 : composer le tableau
Créez src/components/InstanceTable.vue :
<script setup vapor lang="ts">
import { computed } from 'vue'
import InstanceRow from './InstanceRow.vue'
import { useInstances } from '../composables/useInstances'
const { instances } = useInstances(5000, 200)
const unhealthy = computed(
() => instances.value.filter((i) => i.status !== 'healthy').length,
)
function onRestart(id: string) {
console.info('restart requested', id)
}
</script>
<template>
<header class="summary">
<strong>{{ instances.length }}</strong> instances ·
<strong>{{ unhealthy }}</strong> en défaut
</header>
<table>
<thead>
<tr>
<th>ID</th><th>Région</th><th>Statut</th>
<th>Latence</th><th>Charge</th><th></th>
</tr>
</thead>
<tbody>
<InstanceRow
v-for="instance in instances"
:key="instance.id"
:instance="instance"
@restart="onRestart"
/>
</tbody>
</table>
</template>v-for, :key, computed et l'émission d'événements se comportent exactement comme vous les connaissez. La :key n'est pas optionnelle ici : la réconciliation de listes de vapor s'en sert pour réutiliser les blocs DOM déjà construits pour des identités stables.
Étape 7 : monter une application vapor
Pour une application dont la racine et les enfants sont entièrement vapor, montez-la avec createVaporApp, qui n'embarque pas du tout le moteur de rendu du DOM virtuel :
// src/main.ts
import { createVaporApp } from 'vue'
import App from './App.vue'
import './style.css'
createVaporApp(App).mount('#app')App.vue a alors besoin de son propre marqueur vapor :
<script setup vapor lang="ts">
import InstanceTable from './components/InstanceTable.vue'
</script>
<template>
<main>
<h1>Tableau de bord d'exploitation</h1>
<InstanceTable />
</main>
</template>Lancez :
npm run devVous devriez voir 5 000 lignes, avec des latences et des barres de charge qui vacillent au rythme du minuteur.
Étape 8 : interopérabilité avec les composants classiques
Dans la vraie vie, vous ne convertirez pas tout. Vous avez une modale issue du design system, un wrapper de graphiques, un composant tiers venu de npm — tous des composants classiques à DOM virtuel. Vue permet de mélanger les deux, mais le pont doit être installé explicitement.
Pour rendre un composant vapor dans une application classique, installez le plugin d'interopérabilité sur un createApp normal :
// src/main.ts — racine classique, feuilles vapor
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.use(vaporInteropPlugin) // active les frontières VDOM ↔ vapor
app.mount('#app')Avec le plugin installé, un parent classique peut rendre un enfant vapor, et un parent vapor peut rendre un enfant classique, dans les deux sens. Notez que createVaporApp et createApp + vaporInteropPlugin sont deux stratégies distinctes :
| Stratégie | Quand l'utiliser | Compromis |
|---|---|---|
createVaporApp | Nouveau projet, tous les composants en vapor | Runtime le plus léger ; aucun composant classique autorisé |
createApp + vaporInteropPlugin | Application existante, conversion des chemins critiques | Embarque les deux runtimes ; migration composant par composant |
Le conseil pratique est simple : chaque frontière d'interopérabilité a un coût. Traverser du VDOM vers vapor et retour à chaque ligne d'un tableau annule le gain. Convertissez des sous-arbres contigus — le tableau, ses lignes, ses badges — et laissez la frontière tout en haut, là où elle n'est traversée qu'une fois.
Pour un composant classique sans fichier SFC (une fonction de rendu, par exemple), vous pouvez l'écrire en style vapor avec defineVaporComponent, mais dans la plupart des applications, ajouter le marqueur aux SFC suffira.
Tester votre implémentation
Deux vérifications comptent, et une seule implique un chronomètre.
Exactitude
Vérifiez que rien n'est cassé fonctionnellement. Cliquez sur un bouton Redémarrer et confirmez que l'émission atteint la console. Vérifiez que le compteur d'instances en défaut suit bien les badges. Vérifiez que les lignes se mettent à jour sur place au lieu d'être réordonnées, ce qui signalerait une :key erronée.
Performance
Ouvrez Chrome DevTools, allez dans le panneau Performance, et enregistrez environ 5 secondes de fonctionnement. Comparez ensuite deux builds :
- Retirez
vapordes balises script deStatusBadge.vue,InstanceRow.vueetInstanceTable.vue, repassezmain.tsàcreateApp, puis enregistrez. - Remettez les marqueurs, repassez à
createVaporApp, et enregistrez à nouveau.
Cherchez trois choses dans le flame chart :
- Le temps de scripting par tick. En mode classique, vous verrez des frames de fonctions de rendu répétées et du travail de patch pour le sous-arbre de chaque ligne modifiée. En Vapor Mode, ces frames disparaissent en grande partie ; il ne reste qu'une poignée d'appels d'effets très étroits.
- La mémoire et le GC. Le mode classique alloue un nouvel arbre de vnodes à chaque mise à jour. Observez la dent de scie de la mémoire s'aplatir sous vapor.
- Le montage initial. Le montage vapor est généralement plus rapide aussi, car il exécute du code de construction DOM linéaire au lieu de bâtir un arbre puis de le parcourir.
Ne cherchez pas un multiplicateur précis. L'ampleur du gain dépend entièrement de la densité de liaisons de vos templates et de la part de l'application convertie. Sur un tableau comme celui-ci, avec beaucoup de lignes et peu de liaisons modifiées par ligne, l'écart est important. Sur un composant qui redessine tout de toute façon, il est faible.
Rendez aussi la comparaison honnête : construisez en production (npm run build && npm run preview) avant de mesurer. Le mode développement de Vue embarque des avertissements et une comptabilité interne qui faussent les deux modes.
Dépannage
« L'attribut vapor est ignoré / le composant utilise encore le DOM virtuel. »
Le drapeau features: { vapor: true } manque dans les options de @vitejs/plugin-vue, ou votre version du plugin est antérieure au support de vapor. Revérifiez avec npm ls @vitejs/plugin-vue.
« Impossible d'utiliser l'Options API dans un composant vapor. »
Exactement ce qui est écrit. Convertissez le composant en script setup avec la Composition API, ou laissez-le classique et laissez le plugin d'interopérabilité faire le pont.
Un composant classique s'affiche vide dans un parent vapor.
Vous avez oublié app.use(vaporInteropPlugin). L'interopérabilité n'est jamais implicite.
Un composant d'une bibliothèque tierce lève une erreur sous vapor. Certaines bibliothèques manipulent les internes du DOM virtuel (fonctions de rendu personnalisées, manipulation de vnodes, certaines directives). Gardez ces composants en classique et placez la frontière d'interopérabilité au-dessus d'eux plutôt que de lutter.
Les performances se sont dégradées après conversion. C'est presque toujours un problème de frontière : vous avez converti un composant feuille situé dans une liste classique, si bien que chaque élément paie désormais une traversée. Convertissez vers le haut jusqu'à ce que la frontière soit franchie une fois, et non N fois.
Une fonctionnalité dont vous dépendez n'est pas supportée. Le Vapor Mode est récent et sa surface fonctionnelle se complète version après version. Consultez la documentation Vue actuelle avant de supposer qu'une directive, une transition ou un comportement de cycle de vie est disponible dans les composants vapor — et gardez le composant concerné en classique en attendant.
Prochaines étapes
- Virtualisez la liste. Vapor rend chaque mise à jour peu coûteuse ; rendre 5 000 lignes DOM reste coûteux en soi. Associez vapor à une bibliothèque de fenêtrage et les deux gains se composent.
- Convertissez d'abord votre pire composant. Profilez votre vraie application, trouvez le composant au coût de scripting le plus élevé par interaction, et convertissez ce sous-arbre — pas toute votre base de code.
- Explorez la sortie du compilateur. Lire le code généré pour un template que vous avez écrit vous-même est le moyen le plus rapide de développer une intuition sur les liaisons réellement dynamiques.
- Lisez nos guides connexes sur l'optimisation automatique avec React Compiler et la création d'applications fullstack avec Nuxt 4 pour voir comment d'autres frameworks attaquent le même surcoût sous des angles différents.
Conclusion
Le Vapor Mode n'est ni un nouveau framework, ni un nouveau modèle mental, ni une réécriture. C'est le même Vue que vous écrivez aujourd'hui — ref, computed, v-for, SFC, styles scopés — compilé par un backend différent qui émet des opérations DOM directes au lieu d'un arbre de DOM virtuel.
Ce que vous avez appris ici se transpose directement en production : activez le drapeau du compilateur, marquez des sous-arbres contigus avec vapor, gardez ces sous-arbres exclusivement en Composition API, installez vaporInteropPlugin à la frontière avec votre code classique, et vérifiez le gain dans un build de production avec un profileur plutôt que de faire confiance à un benchmark exécuté par quelqu'un d'autre.
Commencez par le composant que vos utilisateurs ressentent le plus. C'est là que le DOM virtuel vous coûtait cher, et c'est là que sa suppression se verra.