Todos los artículos

RendimientoAvanzado6 min de lectura

Dentro del compilador de Vapor: v-if, v-for, v-model y slots en Vue 3.6

Qué código genera Vapor para v-if, v-for, v-model y slots frente al Virtual DOM, leído con el compilador de Vue 3.6.

Luis Aldair Quispe Rios

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-model y <slot> se escriben igual.

  • Los props y los eventos. defineProps, defineEmits y v-on funcionan igual.

  • La reactividad que escribes. ref, computed y watch son 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-memo tenga 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