Рутиране
Накратко: Рутирането свързва адреса със страницата: Vue Router показва компонента на маршрута, връзките не презареждат, а параметри като /product/:id идват от адреса.
Проблем
Връзка, която не води никъде
Магазинът има каталог и страница за всеки продукт. Отвори „Прясно мляко“ и се върни.
Конзола
Изглежда наред. Но коя страница се вижда, е само в състоянието на компонента (productId). Адресът в браузъра е един и същ за каталога и за всеки продукт. Затова:
- не можеш да пратиш на приятел връзка към млякото: при него ще се отвори каталогът;
- „Презареди“ те връща в каталога, защото състоянието се губи;
- „Назад“ в браузъра не връща в каталога, а излиза от магазина;
- продуктите са бутони, не връзки: „Отвори в нов раздел“ не работи.
(Пясъчникът няма адресна лента, затова тези неща не се виждат в него. В истински сайт ги виждаш веднага.)
Идеята
Адресът е състоянието
Всяка страница трябва да има свой адрес: / за каталога, /product/5 за млякото, /cart за кошницата. Тогава адресът казва коя страница да се покаже, а не състоянието в паметта.
Приложението остава една страница (single-page application): браузърът зарежда HTML веднъж. При клик на връзка библиотеката спира презареждането, сменя адреса през History API на браузъра и рисува друг компонент. Това прави рутерът (router). Той има таблица с маршрути (routes): шаблон на адрес и компонент за него.
Vue Router получава таблицата като масив. Рутерът се създава в отделен файл и се добавя към приложението с 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');В App.vue мястото на страницата е <RouterView />. Там рутерът рисува компонента, който съвпада с адреса. Всичко около него (менюто горе) се вижда на всяка страница.
:id в /product/:id е параметър: замества един сегмент от адреса. Един маршрут покрива всички продукти.
Виж го
Виж как адресът избира страницата
<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>Няма такава страница</h1>Стъпка по стъпка, с думи
- Рутерът има таблица: шаблон на адрес и компонентът за него. Сега адресът е /, каталогът.
- Клик на „Прясно мляко“. Рутерът не дава на страницата да се презареди, а само сменя адреса на /product/5.
- Сверява адреса с всеки ред. „/product/:id“ съвпада: :id замества който и да е един сегмент. Редът за всичко останало също съвпада, но е по-общ и губи.
- От съвпадението излиза параметърът: id = "5". Той е текст, защото адресът е текст. ProductPage чете route.params.id, а Number(…) го прави 5.
- ProductPage намира продукт 5 и рисува страницата му. Един и същ компонент рисува всеки продукт: различен е само параметърът.
- Сега адресът е /sale, а такава страница още няма. Не съвпада нито един ред освен последния, затова рутерът рисува NotFound.
- Адресът е състоянието на страницата. Можеш да го пратиш, презаредиш и да се върнеш към него, а таблицата винаги дава същата страница.
Рутерът не знае нищо за продуктите. Той сверява текста на адреса с шаблоните и подава параметъра на компонента. Какво значи 5, решава компонентът.
Идеята
В пясъчника и в истинския проект
Рутерът на истинския сайт чете и пише адреса в адресната лента на браузъра. Това е createWebHistory(). Пясъчникът в уроците е страница вътре в страницата на урока и няма свой адрес. Затова тук той дава ясна грешка:
Конзола
В пясъчника ползваме createMemoryHistory(): рутер, който пази адреса в паметта. Всичко останало е същото: таблицата, връзките, параметрите. Адреса показваме сами, долу на страницата.
В истинския проект се сменя само историята:
import { createRouter, createWebHistory } from 'vue-router';
export const router = createRouter({ history: createWebHistory(), routes,});И още едно нещо, което пясъчникът не показва: сървърът. Когато някой отвори направо /product/5 или натисне „Презареди“, браузърът иска този адрес от сървъра. Такъв файл няма. Сървърът трябва да върне index.html за всеки адрес, който не е файл, а после рутерът избира страницата. Сървърът на Vite при разработка го прави сам. За nginx това прави редът try_files:
location / { try_files $uri /index.html;}Пробвай
Страница за всеки продукт
Таблицата има каталог и кошница, а продуктите водят към /product/1, /product/5 и /product/7, но такъв маршрут няма. Добави маршрут с параметър за страницата на продукта. ProductPage сега винаги показва хляба: нека взима id от адреса. Проверката отваря млякото, после сиренето от „Виж също“, после хляба от каталога и гледа заглавието и адреса.
Конзола
Решение
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';
// Кошницата: един ref за цялото приложение. Всеки компонент, който го внесе, чете същия.
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="/">Каталог</RouterLink>
<RouterLink to="/cart">Кошница ({{ items.length }})</RouterLink>
</nav>
</template>
Catalog.vue<script setup>
import { RouterLink } from 'vue-router';
import { PRODUCTS, money } from './products.js';
</script>
<template>
<h1>Каталог</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">Добави в кошницата</button>
<h2>Виж също</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>Няма такъв продукт</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>Кошница</h1>
<p v-if="items.length === 0">Кошницата е празна</p>
<ul>
<li v-for="(p, i) in items" :key="i">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
<p>Общо: {{ money(total) }}</p>
</template>
NotFound.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Няма такава страница</h1>
<p>
<RouterLink to="/">Към каталога</RouterLink>
</p>
</template>
Address.vue<script setup>
import { useRoute } from 'vue-router';
// Пясъчникът няма адресна лента: показваме адреса тук.
const route = useRoute();
</script>
<template>
<footer>Адрес: {{ route.path }}</footer>
</template>
products.jsexport const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', 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; }
В router.js маршрутът е { path: '/product/:id', component: ProductPage }. В ProductPage.vue параметърът е в useRoute().params.id. Продуктът е computed: от „Прясно мляко“ към „Сирене“ Vue ползва същия компонент и само сменя route.params. Обикновена константа в <script setup> е сметната веднъж и заглавието остава старото. Проверката го хваща.
Параметърът е текст: "5", а id в данните е число. p.id === "5" е false за всеки продукт. Сравни с Number(id).
Пробвай
Намери грешката
Всяка страница на продукт казва „Няма такъв продукт“, а продуктите са в данните. Кой ред е виновен?
Избери реда с грешката.
route.params.id е текст ("5"), а p.id е число. Строгото === никога не е вярно. Трябва p.id === Number(route.params.id).Идеята
Връзки и преход от кода
Връзките. Към страница на приложението води <RouterLink to="/cart">. В страницата той е обикновен <a href="/cart">: работи „Отвори в нов раздел“, а екранният четец го чете като връзка. При обикновен клик рутерът спира презареждането (preventDefault) и само сменя адреса. Ако напишеш <a href> сам, браузърът зарежда целия сайт наново, и кошницата в паметта изчезва.
За менюто има RouterLink с класа, който рутерът му слага: връзката към текущата страница получава aria-current="page" и може да е с друг стил. Така и окото, и екранният четец разбират къде си.
Преход от кода. Понякога страницата трябва да се смени след действие, не след клик на връзка: „Добави в кошницата“ добавя продукта и води към кошницата.
import { useRouter } from 'vue-router';
const router = useRouter();
function buy() { add(product.value); router.push('/cart');}useRouter() дава рутера и се вика в <script setup>, не в обработчика. router.push сменя адреса, както клик на връзка. useRoute() (без r) дава текущия адрес и параметрите му.
Връзка ползваш, когато човек избира къде да отиде. Преход от кода ползваш, когато страницата се сменя заради резултат от действие.
Пробвай
Попълни
Откъде страницата на продукта взима id от адреса?
<script setup>
const route = ();
const product = computed(() =>
PRODUCTS.find((p) => p.id === Number(route.params.id)),
);
</script>useRoute: дава текущия маршрут. Параметрите са в route.params, за /product/:id това е { id: "5" }.Идеята
Няма такава страница
Ако адресът не съвпада с нито един маршрут, рутерът не рисува нищо. Човекът вижда празно място под менюто и не знае какво стана. Затова последният маршрут хваща всичко останало и рисува „Няма такава страница“ с връзка към каталога.
{ path: '/:pathMatch(.*)*', component: NotFound },pathMatch е име на параметър, а (.*) казва „всякакъв текст, и с наклонени черти“. Vue Router подрежда маршрутите по точност, затова този печели само когато друг не съвпада. Старият запис path: '*' от Vue Router 3 вече не работи: рутерът хвърля грешка.
Страницата за продукт, който го няма (/product/99), е друг случай: маршрутът съвпада, но продуктът липсва. Това решава компонентът, затова ProductPage пише „Няма такъв продукт“.
По-точно, за любопитните
Вложени маршрути. Страниците често имат обща рамка: акаунтът с меню вляво и подстраници „Поръчки“ и „Адреси“. Маршрутът има children, а родителят има свой <RouterView /> за детето.
Заявка в адреса. Търсенето и филтрите също са състояние, което си струва да е в адреса: /?q=мляко&category=2. Чете се от route.query, а се пише с router.push({ query: { q: 'мляко' } }).
Същият компонент, нов параметър. От /product/5 към /product/7 Vue не създава нов ProductPage, а сменя route.params. Затова изчисленото от параметъра е в computed, а заявка към API при смяна на параметъра е в watch(() => route.params.id, …).
Маршрути с имена. { path: '/product/:id', name: 'product', component: ProductPage } позволява router.push({ name: 'product', params: { id: 5 } }): адресът се сглобява от таблицата, а не на ръка.
Пазачи. Страницата „Поръчка“ може да изисква вход. Рутерът може да провери преди прехода и да пренасочи към вход: с router.beforeEach.
Упражнение
Кошницата и 404
В менюто има „Промоции“ (/sale), но такава страница още няма и под менюто остава празно. А „Добави в кошницата“ добавя продукта, но човекът остава на страницата на продукта. Добави маршрут за всичко останало към NotFound (компонентът е готов) и направи бутона да води към /cart след добавянето. Проверката отваря „Промоции“, връща се с „Към каталога“, купува хляб и сирене и гледа кошницата, адреса и сумата.
Конзола
Решение
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';
// Кошницата: един ref за цялото приложение. Всеки компонент, който го внесе, чете същия.
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="/">Каталог</RouterLink>
<RouterLink to="/cart">Кошница ({{ items.length }})</RouterLink>
<RouterLink to="/sale">Промоции</RouterLink>
</nav>
</template>
Catalog.vue<script setup>
import { RouterLink } from 'vue-router';
import { PRODUCTS, money } from './products.js';
</script>
<template>
<h1>Каталог</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">Добави в кошницата</button>
<h2>Виж също</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>Няма такъв продукт</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>Кошница</h1>
<p v-if="items.length === 0">Кошницата е празна</p>
<ul>
<li v-for="(p, i) in items" :key="i">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
<p>Общо: {{ money(total) }}</p>
</template>
NotFound.vue<script setup>
import { RouterLink } from 'vue-router';
</script>
<template>
<h1>Няма такава страница</h1>
<p>
<RouterLink to="/">Към каталога</RouterLink>
</p>
</template>
Address.vue<script setup>
import { useRoute } from 'vue-router';
// Пясъчникът няма адресна лента: показваме адреса тук.
const route = useRoute();
</script>
<template>
<footer>Адрес: {{ route.path }}</footer>
</template>
products.jsexport const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', 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; }
Маршрутът е { path: '/:pathMatch(.*)*', component: NotFound }. В ProductPage.vue: const router = useRouter(); в <script setup>, а router.push('/cart') в buy след add(product.value). Кошницата е в cart.js: един ref за цялото приложение, затова броят в менюто и CartPage я виждат веднага.
Проверката гледа и менюто: на страницата на кошницата връзката „Кошница (1)“ има aria-current="page". Това рутерът го прави сам.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Състояние · Изчислено състояние · Вдигане на състоянието · Общо състояние
- Продължава в: Бързина: кога се рисува наново · От пясъчника към проект · Витрината на магазина
- Задачи по темата: Цяло приложение