El primer componente
En resumen: Un componente de Vue es una plantilla con datos: describe cómo se ve la pantalla según esos datos, y Vue cambia en la página solo la diferencia.
Problema
El campo pierde el foco
El módulo «JavaScript en la página» terminó con un carrito dibujado a partir de los datos por un único render(). Aquí cada cambio del campo de cantidad llama a render(). El cliente quiere 15 botellas de leche: hace clic en el campo, borra el 2 y escribe 15. Pruébalo.
Consola
Tras borrar, el campo está vacío y ya no tiene el foco: el 1 y el 5 no van a ninguna parte. render() sustituyó el campo por uno nuevo con la primera tecla. El campo nuevo se ve igual, pero es otro elemento, y el foco estaba en el viejo. Tocar a mano cada lugar por separado hace que se olviden lugares. Dibujarlo todo de nuevo hace perder el foco. Hay un tercer camino.
La idea
La interfaz como función de los datos
render() tenía una buena idea: la pantalla se construye solo a partir de los datos. Con los mismos datos la pantalla es la misma, sea lo que sea lo que hubiera antes. El error estaba en el último paso: render() quita y crea los elementos él mismo.
Por eso el trabajo se divide en dos:
- Tú escribes una función que dice cómo se ve la pantalla para esos datos. No toca la página; devuelve una descripción: aquí hay un
<main>, dentro un<h1>con el texto «El carrito», luego un campo con el valor 2. - La biblioteca guarda el DOM real. La primera vez lo construye a partir de la descripción. En cada cambio de los datos toma la descripción nueva, la compara con la anterior y cambia en la página solo la diferencia.
Una función así se llama componente. En corto: la pantalla es una función de los datos, y el componente es esa función. React y Vue lo hacen de formas distintas. La idea es la misma, y elegir entre ellos es cosa del equipo, no de una biblioteca «mejor».
Míralo
El primer componente
En Vue un componente es un archivo .vue (Single File Component) con hasta tres partes: <script setup> con los datos y las funciones, <template> con la descripción de la pantalla y, si quieres, <style>.
<script setup>const product = { name: 'Leche fresca', price: 1.6, stock: 35 };</script>
<template> <main> <h1>{{ product.name }}</h1> <p>Precio: {{ money(product.price) }}</p> </main></template>- Todo lo que declara
<script setup>(variables, funciones, componentes importados) lo ve la plantilla. {{ product.name }}es una expresión de JavaScript entre dobles llaves. Su resultado se convierte en texto en la página.- El nombre del componente es el nombre del archivo:
App.vuees el componenteApp. - El sandbox pone
Appen la página por sí solo. En un proyecto real lo hace una línea enmain.js:createApp(App).mount('#app').
Consola
Cambia el precio o el stock en el objeto product y pulsa «Ejecutar». No tocas la descripción de la pantalla: la pantalla sigue a los datos.
La idea
Las reglas de la plantilla
La plantilla es HTML, pero Vue la compila a JavaScript y lee en ella signos propios. Por eso tiene reglas propias.
{{ expresión }}en el texto:<h1>{{ product.name }}</h1>.:delante de un atributo significa «el valor es una expresión»:<img :src="product.image" :alt="product.name" />.:altes la abreviatura dev-bind:alt. Sin:el valor es el texto «product.name», letra por letra.{{ }}no funciona dentro de un atributo:alt="{{ product.name }}"es el texto{{ product.name }}.@delante de un evento:@click="add"(abreviatura dev-on:click). También sirve una expresión corta:@click="count++"o@click="add(product)". Vue la envuelve en una función que se ejecuta con el clic.classyforsiguen siendoclassyfor, como en HTML.- Se permiten varias raíces: la plantilla puede tener un
<header>y un<main>uno junto al otro. - Las etiquetas sin contenido se pueden cerrar con
/>:<input />,<ProductCard />.
Pruébalo
Encuentra el error
Un clic en «Añadir» no escribe nada en la consola.
Elige la línea con el error.
onclick="add()" es un atributo HTML normal. Vue lo pone en la página tal cual, y con el clic el navegador busca una función global add. No hay ninguna, así que la consola dice «ReferenceError: add is not defined». El add de <script setup> se llama con @click="add".Pruébalo
Completa
El lector de pantalla tiene que leer el nombre del producto como descripción de la imagen.
<img :src="product.image" ="product.name" />:alt="product.name": los dos puntos dicen que el valor es una expresión. La forma completa es v-bind:alt.La idea
Componentes hechos de componentes
La página de la tienda tiene partes: la parte de arriba con el menú, la tarjeta del producto, el carrito. Cada parte se convierte en un componente en su propio archivo, y un componente mayor los usa como etiquetas. Así App no es una función larga, sino unas pocas líneas que dicen qué hay en la página.
<template> <header> <a href="/">La tienda</a> <nav>…</nav> </header></template><script setup>import ShopHeader from './ShopHeader.vue';import ProductCard from './ProductCard.vue';</script>
<template> <ShopHeader /> <main> <ProductCard /> </main></template>La plantilla ve directamente un componente importado. Sin el import, Vue no sabe qué es ShopHeader: la consola dice «[Vue warn]: Failed to resolve component: ShopHeader», y en la página queda una etiqueta desconocida. Por convención el nombre empieza con mayúscula (PascalCase), para distinguirlo de las etiquetas HTML. Vue también acepta <shop-header />.
Pruébalo
Monta la página
ShopHeader y ProductCard están listos en sus archivos. Monta App: ShopHeader arriba, y la tarjeta del producto en main, bajo el título «Lácteos».
Consola
Solución
App.vue<script setup>
import ShopHeader from './ShopHeader.vue';
import ProductCard from './ProductCard.vue';
</script>
<template>
<ShopHeader />
<main>
<h1>Lácteos</h1>
<ProductCard />
</main>
</template>
ShopHeader.vue<template>
<header>
<a href="/">La tienda</a>
<nav>
<a href="/bread">Pan</a>
<a href="/milk">Leche</a>
<a href="/cart">Carrito</a>
</nav>
</header>
</template>
ProductCard.vue<script setup>
const product = { name: 'Leche fresca', price: 1.6, stock: 35 };
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>Precio: {{ money(product.price) }}</p>
</article>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
nav a { margin-left: 12px; }
main { padding: 0 16px; }
h1 { font-size: 22px; }
label { display: block; margin: 8px 0; }
input { width: 4em; font: inherit; margin-left: 8px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 4px 12px 12px; max-width: 320px; }
button { font: inherit; }
<script setup> importa los dos componentes, y la plantilla los usa como etiquetas: <ShopHeader /> antes de <main>, <ProductCard /> dentro de <main>. La plantilla puede tener dos raíces una junto a la otra. La comprobación busca la parte de arriba por su rol: un <header> dentro de <main> ya no es la cabecera de la página, y sin el import no hay ningún <header>.
La idea
Solo la diferencia
Aquí está el carrito del problema como componente.
ref('2') es la memoria del componente: un valor que Vue vigila. Cuando cambia, Vue vuelve a dibujar los componentes que lo leen. Es el tema del módulo «Estado». Aquí solo está para que algo pueda cambiar.
Consola
Haz lo mismo que en el problema: clic en el campo de leche, borra el 2 y escribe 15. El campo conserva el foco y lo que escribiste, y el total pasa a 25,20 €. El componente describe toda la pantalla con cada tecla, igual que render(). La diferencia es que la página no se construye de nuevo. La biblioteca ve que en la descripción nueva el campo está en el mismo lugar con la misma etiqueta, y cambia solo su valor y el texto del total.
Míralo
Mira la comparación
<script setup>import { ref } from 'vue';const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
const milk = ref('2');const bread = ref('1');</script>
<template> <main> <h1>El carrito</h1> <label> Leche fresca <input type="number" :value="milk" @input="milk = $event.target.value" /> </label> <label> Pan blanco <input type="number" :value="bread" @input="bread = $event.target.value" /> </label> <p>Total: {{ money(Number(milk) * 1.6 + Number(bread) * 1.2) }}</p> </main></template>Paso a paso, en palabras
- El cliente ha borrado el 2 y ha escrito 1: los datos dicen milk = "1". En el centro está la última descripción que devolvió el componente; a la derecha está el DOM, con el foco en el campo de leche.
- El cliente escribe 5. @input escribe "15" en milk: los datos cambian, y Vue sabe que la plantilla lee milk.
- Vue compiló la plantilla a una función de render. Como milk cambió, la ejecuta otra vez y devuelve una descripción nueva: un árbol de objetos simples.
- La biblioteca compara la descripción nueva con la anterior. Las mismas etiquetas en el mismo orden y dos diferencias: el valor del campo y el texto del total.
- Solo las dos diferencias llegan al DOM. El <input> es el mismo elemento de antes, por eso el foco y el cursor se quedan en él.
- Tú describes toda la pantalla para los datos dados, como con render(). La biblioteca encuentra qué cambió y toca solo eso. Las dos bibliotecas lo hacen, cada una a su manera.
La descripción es barata: es un árbol de objetos simples, y el componente lo rehace cada vez. El DOM es la parte cara, así que la biblioteca solo toca ahí las diferencias. Tú escribes como con render(): toda la pantalla a partir de los datos. El precio de render() (el foco perdido, miles de elementos nuevos) no se paga.
Más precisamente, para curiosos
DOM virtual. A la descripción se la suele llamar DOM virtual, y a su comparación con la anterior, reconciliación (React) o patching (Vue). Un nodo de la descripción es un objeto como { type: 'h1', props: { children: 'El carrito' } }.
Los dos caminos. La plantilla se compila a una función de render que devuelve los nodos de la descripción. El compilador marca qué partes no cambian nunca, y la comparación las salta. Vue recuerda qué datos lee cada componente (reactividad) y vuelve a ejecutar solo los componentes cuyos datos cambiaron. En los dos casos al final hay una descripción nueva, una comparación y unos pocos cambios en el DOM.
El texto es texto. {{ product.name }} inserta texto, como textContent. Un nombre como <img src=x onerror=alert(1)> se muestra como caracteres y no se convierte en una etiqueta. El HTML de una cadena solo entra con v-html, y el nombre ya avisa de que hay que tener cuidado.
Options API. En código antiguo un componente es un objeto: export default { data() { … }, methods: { … } }. Sigue funcionando, pero el código nuevo usa <script setup> (Composition API).
v-model. El par :value y @input tiene una abreviatura, v-model. Pertenece a la lección sobre formularios.
Ejercicio
La tarjeta del producto
En ProductCard está el objeto product (Queso blanco). Haz la tarjeta: el nombre como título de nivel dos, «Precio: 7,90 €» (con money), «En stock: 12 uds.» y un botón «Añadir» cuyo nombre para el lector de pantalla sea «Añadir Queso blanco». Un clic en el botón escribe «Añadido: Queso blanco» en la consola (la función add ya está hecha). Después monta App: ShopHeader arriba, y la tarjeta en main, bajo el título «Lácteos».
Consola
Solución
ProductCard.vue<script setup>
const product = { name: 'Queso blanco', price: 7.9, stock: 12 };
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
function add() {
console.log(`Añadido: ${product.name}`);
}
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>Precio: {{ money(product.price) }}</p>
<p>En stock: {{ product.stock }} uds.</p>
<button type="button" :aria-label="`Añadir ${product.name}`" @click="add">Añadir</button>
</article>
</template>
App.vue<script setup>
import ShopHeader from './ShopHeader.vue';
import ProductCard from './ProductCard.vue';
</script>
<template>
<ShopHeader />
<main>
<h1>Lácteos</h1>
<ProductCard />
</main>
</template>
ShopHeader.vue<template>
<header>
<a href="/">La tienda</a>
<nav>
<a href="/bread">Pan</a>
<a href="/milk">Leche</a>
<a href="/cart">Carrito</a>
</nav>
</header>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
nav a { margin-left: 12px; }
main { padding: 0 16px; }
h1 { font-size: 22px; }
label { display: block; margin: 8px 0; }
input { width: 4em; font: inherit; margin-left: 8px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 4px 12px 12px; max-width: 320px; }
button { font: inherit; }
El texto que viene de los datos va en {{ … }}: {{ product.name }}, {{ money(product.price) }}, {{ product.stock }}. El nombre para el lector de pantalla es un atributo a partir de una expresión, con los dos puntos: :aria-label="`Añadir ${product.name}`". Sin los dos puntos el botón se llama literalmente «Añadir ${product.name}», y la comprobación no lo encuentra. El botón llama a la función con @click="add".
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Accesibilidad · El DOM: la página desde JavaScript · Eventos · Una lista a partir de datos
- Continúa en: Props: datos desde arriba · Listas y claves · Condiciones: vacío, error, cargando · Estado
- Ejercicios sobre el tema: Componentes