Agregas una palabra a tu componente, <script setup vapor>, y Vue deja de usar el Virtual DOM para renderizarlo. No cambias la sintaxis del template, ni ref, ni computed. Cambia lo que el compilador genera: en vez de una función que crea un árbol de nodos virtuales en cada actualización, Vapor produce código que escribe directamente en el nodo del DOM que depende de cada dato.
En este artículo vemos qué problema resuelve Vapor Mode, cómo activarlo hoy, qué código genera el compilador y cuánto mejora realmente. Para eso medí la misma tabla de 1.000 filas con los dos motores, y también el peso del bundle. Spoiler: la mejora es enorme en algunas operaciones y modesta en otras, y entender por qué es lo más útil del experimento.
Objetivo y requisitos
Vamos a crear un componente Vapor dentro de una aplicación Vue normal y a leer lo que genera el compilador. Necesitas:
Vue 3.6, que hoy está en Release Candidate (
3.6.0-rc.15, publicada el 12 de noviembre de 2026). La versión estable sigue siendo la 3.5. El equipo de Vue describe Vapor como completo en funcionalidades en este RC, pero aún no hay fecha para la versión estable.Un proyecto con Vite creado con
npm create vue@latest(TypeScript y Vue Router opcionales).@vitejs/plugin-vue6.0.10 o superior, la primera versión que acepta el RC de Vue 3.6.
Al ser una versión candidata, úsala para experimentar o en partes acotadas de tu aplicación. Revisa el CHANGELOG de la rama
minorantes de actualizar: cada semana sale un RC nuevo con correcciones.
El costo que Vapor quiere eliminar
Con el Virtual DOM, cuando cambia un dato ocurre esto:
El estado cambia.
El componente vuelve a ejecutar su función de render.
Se crea un árbol nuevo de nodos virtuales (vnodes).
Vue compara ese árbol con el anterior (diff).
Aplica al DOM real solo las diferencias.
Vue ya optimiza mucho este proceso: marca qué partes del template son estáticas y cuáles son dinámicas para comparar menos. Aun así, cada actualización paga los pasos 2 a 4 aunque solo haya cambiado un texto.
Con Vapor el camino es más corto:
El estado cambia.
Solo se ejecuta el efecto que lee ese dato.
Ese efecto escribe directamente en su nodo del DOM.
No hay árbol virtual que recrear ni comparar. El HTML estático del componente se crea una vez clonando un <template>, y cada parte dinámica queda conectada a su propio efecto reactivo. Es el modelo de reactividad de grano fino que popularizaron Solid y Svelte 5; según las notas de lanzamiento de Vue 3.6, Vapor alcanza su mismo nivel de rendimiento en el js-framework-benchmark.
Activa Vapor en una aplicación existente
Instala la versión candidata. Como Vue Router declara compatibilidad con vue ^3.5 y npm no acepta versiones prerelease en ese rango, fija la versión y añade overrides en package.json:
{
"dependencies": {
"vue": "3.6.0-rc.15"
},
"overrides": {
"vue": "$vue",
"@vue/compiler-sfc": "$vue"
}
}
Ejecuta npm install. Cuando la 3.6 sea estable podrás quitar los overrides.
Crea src/components/VaporCounter.vue. Es un componente normal con <script setup>; la única diferencia es el atributo vapor:
<script setup vapor lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button type="button" @click="count++">Clicks: {{ count }}</button>
</template>
Para usarlo dentro de una aplicación que sigue usando Virtual DOM, registra vaporInteropPlugin en src/main.ts:
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(vaporInteropPlugin).use(router).mount('#app')
Ahora puedes importar VaporCounter desde cualquier componente, por ejemplo en App.vue, como harías con cualquier otro. Props, eventos y slots funcionan entre los dos mundos.
Si toda tu aplicación es Vapor, puedes prescindir del runtime del Virtual DOM y montarla con createVaporApp:
import { createVaporApp } from 'vue'
import App from './App.vue' // también con <script setup vapor>
createVaporApp(App).mount('#app')
Qué genera el compilador
La mejor forma de entender Vapor es leer su salida. Este es el mismo contador compilado con @vue/compiler-sfc en los dos modos (recorté imports, envoltorios y anotaciones de TypeScript).
Con Virtual DOM, el componente devuelve una función de render que crea vnodes en cada actualización:
return (_ctx, _cache) => {
return (_openBlock(), _createElementBlock("button", {
type: "button",
onClick: _cache[0] || (_cache[0] = $event => (count.value++))
}, "Clicks: " + _toDisplayString(count.value), 1 /* TEXT */))
}
Con Vapor, setup construye el DOM una sola vez y conecta los efectos:
const t0 = _template("<button type=button> ", 1)
// dentro de setup()
const n0 = t0() // clona el HTML estático
const x0 = _txt(n0) // referencia al nodo de texto
_on(n0, "click", () => (count.value++))
_renderEffect(() => _setText(x0, "Clicks: " + _toDisplayString(count.value)))
return n0
Ya no hay función de render. Cuando cambia count, Vue solo vuelve a ejecutar ese _renderEffect, que actualiza el texto de un nodo concreto. El resto del componente no se vuelve a evaluar.
Un detalle brillante: createSelector
El caso más interesante aparece en las listas. Imagina una lista donde una fila puede estar seleccionada:
<li
v-for="row in rows"
:key="row.id"
:class="{ active: row.id === selectedId }"
@click="selectedId = row.id"
>
{{ row.label }}
</li>
Con Virtual DOM, cambiar selectedId vuelve a renderizar el componente y compara las 1.000 filas. El compilador Vapor detecta el patrón row.id === selectedId y genera un selector:
const _selector0 = _createSelector(() => selectedId.value)
// por cada fila:
_selector0(_for_item0.value.id, () => {
_setClassName(n2, _for_item0.value.id === selectedId.value ? 1 : 0, "active")
})
Cuando cambia la selección, solo se ejecutan los efectos de dos filas: la que deja de estar activa y la nueva. El costo deja de depender del tamaño de la lista.
¿Cuánto mejora realmente?
Monté la misma tabla dos veces. Los archivos son idénticos salvo por la palabra vapor, y cada tabla vive en una aplicación aislada (createApp frente a createVaporApp). Medí con Vue 3.6.0-rc.14, en un build de producción, Chrome 156 y Windows 11, usando la mediana de 10 ejecuciones.
Medí dos cosas. Solo JS es el tiempo desde que cambia el estado hasta que Vue termina de escribir en el DOM. JS + layout suma además el cálculo de layout que hace el navegador.
Operación | VDOM, solo JS | Vapor, solo JS | VDOM, JS + layout | Vapor, JS + layout |
|---|---|---|---|---|
Crear 1.000 filas | 9,4 ms | 5,6 ms | 43,1 ms | 37,3 ms |
Actualizar 1 de cada 10 filas | 2,0 ms | 0,3 ms | 6,1 ms | 3,6 ms |
Seleccionar una fila | 1,6 ms | 0,1 ms | 5,1 ms | 3,9 ms |
Crear 10.000 filas | 75,8 ms | 67,4 ms | 445,5 ms | 392,1 ms |
Tres conclusiones:
En las actualizaciones, el trabajo de Vue casi desaparece. Entre 3,5 y 15 veces menos JavaScript, porque no hay render ni diff. La selección de fila es el caso extremo gracias a
createSelector(valores tan pequeños rozan la precisión deperformance.now(), así que tómalo como orden de magnitud).Al crear nodos, la ventaja es moderada (entre 1,1 y 1,7 veces). Ambos motores tienen que crear los mismos elementos en el DOM.
Medido de punta a punta, el navegador pasa a ser el cuello de botella. El layout cuesta lo mismo con los dos motores, por eso el total mejora entre 1,1 y 1,7 veces. Vapor no acelera el DOM: elimina el trabajo que Vue hacía alrededor de él.
Midiendo en modo desarrollo las cifras cambian bastante, porque Vue añade comprobaciones. Compara siempre con npm run build y npm run preview.
El peso del bundle
También compilé un contador mínimo en tres variantes con Vite y medí el JavaScript resultante comprimido con gzip:
Variante | gzip |
|---|---|
Virtual DOM ( | 25,1 kB |
Virtual DOM sin Options API ( | 21,9 kB |
Solo Vapor ( | 17,8 kB |
Vapor dentro de una app VDOM ( | 45,7 kB |
Una aplicación solo Vapor pesa un 19 % menos que la versión VDOM sin Options API, que es la comparación justa porque Vapor no la soporta. Pero fíjate en la última fila: con el plugin de interoperabilidad se cargan los dos runtimes y el bundle casi se duplica. En una aplicación existente, la ganancia de Vapor está en el rendimiento de los componentes migrados, no en el peso.
Comprueba el resultado
Ejecuta npm run dev y pulsa el botón: el contador debe aumentar igual que un componente normal. Después ejecuta npm run build para confirmar que el type-check y la compilación pasan.
Para ver lo que genera el compilador sin configurar nada, crea un archivo inspect-vapor.mjs en la raíz del proyecto:
import { readFileSync } from 'node:fs'
import { parse, compileScript } from 'vue/compiler-sfc'
const file = 'src/components/VaporCounter.vue'
// El atributo vapor del archivo tiene prioridad: lo quitamos para controlar el modo con la opción.
const source = readFileSync(file, 'utf8').replace('<script setup vapor', '<script setup')
const { descriptor } = parse(source, { filename: file })
for (const vapor of [false, true]) {
const { content } = compileScript(descriptor, { id: 'demo', inlineTemplate: true, vapor })
console.log(`\n--- vapor: ${vapor} ---\n${content}`)
}
Ejecútalo con node inspect-vapor.mjs. Con vapor: false verás _createElementBlock dentro de una función de render; con vapor: true, _template, _renderEffect y _setText.
Errores frecuentes y límites
Solo
<script setup>. Vapor no soporta Options API. Un componente con<script>normal no se puede compilar en modo Vapor.Sin instancia pública.
getCurrentInstance()devuelvenully no existeapp.config.globalProperties. Si una librería depende de ellos, ese componente debe seguir en Virtual DOM.Sin
v-memo. Con Vapor no hace falta: la directiva existía para evitar renders innecesarios del Virtual DOM, y en Vapor no hay render que evitar.Render functions y JSX siguen siendo Virtual DOM. Necesitan el plugin de interoperabilidad.
No hay
Suspensepropio de Vapor. Un componente Vapor sí puede vivir dentro de un<Suspense>del Virtual DOM.Transition,KeepAliveyTeleportsí tienen versión Vapor.Interoperabilidad con librerías de UI. Funciona, pero el equipo recomienda separar regiones Vapor y Virtual DOM en vez de anidarlas mezcladas.
Herramientas. El soporte en Vue DevTools y Vue Test Utils todavía figura como pendiente en el roadmap de Vapor.
Cuándo usarlo hoy
Partes críticas de una aplicación existente: tablas grandes, dashboards con muchas actualizaciones, editores. Migra esos componentes y mide antes y después.
Aplicaciones pequeñas o micro-frontends nuevos completamente en Vapor, donde además ganas en peso.
No migres todo de golpe. Es un RC, y mezclar los dos motores aumenta el bundle. La API que escribes es la misma, así que activarlo componente a componente es barato y reversible.
En las próximas entregas veremos cómo medí estos números sin engañarnos, qué más genera el compilador Vapor (v-for, v-if, v-model) y cómo adoptarlo en una aplicación real con librerías de terceros.
Lecturas recomendadas
Notas de lanzamiento de Vue 3.6.0-rc.1: sintaxis, APIs y funcionalidades no soportadas.
CHANGELOG de Vue 3.6: cambios de cada RC.
Roadmap de Vapor Mode: qué falta para la versión estable.
La nueva reactividad basada en alien-signals: menos memoria y
computedmás rápidos en Vue 3.6.Rendering Mechanism en la documentación oficial: cómo funciona el Virtual DOM que Vapor reemplaza.
Si te interesa cómo Vue convierte componentes en otras piezas de la plataforma web, lee también Web Components con Vue 3 en El Rincón de Vue.
Sigue leyendo
Una idea lleva a otra.
- Dentro del compilador de Vapor: v-if, v-for, v-model y slots en Vue 3.6Compara el código que genera Vapor y Virtual DOM para v-if, v-for, v-model y slots, con el compilador de Vue 3.6.
- Medir sin engañarse: benchmark de Virtual DOM frente a Vapor en Vue 3.6Mide Virtual DOM frente a Vapor sin engañarse: apps aisladas, calentamiento, orden alternado, mediana y pruebas en producción.
- Adoptar Vapor en una app Vue real: interop, componentes y bundleMigra una app Vue a Vapor sin reescribirla: activa la interop, migra componentes hoja, mide y entiende el peso del bundle.