Objetivo y requisitos
En el post sobre Vapor Mode vimos que Vapor no usa el Virtual DOM. Aquí entramos al compilador para responder la pregunta práctica: ¿qué código genera Vapor para v-if, v-for, v-model y los slots, y qué cambia frente al Virtual DOM?
Necesitas Vue 3.6.0-rc.15 o superior y @vue/compiler-sfc con la misma versión. Para ver la salida, usamos el compilador directamente en Node, sin proyecto completo.
npm install @vue/compiler-sfc@3.6.0-rc.15
Cómo leer la salida
Compilamos el mismo SFC dos veces, con vapor: false y con vapor: true:
import { parse, compileScript } from '@vue/compiler-sfc'
const { descriptor } = parse(source, { filename: 'Demo.vue' })
for (const vapor of [false, true]) {
const { content } = compileScript(descriptor, { id: 'demo', inlineTemplate: true, vapor })
console.log(content)
}
Un detalle para evitar confusiones: si el archivo ya tiene <script setup vapor>, esa marca manda sobre la opción. Para comparar los dos modos, quita el atributo del archivo antes de compilar, como hacemos en el script del post anterior.
El código generado incluye imports, _hoisted y envoltorios que no cambian la historia. Lo que sigue son solo las partes que importan.
v-if: una rama, una clonación
Este template muestra dos ramas:
<p v-if="show">Visible</p>
<p v-else>Oculto</p>
Con Virtual DOM, el compilador elige entre dos bloques con un operador ternario. Cada bloque devuelve un vnode nuevo, y Vue lo compara con el anterior:
return (_ctx, _cache) => {
return (show.value)
? (_openBlock(), _createElementBlock("p", _hoisted_1, "Visible"))
: (_openBlock(), _createElementBlock("p", _hoisted_2, "Oculto"))
}
Con Vapor, el compilador usa createIf. Recibe la condición y una fábrica por rama, y cada rama clona su propio template:
const t0 = _template("<p>Visible", 3)
const t1 = _template("<p>Oculto", 3)
const n0 = _createIf(() => (show.value), () => {
const n2 = t0()
return n2
}, () => {
const n4 = t1()
return n4
})
return n0
La diferencia de fondo: en Vapor, al cambiar la condición se monta la rama nueva y se desmonta la anterior. No hay un render del componente completo que genere un árbol para comparar después.
v-for: una fuente reactiva por fila
Este template renderiza una lista con clave:
<li v-for="row in rows" :key="row.id">{{ row.label }}</li>
Con Virtual DOM, el compilador llama a renderList dentro de un Fragment marcado como KEYED_FRAGMENT. Cada vez que cambia rows, Vue vuelve a ejecutar la función del render, crea los vnodes de todas las filas y los compara por clave:
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(rows.value, (row) => {
return (_openBlock(), _createElementBlock("li", {
key: row.id
}, _toDisplayString(row.label), 1 /* TEXT */))
}), 128 /* KEYED_FRAGMENT */))
Con Vapor, el compilador genera createFor. La clave se obtiene con una función, y cada fila recibe el dato como una fuente reactiva (de ahí el .value que aparece dentro de la fila):
const t0 = _template("<li> ")
const n0 = _createFor(() => (rows.value), (_for_item0) => {
const n2 = t0()
const x2 = _txt(n2)
_renderEffect(() => _setText(x2, _toDisplayString(_for_item0.value.label)))
return n2
}, (row) => (row.id), 8 /* IS_SINGLE_NODE */)
Aquí está la explicación de por qué las listas son rápidas en Vapor. Cuando cambia el label de una fila, solo se ejecuta su renderEffect, que escribe en el nodo x2. El resto de filas no se evalúa. Eso es lo que medimos en el benchmark de actualizar 1 de cada 10 filas: 2,0 ms frente a 0,3 ms en JavaScript.
v-model: un helper en lugar de una directiva
Un input con v-model:
<input v-model="name" />
Con Virtual DOM, v-model se compila a withDirectives con vModelText, más un listener onUpdate:modelValue. La directiva se aplica en cada render:
return _withDirectives((_openBlock(), _createElementBlock("input", {
"onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((name).value = $event))
}, null, 512 /* NEED_PATCH */)), [
[_vModelText, name.value]
])
Con Vapor, el compilador genera una sola llamada a applyTextModel, que recibe el nodo, un getter y un setter:
const t0 = _template("<input>", 1)
const n0 = t0()
_applyTextModel(n0, () => (name.value), _value => (name.value = _value))
return n0
No hay directiva que se reevalúe en cada render, porque el enlace se registra una vez, con sus efectos. Las directivas personalizadas también cambian en Vapor: no tienen la misma firma que en el Virtual DOM. Antes de migrar un componente que las use, revisa la documentación de la versión que vas a usar.
Slots: sin withCtx ni caché de _cache
Un componente con contenido en su slot por defecto:
<Card><b>Hola</b></Card>
Con Virtual DOM, el slot se envuelve en withCtx y el contenido estático se guarda en _cache para no recrearlo:
return (_openBlock(), _createBlock(Card, null, {
default: _withCtx(() => [...(_cache[0] || (_cache[0] = [
_createElementVNode("b", null, "Hola", -1 /* CACHED */)
]))]),
_: 1 /* STABLE */
}))
Con Vapor, el componente se crea con createComponent y el slot es una función que devuelve el nodo clonado. El contenido estático sigue siendo un _template:
const t0 = _template("<b>Hola", 2)
const n1 = _createComponent(Card, null, () => {
const n0 = t0()
return n0
}, true)
return n1
Para tu código, la conclusión es que la sintaxis de slots no cambia. Lo que cambia es el envoltorio que genera el compilador.
Qué no cambia
La sintaxis del template.
v-if,v-for,v-modely<slot>se escriben igual.Los props y los eventos.
defineProps,defineEmitsyv-onfuncionan igual.La reactividad que escribes.
ref,computedywatchson los mismos.
Lo que cambia es el código que el compilador produce detrás. Por eso la forma de medir el impacto es con benchmarks como los del post anterior, no leyendo la sintaxis.
Comprueba el resultado
Para ver la salida de cualquier componente tuyo, crea inspect-vapor.mjs en la raíz del proyecto:
import { readFileSync } from 'node:fs'
import { parse, compileScript } from '@vue/compiler-sfc'
const file = process.argv[2]
// Quita el atributo vapor del archivo 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 src/components/MiComponente.vue y busca _createIf, _createFor, _applyTextModel y _createComponent en la salida de Vapor.
Errores frecuentes
Comparar sin quitar el atributo
vapor. Si el archivo lo tiene, ambos resultados salen en Vapor.Leer la salida como si fuera el código que escribes. Es el código que Vue ejecuta; cambia con cada versión del compilador. Úsala para entender, no para copiar.
Esperar que
v-memotenga efecto. En Vapor no existe. La directiva servía para evitar renders del Virtual DOM, y aquí no hay render que evitar.Usar directivas personalizadas sin revisar su comportamiento en Vapor. Su firma es distinta.
Si quieres ver el lado del rendimiento de estas piezas, el benchmark con la métrica de JavaScript muestra cómo se traduce cada patrón en tiempo.
Sigue leyendo
Una idea lleva a otra.
- Vapor Mode en Vue 3.6: Vue sin Virtual DOM, explicado y medidoActiva Vapor Mode en Vue 3.6 con una sola palabra, mira qué compila y mide cuánto mejora frente al Virtual DOM, con cifras reales.
- 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.