Velocidad: cuándo se vuelve a renderizar un componente
En resumen: En Vue se vuelve a renderizar el componente que lee un valor cambiado, y un hijo cuando sus props son nuevas; primero mides, y después computed mantiene el mismo resultado.
Problema
Cada letra renderiza toda la lista
Arriba hay un campo de búsqueda y un botón «Buscar». La lista se filtra solo después de «Buscar». ProductList escribe en la consola cada vez que se renderiza. Escribe «leche» en el campo y mira la consola.
Consola
Cinco letras, cinco líneas nuevas «renderizo ProductList». La lista es la misma: todavía no has pulsado «Buscar». Aquí hay ocho productos y no lo notas. En un catálogo real hay 2000, cada uno con imagen y precio, y el campo empieza a ir por detrás de tus dedos.
La idea
Cuándo se vuelve a renderizar un componente
Un componente se vuelve a renderizar cuando cambian los datos que muestra. Pero qué son «sus datos» lo deciden de forma distinta las dos bibliotecas. Las dos dan el resultado correcto; la diferencia es cuánto trabajo hacen.
Vue sigue qué valores reactivos lee cada componente mientras se renderiza. Cuando cambia draft, Vue vuelve a renderizar solo los componentes que leyeron draft: aquí App. Un hijo se vuelve a renderizar si sus props son distintas de las anteriores (las compara una por una).
El contador de ProductList.vue está en onBeforeMount y onBeforeUpdate: Vue los llama antes del primer render del componente y antes de cada render siguiente.
Aquí la plantilla pasa :products="visible()": una función que filtra. Se ejecuta en cada render de App y devuelve un array nuevo. Para Vue eso es una prop nueva, así que renderiza también ProductList.
Primero mide, después arregla. Un contador en la consola, como aquí, es la medición más sencilla. Las Vue DevTools tienen una Timeline con los renders de los componentes y sus tiempos. Arreglas solo un render que sea a la vez innecesario y lento.
Míralo
Mira quién se ilumina
<script setup>const draft = ref('');const query = ref('');const visible = computed(() => filterByName(PRODUCTS, query.value));</script>
<template> <input v-model="draft" /> <button @click="query = draft">Buscar</button> <ProductList :products="visible" @add="add" /></template>
<!-- ProductList.vue --><script setup>defineProps(['products']);onBeforeUpdate(() => console.log('renderizo ProductList'));</script>Paso a paso, en palabras
- App guarda el texto del campo (draft) y la búsqueda (query). ProductList dibuja las tarjetas. La consola cuenta cuántas veces se renderiza la lista.
- Antes del arreglo la plantilla llamaba a una función, visible(). Cada render de App creaba un array nuevo, así que Vue veía una prop nueva y volvía a renderizar ProductList.
- computed conserva el mismo array mientras query sea la misma. Vue sigue lo que lee cada componente y compara las props del hijo.
- Ahora una tecla renderiza solo App y el campo. Las props de ProductList son las mismas, así que se omite, y el contador se queda.
- «Buscar» cambia query. El array es nuevo, así que ProductList se renderiza. Este render es necesario: la lista muestra ahora dos productos.
- Primero mide: un contador en la consola o el Profiler de las DevTools. Arregla solo los renders que son a la vez innecesarios y lentos.
computed conserva el array, mientras que una función en la plantilla da uno nuevo en cada render.Pruébalo
Solo «Buscar» renderiza la lista
Haz que escribir en el campo y «Añadir» no vuelvan a renderizar ProductList. Solo «Buscar» debe renderizarla, porque solo entonces cambia la lista. La comprobación escribe «leche», añade dos productos, pulsa «Buscar» y cuenta las líneas «renderizo ProductList» de la consola.
Consola
Solución
App.vue<script setup>
import { ref, computed } from 'vue';
import ProductList from './ProductList.vue';
import { PRODUCTS } from './products.js';
const draft = ref('');
const query = ref('');
const count = ref(0);
const visible = computed(() => PRODUCTS.filter((p) => p.name.toLowerCase().includes(query.value.toLowerCase())));
function add() {
count.value++;
}
function search() {
query.value = draft.value;
}
</script>
<template>
<header>
<form role="search" @submit.prevent="search">
<input type="search" aria-label="Búsqueda" v-model="draft" />
<button>Buscar</button>
</form>
<p>En el carrito: {{ count }}</p>
</header>
<main>
<ProductList :products="visible" @add="add" />
</main>
</template>
ProductList.vue<script setup>
import { onBeforeMount, onBeforeUpdate } from 'vue';
import { money } from './products.js';
defineProps(['products']);
const emit = defineEmits(['add']);
let renders = 0;
const log = () => console.log(`renderizo ProductList: ${++renders}`);
onBeforeMount(log);
onBeforeUpdate(log);
</script>
<template>
<ul>
<li v-for="p in products" :key="p.id">
<span>{{ p.name }}, {{ money(p.price) }}</span>
<button type="button" :aria-label="`Añadir ${p.name}`" @click="emit('add', p)">
Añadir
</button>
</li>
</ul>
</template>
products.jsexport const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 2, name: 'Pan integral', price: 1.9 },
{ id: 5, name: 'Leche fresca', price: 1.6 },
{ id: 6, name: 'Leche desnatada', price: 0.9 },
{ id: 7, name: 'Queso blanco', price: 7.9 },
{ id: 8, name: 'Queso kashkaval', price: 9.5 },
{ id: 9, name: 'Mantequilla', price: 3.4 },
{ id: 10, name: 'Huevos (10 uds.)', price: 2.8 },
];
export const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button, input { font: inherit; }
La función visible() pasa a ser computed: const visible = computed(() => PRODUCTS.filter(…));, y la plantilla pasa :products="visible", sin paréntesis. computed recalcula solo cuando cambia query y hasta entonces devuelve el mismo array. add es una función normal: es la misma en cada render, y Vue conserva también las flechas de @add="…".
La comprobación detecta también :products="[...visible]": la copia es un array nuevo en cada render.
Pruébalo
Encuentra el error
visible es computed, y la lista se sigue renderizando con cada letra. ¿Qué línea tiene la culpa?Elige la línea con el error.
[...visible] crea un array nuevo en cada render de App, aunque visible es el mismo. Pásalo directamente: :products="visible".Pruébalo
Completa
¿Cómo se mantiene igual la lista cara entre renders?
const visible = (
() => PRODUCTS.filter((p) => p.name.includes(query.value)),
);computed: ejecuta la función cuando cambia algo que lee (query.value). Si no, devuelve el mismo array.La idea
Una lista larga: renderizar menos
El elemento más rápido es el que no se dibuja. Una persona ve 10–20 productos en la pantalla, no 2000. Por eso una lista larga dibuja solo una parte:
- «Mostrar más»: los primeros 20, y con un clic 20 más. Solo se recuerda cuántos se muestran.
- Páginas: «1, 2, 3 …», a menudo con el número en la dirección (
/?page=2), para poder compartirla. - Una lista virtual (windowing): dibuja solo las filas visibles y las cambia al desplazarse. Es difícil de escribir, así que usas una biblioteca: TanStack Virtual o vue-virtual-scroller.
Las claves de la lección sobre listas ayudan también aquí: con key={p.id} (:key="p.id") los 20 productos añadidos son elementos nuevos, y los antiguos se quedan sin rehacerse.
La idea
Carga diferida
No todas las partes de la página hacen falta de golpe. Las reseñas del producto, el mapa de las tiendas, el editor del perfil: pueden cargarse solo cuando la persona los abre. Entonces la primera carga descarga menos código.
import { defineAsyncComponent } from 'vue';
const Reviews = defineAsyncComponent(() => import('./Reviews.vue'));import('./Reviews.vue') es una llamada a una función que carga el archivo y devuelve una promesa. defineAsyncComponent la convierte en un componente que se usa como cualquier otro: <Reviews v-if="open" />.
Consola
Abre la consola: la línea de reviews.js aparece solo después del clic. Para ser sinceros, en el sandbox todos los archivos ya están en la página, así que aquí import() solo ejecuta el archivo más tarde. En un proyecto real Vite pone cada uno de esos archivos aparte y el navegador lo descarga del servidor en el primer clic. Lo mismo vale para las páginas: en Vue Router component: () => import('./CartPage.vue') carga la página la primera vez que se navega a ella.
Más precisamente, para curiosos
v-memo. Para un v-for muy largo Vue tiene v-memo="[p.id === selected]": la fila se vuelve a renderizar solo si cambian los valores del array. La documentación lo llama una microoptimización para casos raros. Un array equivocado significa una actualización perdida.
El estado hacia abajo. El campo con su draft puede ser un componente aparte SearchBox que emite search hacia arriba solo con «Buscar». Entonces las letras cambian solo SearchBox.
computed con un resultado estable. Desde Vue 3.4, un computed que devuelve el mismo valor primitivo (por ejemplo true) no dispara lo que depende de él. Para un objeto puedes devolver el anterior (computed((old) => …)) si el contenido es el mismo.
El modo de desarrollo es más lento. Las advertencias y las comprobaciones cuestan tiempo. Mide la velocidad de la versión construida (vite build y vite preview), no solo en desarrollo.
Ejercicio
Mostrar más
El catálogo tiene 10 productos y los dibuja todos. Muestra los primeros 4, y debajo «Mostrando 4 de 10» y un botón «Mostrar más» que añade 4 más. Cuando todos están mostrados, el botón desaparece. La comprobación cuenta los productos después de cada clic y mira el texto y el botón.
Consola
Solución
App.vue<script setup>
import { ref, computed } from 'vue';
import { PRODUCTS, money } from './products.js';
const PAGE = 4;
const limit = ref(PAGE);
const shown = computed(() => PRODUCTS.slice(0, limit.value));
</script>
<template>
<main>
<ul>
<li v-for="p in shown" :key="p.id">
<span>{{ p.name }}, {{ money(p.price) }}</span>
</li>
</ul>
<p>{{ `Mostrando ${shown.length} de ${PRODUCTS.length}` }}</p>
<button v-if="limit < PRODUCTS.length" type="button" @click="limit += PAGE">Mostrar más</button>
</main>
</template>
products.jsexport const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 2, name: 'Pan integral', price: 1.9 },
{ id: 5, name: 'Leche fresca', price: 1.6 },
{ id: 6, name: 'Leche desnatada', price: 0.9 },
{ id: 7, name: 'Queso blanco', price: 7.9 },
{ id: 8, name: 'Queso kashkaval', price: 9.5 },
{ id: 9, name: 'Mantequilla', price: 3.4 },
{ id: 10, name: 'Huevos (10 uds.)', price: 2.8 },
{ id: 11, name: 'Manzanas (1 kg)', price: 2.2 },
{ id: 12, name: 'Bananas (1 kg)', price: 2.6 },
];
export const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button, input { font: inherit; }
Solo guardas cuántos se muestran: const limit = ref(4);. Los mostrados se calculan: computed(() => PRODUCTS.slice(0, limit.value)). El botón lleva v-if="limit < PRODUCTS.length", y el clic es limit += 4. Una variable normal let limit = 4 no es reactiva y la plantilla no se vuelve a renderizar: la comprobación lo detecta.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Estado derivado · Estado compartido: hacia arriba · Efectos · Enrutamiento
- Ejercicios sobre el tema: Una aplicación completa