Todos los artículos

RendimientoAvanzado7 min de lectura

Medir sin engañarse: benchmark de Virtual DOM frente a Vapor en Vue 3.6

Cómo medir Virtual DOM frente a Vapor en Vue 3.6 sin engañarse: apps aisladas, calentamiento, orden alternado, mediana y medición en producción.

Luis Aldair Quispe Rios

Objetivo y requisitos

Cuando ves una tabla que dice “Vapor es 15 veces más rápido”, la pregunta correcta no es cuánto, sino cómo se midió. En este artículo montamos un benchmark pequeño para comparar Virtual DOM y Vapor Mode en Vue 3.6, y cada decisión de medición tiene su razón: qué se cronometra, cuándo, en qué orden y con cuántas repeticiones.

Necesitas Vue 3.6 (la versión candidata 3.6.0-rc.15 o superior) y un proyecto Vite con TypeScript, como el del post anterior sobre Vapor Mode. Medir en modo desarrollo no sirve: lo explicamos más abajo.

Dos apps aisladas con el mismo componente

La comparación justa exige que solo cambie el motor. Por eso creamos dos archivos idénticos salvo una palabra:

<!-- VdomRowTable.vue -->
<script setup lang="ts">
import type { RowStoreApi } from '@/modules/benchmark/models/benchmark.models'

const props = defineProps<{ store: RowStoreApi }>()
const { rows, selectedId, select, remove } = props.store
</script>
<!-- VaporRowTable.vue -->
<script setup vapor lang="ts">
import type { RowStoreApi } from '@/modules/benchmark/models/benchmark.models'

const props = defineProps<{ store: RowStoreApi }>()
const { rows, selectedId, select, remove } = props.store
</script>

Cada tabla se monta en su propia aplicación: createApp para una y createVaporApp para la otra. Así no hay interop ni runtime compartido que contamine la medición. Cada una tiene su propio almacén de filas, de modo que una operación en una no toca a la otra.

Un detalle que parece menor y no lo es: el template de ambas tablas tiene el mismo color de selección. Si una usa bg-vdom/20 y la otra bg-vapor/20, el diff ya no es solo el atributo vapor, y el resultado mezcla dos cosas.

Qué se mide exactamente

Una operación consiste en cambiar el estado y esperar a que el DOM esté listo. Ahí hay una trampa. Vue actualiza el DOM de forma asíncrona, en el siguiente microtask. Si cronometras solo la llamada que cambia el estado, mides casi nada. Si esperas a nextTick, mides el trabajo del framework. Si fuerzas el layout del navegador, mides además lo que el navegador tiene que calcular.

Por eso distinguimos dos métricas:

  • Solo JS: desde el cambio de estado hasta que nextTick resuelve. Es el trabajo del framework.

  • JS + layout: lo anterior, más una lectura de offsetHeight que obliga al navegador a calcular el layout.

async function measure(renderer: Renderer, scenario: Scenario): Promise<Sample> {
  const store = stores[renderer]
  const host = hosts[renderer].value!

  scenario.prepare(store)
  await nextTick()
  void host.offsetHeight   // deja el DOM en un estado estable
  await pause()            // 25 ms para que el navegador termine

  const start = performance.now()
  scenario.run(store)
  await nextTick()
  const script = performance.now() - start

  void host.offsetHeight   // fuerza el layout
  return { script, total: performance.now() - start }
}

Hay dos decisiones aquí que cambian el resultado. La primera es el await pause() antes de empezar: sin él, el trabajo pendiente de la preparación se cuela en la medición. La segunda es que script se toma antes de forzar el layout, para que la métrica del framework no incluya el costo del navegador.

Ninguna de las dos métricas incluye el paint. Eso queda fuera de este benchmark y debe decirse en el post.

Calentamiento, orden alternado y mediana

Una sola medición no sirve. El primer render de una app carga código, compila y llena cachés. Por eso:

  1. Hay una corrida de calentamiento que se descarta. La primera iteración del bucle (iteration = 0) solo prepara el terreno.

  2. El orden de los runtimes se alterna en cada iteración. Si siempre midieras VDOM primero, cualquier efecto de la carga previa caería siempre sobre el mismo lado.

  3. Se reporta la mediana, no el promedio. Una pausa del recolector de basura o una pestaña que compite por CPU puede inflar una muestra. La mediana no se mueve con un valor atípico.

for (let iteration = 0; iteration <= iterations; iteration++) {
  const order = iteration % 2 ? [...rendererList].reverse() : rendererList
  for (const renderer of order) {
    const sample = await measure(renderer, scenario)
    if (iteration > 0) result.samples[renderer].push(sample)
  }
}

El modo desarrollo distorsiona los números

La primera vez que corrí el benchmark estaba en el servidor de desarrollo. Los resultados fueron estos, para crear 1.000 filas: VDOM 57,9 ms y Vapor 50,9 ms. Al compilar en producción y servir con vite preview, la misma operación dio 36,7 ms contra 35,1 ms.

El modo desarrollo agrega comprobaciones y advertencias que cuestan tiempo. Ese costo no es igual en los dos motores, así que la proporción también cambia. Mide siempre con npm run build y npm run preview. En la página del benchmark dejé un aviso para que nadie tome cifras del modo dev.

Una corrida no basta: mira la variación

Corrí el benchmark dos veces en la misma máquina, con la misma versión y el mismo navegador. Compara la operación de crear 1.000 filas en JS + layout:

Corrida

Iteraciones

VDOM

Vapor

1

5 (solo total)

36,7 ms

35,1 ms

2

10

43,1 ms

37,3 ms

Los dos números de VDOM difieren un 17 %. Con esa variación, “Vapor es 1,05 veces más rápido” y “Vapor es 1,16 veces más rápido” son el mismo resultado: ambos dicen que la diferencia en crear es pequeña. Por eso el benchmark muestra un “Empate técnico” cuando la diferencia está entre 0,95× y 1,05×, en vez de proclamar un ganador.

Lo mismo pasa con “Crear 10.000 filas”, en JS + layout. En modo desarrollo VDOM ganó (420 ms frente a 443 ms). En la primera corrida de producción empataron (425 ms cada uno). En la segunda ganó Vapor (446 ms frente a 392 ms). Cuando el resultado cambia de signo entre corridas, la conclusión honesta es que no hay diferencia medible en esa operación.

Cómo leer los resultados

Con la métrica “Solo JS” y 10 corridas, las diferencias son más claras:

  • Actualizar 1 de cada 10 filas: 2,0 ms frente a 0,3 ms.

  • Seleccionar una fila: 1,6 ms frente a 0,1 ms. Aquí el compilador usa createSelector, así que solo se actualizan dos filas.

  • Crear 1.000 filas: 9,4 ms frente a 5,6 ms.

Pero las mismas operaciones en “JS + layout” se acercan mucho: la selección pasa de 1,6 ms a 0,1 ms en JS, y de 5,1 ms a 3,9 ms con el layout incluido. El navegador calcula el layout de la misma manera con los dos motores, y ese costo se queda igual. Vapor elimina el trabajo del framework, pero no puede eliminar el del navegador.

Lo que este benchmark no mide

  • No es js-framework-benchmark. Ese proyecto usa trazas de Chrome y una batería estándar de operaciones. Este es un laboratorio para comparar tendencias en tu máquina.

  • No mide el paint ni la interacción real del usuario.

  • Valores menores de 0,5 ms están cerca de la resolución de performance.now(). La selección de fila en Vapor (0,1 ms) está en ese rango: úsala como orden de magnitud, no como cifra exacta.

  • Una máquina, un navegador. Cambia la CPU y cambian los números. Lo que se sostiene entre corridas y máquinas son las proporciones, y aun así conviene repetirlas.

Comprueba el resultado

Para repetir la medición en tu equipo, compila y sirve en producción:

npm run build
npm run preview

Abre la página de benchmark, elige 10 corridas y pulsa Correr todo. Repite al menos dos veces y compara. Si tus dos corridas dan resultados con signos distintos en una operación, esa operación no tiene diferencia medible en tu máquina.

Para copiar la tabla, usa Copiar como Markdown: incluye la métrica, el número de corridas, la versión de Vue y el user agent. Pega ese bloque tal cual en tu reporte; sin esos datos, la cifra no se puede reproducir.

Errores frecuentes al medir

  • Cronometrar el cambio de estado sin esperar al DOM. Mides una asignación de JavaScript, no el render.

  • Comparar un motor que calentó con uno que no. Descarta siempre la primera corrida.

  • Usar promedios con valores atípicos. Una sola pausa del recolector de basura cambia el promedio, no la mediana.

  • Un solo orden de ejecución. Alterna el orden o el sesgo queda del lado del que corre primero.

  • Sacar conclusiones de diferencias menores que la variación entre corridas. Antes de decir “más rápido”, compara la variación.

Si quieres profundizar en la otra parte de la historia, el post sobre cómo Vapor compila los templates explica qué código genera cada motor y por qué la selección de filas es tan barata.

Sigue leyendo