Todos los artículos

RendimientoIntermedio9 min de lectura

Vapor Mode en Vue 3.6: Vue sin Virtual DOM, explicado y medido

Qué es Vapor Mode en Vue 3.6, cómo activarlo, qué código genera el compilador y cuánto mejora frente al Virtual DOM, con mediciones reales.

Luis Aldair Quispe Rios

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-vue 6.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 minor antes 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:

  1. El estado cambia.

  2. El componente vuelve a ejecutar su función de render.

  3. Se crea un árbol nuevo de nodos virtuales (vnodes).

  4. Vue compara ese árbol con el anterior (diff).

  5. 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:

  1. El estado cambia.

  2. Solo se ejecuta el efecto que lee ese dato.

  3. 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:

  1. 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 de performance.now(), así que tómalo como orden de magnitud).

  2. 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.

  3. 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 (createApp)

25,1 kB

Virtual DOM sin Options API (__VUE_OPTIONS_API__: false)

21,9 kB

Solo Vapor (createVaporApp)

17,8 kB

Vapor dentro de una app VDOM (vaporInteropPlugin)

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() devuelve null y no existe app.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 Suspense propio de Vapor. Un componente Vapor sí puede vivir dentro de un <Suspense> del Virtual DOM. Transition, KeepAlive y Teleport sí 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

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