Enrutamiento
En resumen: El enrutamiento conecta la dirección con la página: Vue Router muestra el componente de la ruta, los enlaces no recargan, y los parámetros como /product/:id salen de la dirección.
Problema
Un enlace que no lleva a ninguna parte
La tienda tiene un catálogo y una página para cada producto. Abre «Leche fresca» y vuelve.
Consola
Parece que está bien. Pero qué página ves vive solo en el estado del componente (productId). La dirección del navegador es la misma para el catálogo y para cada producto. Por eso:
- no puedes enviarle a un amigo un enlace a la leche: recibirá el catálogo;
- «Recargar» te devuelve al catálogo, porque el estado se pierde;
- «Atrás» en el navegador no vuelve al catálogo, sino que sale de la tienda;
- los productos son botones, no enlaces: «Abrir en una pestaña nueva» no funciona.
(El sandbox no tiene barra de direcciones, así que esto no se nota en él. En un sitio real lo ves enseguida.)
La idea
La dirección es el estado
Cada página necesita su propia dirección: / para el catálogo, /product/5 para la leche, /cart para el carrito. Entonces la dirección dice qué página mostrar, no el estado en memoria.
La aplicación sigue siendo una sola página (una single-page application): el navegador carga el HTML una vez. Con un clic en un enlace la biblioteca detiene la recarga, cambia la dirección con la API History del navegador y dibuja otro componente. Eso es lo que hace el router. Tiene una tabla de rutas: un patrón de dirección y un componente para él.
Vue Router recibe la tabla como un array. El router se crea en su propio archivo y se añade a la aplicación con use:
import { createRouter, createMemoryHistory } from 'vue-router';
export const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: Catalog }, { path: '/product/:id', component: ProductPage }, { path: '/cart', component: CartPage }, ],});
// main.jscreateApp(App).use(router).mount('#app');En App.vue el lugar para la página es <RouterView />. Ahí el router dibuja el componente que coincide con la dirección. Todo lo que lo rodea (el menú de arriba) se ve en todas las páginas.
:id en /product/:id es un parámetro: representa un segmento de la dirección. Una sola ruta cubre todos los productos.
Míralo
Mira cómo la dirección elige la página
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
const routes = [ { path: '/', component: Catalog }, { path: '/product/:id', component: ProductPage }, { path: '/cart', component: CartPage }, { path: '/:pathMatch(.*)*', component: NotFound },];
<!-- ProductPage.vue -->const route = useRoute();const product = computed(() => PRODUCTS.find((p) => p.id === Number(route.params.id)),);<h1>{{ product.name }}</h1>
<!-- NotFound.vue --><h1>Esa página no existe</h1>Paso a paso, en palabras
- El router tiene una tabla: un patrón de dirección y el componente para él. La dirección ahora es /, el catálogo.
- Un clic en «Leche fresca». El router impide que la página se recargue y solo cambia la dirección a /product/5.
- Compara la dirección con cada fila. «/product/:id» coincide: :id representa cualquier segmento. La fila para todo lo demás también coincide, pero es más general y pierde.
- Del resultado sale el parámetro: id = "5". Es texto, porque la dirección es texto. ProductPage lee route.params.id, y Number(…) lo convierte en 5.
- ProductPage encuentra el producto 5 y dibuja su página. El mismo componente dibuja cada producto: solo cambia el parámetro.
- Ahora la dirección es /sale, y esa página todavía no existe. No coincide ninguna fila salvo la última, así que el router dibuja NotFound.
- La dirección es el estado de la página. Puedes compartirla, recargarla y volver a ella, y la tabla siempre da la misma página.
El router no sabe nada de productos. Compara el texto de la dirección con los patrones y le pasa el parámetro al componente. Qué significa 5 lo decide el componente.
La idea
En el sandbox y en un proyecto real
El router de un sitio real lee y escribe la dirección en la barra de direcciones del navegador. Eso es createWebHistory(). El sandbox de las lecciones es una página dentro de la página de la lección y no tiene dirección propia. Por eso aquí da un error claro:
Consola
En el sandbox usamos createMemoryHistory(): un router que guarda la dirección en memoria. Todo lo demás es igual: la tabla, los enlaces, los parámetros. La dirección la mostramos nosotros, al pie de la página.
En un proyecto real solo cambia el historial:
import { createRouter, createWebHistory } from 'vue-router';
export const router = createRouter({ history: createWebHistory(), routes,});Y algo más que el sandbox no muestra: el servidor. Cuando alguien abre /product/5 directamente o pulsa «Recargar», el navegador le pide esa dirección al servidor. Ese archivo no existe. El servidor debe devolver index.html para toda dirección que no sea un archivo, y entonces el router elige la página. El servidor de desarrollo de Vite lo hace solo. En nginx lo hace la línea try_files:
location / { try_files $uri /index.html;}Pruébalo
Una página para cada producto
La tabla tiene el catálogo y el carrito, y los productos llevan a /product/1, /product/5 y /product/7, pero esa ruta no existe. Añade una ruta con parámetro para la página del producto. ProductPage ahora siempre muestra el pan: haz que tome el id de la dirección. La comprobación abre la leche, luego el queso desde «Ver también», luego el pan desde el catálogo y mira el encabezado y la dirección.
Consola
Solución
main.jsimport { createApp } from 'vue';
import App from './App.vue';
import { router } from './router.js';
createApp(App).use(router).mount('#app');
router.jsimport { createRouter, createMemoryHistory } from 'vue-router';
import Catalog from './Catalog.vue';
import ProductPage from './ProductPage.vue';
import CartPage from './CartPage.vue';
export const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Catalog },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
],
});
App.vue<script setup>
import Header from './Header.vue';
import Address from './Address.vue';
</script>
<template>
<Header />
<main>
<RouterView />
</main>
<Address />
</template>
cart.jsimport { ref } from 'vue';
// El carrito: un ref para toda la aplicación. Cada componente que lo importa lee el mismo.
export const items = ref([]);
export function add(product) {
items.value.push(product);
}
Header.vue<script setup>
import { RouterLink } from 'vue-router';
import { items } from './cart.js';
</script>
<template>
<nav>
<RouterLink to="/">Catálogo</RouterLink>
<RouterLink to="/cart">Carrito ({{ items.length }})</RouterLink>
</nav>
</template>
Catalog.vue<script setup>
import { RouterLink } from 'vue-router';
import { PRODUCTS, money } from './products.js';
</script>
<template>
<h1>Catálogo</h1>
<ul>
<li v-for="p in PRODUCTS" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>, {{ money(p.price) }}
</li>
</ul>
</template>
ProductPage.vue<script setup>
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { PRODUCTS, money } from './products.js';
import { add } from './cart.js';
const route = useRoute();
const product = computed(() => PRODUCTS.find((p) => p.id === Number(route.params.id)));
const others = computed(() => PRODUCTS.filter((p) => p.id !== product.value?.id));
function buy() {
add(product.value);
}
</script>
<template>
<section v-if="product">
<h1>{{ product.name }}</h1>
<p>{{ money(product.price) }}</p>
<button type="button" @click="buy">Añadir al carrito</button>
<h2>Ver también</h2>
<ul>
<li v-for="p in others" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
<p v-else>No existe ese producto</p>
</template>
CartPage.vue<script setup>
import { computed } from 'vue';
import { money } from './products.js';
import { items } from './cart.js';
const total = computed(() => items.value.reduce((sum, p) => sum + p.price, 0));
</script>
<template>
<h1>Carrito</h1>
<p v-if="items.length === 0">El carrito está vacío</p>
<ul>
<li v-for="(p, i) in items" :key="i">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
<p>Total: {{ money(total) }}</p>
</template>
NotFound.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Esa página no existe</h1>
<p>
<RouterLink to="/">Volver al catálogo</RouterLink>
</p>
</template>
Address.vue<script setup>
import { useRoute } from 'vue-router';
// El sandbox no tiene barra de direcciones: mostramos la dirección aquí.
const route = useRoute();
</script>
<template>
<footer>Dirección: {{ route.path }}</footer>
</template>
products.jsexport const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 5, name: 'Leche fresca', price: 1.6 },
{ id: 7, name: 'Queso blanco', price: 7.9 },
];
export const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
nav { display: flex; gap: 16px; background: #f3efe6; padding: 8px 16px; }
nav a { color: #1d4f8a; }
nav a[aria-current='page'] { font-weight: 700; text-decoration: none; }
main { padding: 0 16px; }
footer { border-top: 1px solid #ddd; padding: 4px 16px; color: #555; font-family: ui-monospace, monospace; }
button { font: inherit; }
En router.js la ruta es { path: '/product/:id', component: ProductPage }. En ProductPage.vue el parámetro está en useRoute().params.id. El producto es computed: de «Leche fresca» a «Queso blanco» Vue conserva el mismo componente y solo cambia route.params. Una constante normal en <script setup> se calcula una vez y el encabezado se queda el antiguo. La comprobación lo detecta.
El parámetro es texto: "5", mientras que id en los datos es un número. p.id === "5" es false para todos los productos. Compara con Number(id).
Pruébalo
Encuentra el error
Todas las páginas de producto dicen «No existe ese producto», y los productos están en los datos. ¿Qué línea tiene la culpa?
Elige la línea con el error.
route.params.id es texto ("5"), y p.id es un número. El === estricto nunca es verdadero. Debe ser p.id === Number(route.params.id).La idea
Enlaces y navegación desde el código
Enlaces. A una página de la aplicación se llega con <RouterLink to="/cart">. En la página es un <a href="/cart"> normal: «Abrir en una pestaña nueva» funciona y un lector de pantalla lo lee como enlace. Con un clic normal el router detiene la recarga (preventDefault) y solo cambia la dirección. Si escribes <a href> tú mismo, el navegador carga todo el sitio otra vez, y el carrito en memoria desaparece.
Para el menú existe RouterLink con la clase que le da el router: el enlace a la página actual recibe aria-current="page" y puede tener su propio estilo. Así tanto el ojo como el lector de pantalla saben dónde estás.
Navegar desde el código. A veces la página debe cambiar después de una acción, no después de un clic en un enlace: «Añadir al carrito» añade el producto y te lleva al carrito.
import { useRouter } from 'vue-router';
const router = useRouter();
function buy() { add(product.value); router.push('/cart');}useRouter() da el router y se llama en <script setup>, no en el manejador. router.push cambia la dirección como un clic en un enlace. useRoute() (sin r) da la dirección actual y sus parámetros.
Usas un enlace cuando una persona elige adónde ir. Navegas desde el código cuando la página cambia por el resultado de una acción.
Pruébalo
Completa
¿De dónde saca la página del producto el id de la dirección?
<script setup>
const route = ();
const product = computed(() =>
PRODUCTS.find((p) => p.id === Number(route.params.id)),
);
</script>useRoute: da la ruta actual. Los parámetros están en route.params, para /product/:id es { id: "5" }.La idea
Esa página no existe
Si la dirección no coincide con ninguna ruta, el router no dibuja nada. La persona ve un espacio vacío debajo del menú y no sabe qué pasó. Por eso la última ruta recoge todo lo demás y dibuja «Esa página no existe» con un enlace al catálogo.
{ path: '/:pathMatch(.*)*', component: NotFound },pathMatch es el nombre de un parámetro, y (.*) significa «cualquier texto, barras incluidas». Vue Router ordena las rutas según lo exactas que son, así que esta gana solo cuando no coincide ninguna otra. El antiguo path: '*' de Vue Router 3 ya no funciona: el router lanza un error.
Una página para un producto que no existe (/product/99) es otro caso: la ruta coincide, pero falta el producto. Eso lo decide el componente, así que ProductPage dice «No existe ese producto».
Más precisamente, para curiosos
Rutas anidadas. Las páginas suelen compartir un marco: la cuenta con un menú a la izquierda y las subpáginas «Pedidos» y «Direcciones». La ruta tiene children, y el padre tiene su propio <RouterView /> para el hijo.
Una query en la dirección. La búsqueda y los filtros también son estado que conviene poner en la dirección: /?q=leche&category=2. Se lee de route.query y se escribe con router.push({ query: { q: 'leche' } }).
El mismo componente, un parámetro nuevo. De /product/5 a /product/7 Vue no crea una ProductPage nueva, cambia route.params. Por eso lo que se calcula a partir del parámetro va en computed, y una petición a la API cuando cambia el parámetro va en watch(() => route.params.id, …).
Rutas con nombre. { path: '/product/:id', name: 'product', component: ProductPage } permite router.push({ name: 'product', params: { id: 5 } }): la dirección se construye desde la tabla, no a mano.
Guards. La página «Finalizar compra» puede exigir iniciar sesión. El router puede comprobarlo antes del cambio y redirigir al inicio de sesión: con router.beforeEach.
Ejercicio
El carrito y el 404
El menú tiene «Ofertas» (/sale), pero esa página aún no existe y el espacio debajo del menú queda vacío. Y «Añadir al carrito» añade el producto, pero la persona se queda en la página del producto. Añade una ruta para todo lo demás que lleve a NotFound (el componente está listo) y haz que el botón vaya a /cart después de añadir. La comprobación abre «Ofertas», vuelve con «Volver al catálogo», compra pan y queso y mira el carrito, la dirección y el total.
Consola
Solución
main.jsimport { createApp } from 'vue';
import App from './App.vue';
import { router } from './router.js';
createApp(App).use(router).mount('#app');
router.jsimport { createRouter, createMemoryHistory } from 'vue-router';
import Catalog from './Catalog.vue';
import ProductPage from './ProductPage.vue';
import CartPage from './CartPage.vue';
import NotFound from './NotFound.vue';
export const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Catalog },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/:pathMatch(.*)*', component: NotFound },
],
});
App.vue<script setup>
import Header from './Header.vue';
import Address from './Address.vue';
</script>
<template>
<Header />
<main>
<RouterView />
</main>
<Address />
</template>
cart.jsimport { ref } from 'vue';
// El carrito: un ref para toda la aplicación. Cada componente que lo importa lee el mismo.
export const items = ref([]);
export function add(product) {
items.value.push(product);
}
Header.vue<script setup>
import { RouterLink } from 'vue-router';
import { items } from './cart.js';
</script>
<template>
<nav>
<RouterLink to="/">Catálogo</RouterLink>
<RouterLink to="/cart">Carrito ({{ items.length }})</RouterLink>
<RouterLink to="/sale">Ofertas</RouterLink>
</nav>
</template>
Catalog.vue<script setup>
import { RouterLink } from 'vue-router';
import { PRODUCTS, money } from './products.js';
</script>
<template>
<h1>Catálogo</h1>
<ul>
<li v-for="p in PRODUCTS" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>, {{ money(p.price) }}
</li>
</ul>
</template>
ProductPage.vue<script setup>
import { computed } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { PRODUCTS, money } from './products.js';
import { add } from './cart.js';
const route = useRoute();
const router = useRouter();
const product = computed(() => PRODUCTS.find((p) => p.id === Number(route.params.id)));
const others = computed(() => PRODUCTS.filter((p) => p.id !== product.value?.id));
function buy() {
add(product.value);
router.push('/cart');
}
</script>
<template>
<section v-if="product">
<h1>{{ product.name }}</h1>
<p>{{ money(product.price) }}</p>
<button type="button" @click="buy">Añadir al carrito</button>
<h2>Ver también</h2>
<ul>
<li v-for="p in others" :key="p.id">
<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
</li>
</ul>
</section>
<p v-else>No existe ese producto</p>
</template>
CartPage.vue<script setup>
import { computed } from 'vue';
import { money } from './products.js';
import { items } from './cart.js';
const total = computed(() => items.value.reduce((sum, p) => sum + p.price, 0));
</script>
<template>
<h1>Carrito</h1>
<p v-if="items.length === 0">El carrito está vacío</p>
<ul>
<li v-for="(p, i) in items" :key="i">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
<p>Total: {{ money(total) }}</p>
</template>
NotFound.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Esa página no existe</h1>
<p>
<RouterLink to="/">Volver al catálogo</RouterLink>
</p>
</template>
Address.vue<script setup>
import { useRoute } from 'vue-router';
// El sandbox no tiene barra de direcciones: mostramos la dirección aquí.
const route = useRoute();
</script>
<template>
<footer>Dirección: {{ route.path }}</footer>
</template>
products.jsexport const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 5, name: 'Leche fresca', price: 1.6 },
{ id: 7, name: 'Queso blanco', price: 7.9 },
];
export const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
nav { display: flex; gap: 16px; background: #f3efe6; padding: 8px 16px; }
nav a { color: #1d4f8a; }
nav a[aria-current='page'] { font-weight: 700; text-decoration: none; }
main { padding: 0 16px; }
footer { border-top: 1px solid #ddd; padding: 4px 16px; color: #555; font-family: ui-monospace, monospace; }
button { font: inherit; }
La ruta es { path: '/:pathMatch(.*)*', component: NotFound }. En ProductPage.vue: const router = useRouter(); en <script setup>, y router.push('/cart') en buy después de add(product.value). El carrito está en cart.js: un ref para toda la aplicación, así que el contador del menú y CartPage lo ven enseguida.
La comprobación también mira el menú: en la página del carrito el enlace «Carrito (1)» tiene aria-current="page". Eso lo hace el router solo.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Estado · Estado derivado · Estado compartido: hacia arriba · Estado compartido
- Continúa en: Velocidad: cuándo se vuelve a renderizar un componente · Del sandbox al proyecto · El escaparate de la tienda
- Ejercicios sobre el tema: Una aplicación completa