Рутиране
Накратко: Рутирането свързва адреса със страницата: React Router показва компонента на маршрута, връзките не презареждат, а параметри като /product/:id идват от адреса.
Проблем
Връзка, която не води никъде
Магазинът има каталог и страница за всеки продукт. Отвори „Прясно мляко“ и се върни.
Конзола
Изглежда наред. Но коя страница се вижда, е само в състоянието на компонента (productId). Адресът в браузъра е един и същ за каталога и за всеки продукт. Затова:
- не можеш да пратиш на приятел връзка към млякото: при него ще се отвори каталогът;
- „Презареди“ те връща в каталога, защото състоянието се губи;
- „Назад“ в браузъра не връща в каталога, а излиза от магазина;
- продуктите са бутони, не връзки: „Отвори в нов раздел“ не работи.
(Пясъчникът няма адресна лента, затова тези неща не се виждат в него. В истински сайт ги виждаш веднага.)
Идеята
Адресът е състоянието
Всяка страница трябва да има свой адрес: / за каталога, /product/5 за млякото, /cart за кошницата. Тогава адресът казва коя страница да се покаже, а не състоянието в паметта.
Приложението остава една страница (single-page application): браузърът зарежда HTML веднъж. При клик на връзка библиотеката спира презареждането, сменя адреса през History API на браузъра и рисува друг компонент. Това прави рутерът (router). Той има таблица с маршрути (routes): шаблон на адрес и компонент за него.
React Router дава компоненти за таблицата. Всеки Route казва „за този адрес нарисувай този елемент“:
import { MemoryRouter, Routes, Route } from 'react-router';
export default function App() { return ( <MemoryRouter> <Header /> <Routes> <Route path="/" element={<Catalog />} /> <Route path="/product/:id" element={<ProductPage />} /> <Route path="/cart" element={<CartPage />} /> </Routes> </MemoryRouter> );}Routes рисува само един от маршрутите си: този, който съвпада с адреса. Header е извън Routes, затова се вижда на всяка страница.
:id в /product/:id е параметър: замества един сегмент от адреса. Един маршрут покрива всички продукти.
Виж го
Виж как адресът избира страницата
<Link to={`/product/${p.id}`}>{p.name}</Link>
<Routes> <Route path="/" element={<Catalog />} /> <Route path="/product/:id" element={<ProductPage />} /> <Route path="/cart" element={<CartPage />} /> <Route path="*" element={<NotFound />} /></Routes>
function ProductPage() { const { id } = useParams(); const product = PRODUCTS.find((p) => p.id === Number(id)); return <h1>{product.name}</h1>;}
function NotFound() { return <h1>Няма такава страница</h1>;}Стъпка по стъпка, с думи
- Рутерът има таблица: шаблон на адрес и компонентът за него. Сега адресът е /, каталогът.
- Клик на „Прясно мляко“. Рутерът не дава на страницата да се презареди, а само сменя адреса на /product/5.
- Сверява адреса с всеки ред. „/product/:id“ съвпада: :id замества който и да е един сегмент. Редът за всичко останало също съвпада, но е по-общ и губи.
- От съвпадението излиза параметърът: id = "5". Той е текст, защото адресът е текст. useParams() го дава на ProductPage, а Number(id) го прави 5.
- ProductPage намира продукт 5 и рисува страницата му. Един и същ компонент рисува всеки продукт: различен е само параметърът.
- Сега адресът е /sale, а такава страница още няма. Не съвпада нито един ред освен последния, затова рутерът рисува NotFound.
- Адресът е състоянието на страницата. Можеш да го пратиш, презаредиш и да се върнеш към него, а таблицата винаги дава същата страница.
Рутерът не знае нищо за продуктите. Той сверява текста на адреса с шаблоните и подава параметъра на компонента. Какво значи 5, решава компонентът.
Идеята
В пясъчника и в истинския проект
Рутерът на истинския сайт чете и пише адреса в адресната лента на браузъра. Това е BrowserRouter. Пясъчникът в уроците е страница вътре в страницата на урока и няма свой адрес. Затова тук той дава ясна грешка:
Конзола
В пясъчника ползваме MemoryRouter: рутер, който пази адреса в паметта. Всичко останало е същото: таблицата, връзките, параметрите. Адреса показваме сами, долу на страницата.
В истинския проект рутерът обвива приложението в main.jsx:
import { createRoot } from 'react-dom/client';import { BrowserRouter } from 'react-router';import App from './App.jsx';
createRoot(document.getElementById('root')).render( <BrowserRouter> <App /> </BrowserRouter>,);И още едно нещо, което пясъчникът не показва: сървърът. Когато някой отвори направо /product/5 или натисне „Презареди“, браузърът иска този адрес от сървъра. Такъв файл няма. Сървърът трябва да върне index.html за всеки адрес, който не е файл, а после рутерът избира страницата. Сървърът на Vite при разработка го прави сам. За nginx това прави редът try_files:
location / { try_files $uri /index.html;}Пробвай
Страница за всеки продукт
Таблицата има каталог и кошница, а продуктите водят към /product/1, /product/5 и /product/7, но такъв маршрут няма. Добави маршрут с параметър за страницата на продукта. ProductPage сега винаги показва хляба: нека взима id от адреса. Проверката отваря млякото, после сиренето от „Виж също“, после хляба от каталога и гледа заглавието и адреса.
Конзола
Решение
App.jsximport { useState } from 'react';
import { MemoryRouter, Routes, Route } from 'react-router';
import Header from './Header.jsx';
import Catalog from './Catalog.jsx';
import ProductPage from './ProductPage.jsx';
import CartPage from './CartPage.jsx';
import Address from './Address.jsx';
export default function App() {
const [items, setItems] = useState([]);
function add(product) {
setItems([...items, product]);
}
return (
<MemoryRouter>
<Header count={items.length} />
<main>
<Routes>
<Route path="/" element={<Catalog />} />
<Route path="/product/:id" element={<ProductPage onAdd={add} />} />
<Route path="/cart" element={<CartPage items={items} />} />
</Routes>
</main>
<Address />
</MemoryRouter>
);
}
Header.jsximport { NavLink } from 'react-router';
export default function Header({ count }) {
return (
<nav>
<NavLink to="/" end>Каталог</NavLink>
<NavLink to="/cart">Кошница ({count})</NavLink>
</nav>
);
}
Catalog.jsximport { Link } from 'react-router';
import { PRODUCTS, money } from './products.js';
export default function Catalog() {
return (
<>
<h1>Каталог</h1>
<ul>
{PRODUCTS.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>, {money(p.price)}
</li>
))}
</ul>
</>
);
}
ProductPage.jsximport { Link, useParams } from 'react-router';
import { PRODUCTS, money } from './products.js';
export default function ProductPage({ onAdd }) {
const { id } = useParams();
const product = PRODUCTS.find((p) => p.id === Number(id));
if (!product) return <p>Няма такъв продукт</p>;
const others = PRODUCTS.filter((p) => p.id !== product.id);
function buy() {
onAdd(product);
}
return (
<section>
<h1>{product.name}</h1>
<p>{money(product.price)}</p>
<button type="button" onClick={buy}>Добави в кошницата</button>
<h2>Виж също</h2>
<ul>
{others.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
);
}
CartPage.jsximport { money } from './products.js';
export default function CartPage({ items }) {
const total = items.reduce((sum, p) => sum + p.price, 0);
return (
<>
<h1>Кошница</h1>
{items.length === 0 && <p>Кошницата е празна</p>}
<ul>
{items.map((p, i) => (
<li key={i}>{p.name}, {money(p.price)}</li>
))}
</ul>
<p>Общо: {money(total)}</p>
</>
);
}
NotFound.jsximport { Link } from 'react-router';
export default function NotFound() {
return (
<>
<h1>Няма такава страница</h1>
<p>
<Link to="/">Към каталога</Link>
</p>
</>
);
}
Address.jsximport { useLocation } from 'react-router';
// Пясъчникът няма адресна лента: показваме адреса тук.
export default function Address() {
const location = useLocation();
return <footer>Адрес: {location.pathname}</footer>;
}
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; }
В App.jsx маршрутът е <Route path="/product/:id" element={<ProductPage onAdd={add} />} />. В ProductPage.jsx параметърът идва от const { id } = useParams();. Името id е същото като в шаблона :id.
Параметърът е текст: "5", а id в данните е число. p.id === "5" е false за всеки продукт. Сравни с Number(id).
Пробвай
Намери грешката
Всяка страница на продукт казва „Няма такъв продукт“, а продуктите са в данните. Кой ред е виновен?
Избери реда с грешката.
id от useParams() е текст ("5"), а p.id е число. Строгото === никога не е вярно. Трябва p.id === Number(id).Идеята
Връзки и преход от кода
Връзките. Към страница на приложението води <Link to="/cart">. В страницата той е обикновен <a href="/cart">: работи „Отвори в нов раздел“, а екранният четец го чете като връзка. При обикновен клик рутерът спира презареждането (preventDefault) и само сменя адреса. Ако напишеш <a href> сам, браузърът зарежда целия сайт наново, и кошницата в паметта изчезва.
За менюто има NavLink: връзката към текущата страница получава aria-current="page" и може да е с друг стил. Така и окото, и екранният четец разбират къде си.
Преход от кода. Понякога страницата трябва да се смени след действие, не след клик на връзка: „Добави в кошницата“ добавя продукта и води към кошницата.
import { useNavigate } from 'react-router';
export default function ProductPage({ onAdd }) { const navigate = useNavigate(); // … function buy() { onAdd(product); navigate('/cart'); }}useNavigate() е кукичка: викаш я в тялото на компонента и получаваш функция navigate. Самата функция викаш където трябва, например в обработчика.
Връзка ползваш, когато човек избира къде да отиде. Преход от кода ползваш, когато страницата се сменя заради резултат от действие.
Пробвай
Попълни
Откъде страницата на продукта взима id от адреса?
export default function ProductPage() {
const { id } = ();
const product = PRODUCTS.find((p) => p.id === Number(id));
return <h1>{product.name}</h1>;
}useParams: връща обект с параметрите на маршрута, който съвпада. За /product/:id това е { id: "5" }.Идеята
Няма такава страница
Ако адресът не съвпада с нито един маршрут, рутерът не рисува нищо. Човекът вижда празно място под менюто и не знае какво стана. Затова последният маршрут хваща всичко останало и рисува „Няма такава страница“ с връзка към каталога.
<Route path="*" element={<NotFound />} />* съвпада с всеки адрес. Редът в Routes не е важен: React Router подрежда маршрутите по точност, и * печели само когато друг не съвпада.
Страницата за продукт, който го няма (/product/99), е друг случай: маршрутът съвпада, но продуктът липсва. Това решава компонентът, затова ProductPage пише „Няма такъв продукт“.
По-точно, за любопитните
Вложени маршрути. Страниците често имат обща рамка: акаунтът с меню вляво и подстраници „Поръчки“ и „Адреси“. Маршрутите се влагат един в друг, а родителят рисува <Outlet /> там, където идва детето.
Заявка в адреса. Търсенето и филтрите също са състояние, което си струва да е в адреса: /?q=мляко&category=2. Чете се и се пише с useSearchParams().
Същият компонент, нов параметър. От /product/5 към /product/7 React рисува наново същия ProductPage с нов id, затова сметките в тялото са верни. Но състоянието му (например поле „брой“) остава от предишния продукт. Ако трябва да започва отначало, дай ключ: <ProductPage key={id} /> в компонент, който чете id.
Режими на React Router. Тук е „декларативният“ режим: Routes и Route в JSX. Има и режим с данни (createBrowserRouter с loader за зареждане преди страницата) и режим „framework“ с Vite. Идеята за таблица, параметри и връзки е същата.
Пазачи. Страницата „Поръчка“ може да изисква вход. Рутерът може да провери преди прехода и да пренасочи към вход: в режима с данни с loader и redirect.
Упражнение
Кошницата и 404
В менюто има „Промоции“ (/sale), но такава страница още няма и под менюто остава празно. А „Добави в кошницата“ добавя продукта, но човекът остава на страницата на продукта. Добави маршрут за всичко останало към NotFound (компонентът е готов) и направи бутона да води към /cart след добавянето. Проверката отваря „Промоции“, връща се с „Към каталога“, купува хляб и сирене и гледа кошницата, адреса и сумата.
Конзола
Решение
App.jsximport { useState } from 'react';
import { MemoryRouter, Routes, Route } from 'react-router';
import Header from './Header.jsx';
import Catalog from './Catalog.jsx';
import ProductPage from './ProductPage.jsx';
import CartPage from './CartPage.jsx';
import NotFound from './NotFound.jsx';
import Address from './Address.jsx';
export default function App() {
const [items, setItems] = useState([]);
function add(product) {
setItems([...items, product]);
}
return (
<MemoryRouter>
<Header count={items.length} />
<main>
<Routes>
<Route path="/" element={<Catalog />} />
<Route path="/product/:id" element={<ProductPage onAdd={add} />} />
<Route path="/cart" element={<CartPage items={items} />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
<Address />
</MemoryRouter>
);
}
Header.jsximport { NavLink } from 'react-router';
export default function Header({ count }) {
return (
<nav>
<NavLink to="/" end>Каталог</NavLink>
<NavLink to="/cart">Кошница ({count})</NavLink>
<NavLink to="/sale">Промоции</NavLink>
</nav>
);
}
Catalog.jsximport { Link } from 'react-router';
import { PRODUCTS, money } from './products.js';
export default function Catalog() {
return (
<>
<h1>Каталог</h1>
<ul>
{PRODUCTS.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>, {money(p.price)}
</li>
))}
</ul>
</>
);
}
ProductPage.jsximport { Link, useNavigate, useParams } from 'react-router';
import { PRODUCTS, money } from './products.js';
export default function ProductPage({ onAdd }) {
const { id } = useParams();
const navigate = useNavigate();
const product = PRODUCTS.find((p) => p.id === Number(id));
if (!product) return <p>Няма такъв продукт</p>;
const others = PRODUCTS.filter((p) => p.id !== product.id);
function buy() {
onAdd(product);
navigate('/cart');
}
return (
<section>
<h1>{product.name}</h1>
<p>{money(product.price)}</p>
<button type="button" onClick={buy}>Добави в кошницата</button>
<h2>Виж също</h2>
<ul>
{others.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
);
}
CartPage.jsximport { money } from './products.js';
export default function CartPage({ items }) {
const total = items.reduce((sum, p) => sum + p.price, 0);
return (
<>
<h1>Кошница</h1>
{items.length === 0 && <p>Кошницата е празна</p>}
<ul>
{items.map((p, i) => (
<li key={i}>{p.name}, {money(p.price)}</li>
))}
</ul>
<p>Общо: {money(total)}</p>
</>
);
}
NotFound.jsximport { Link } from 'react-router';
export default function NotFound() {
return (
<>
<h1>Няма такава страница</h1>
<p>
<Link to="/">Към каталога</Link>
</p>
</>
);
}
Address.jsximport { useLocation } from 'react-router';
// Пясъчникът няма адресна лента: показваме адреса тук.
export default function Address() {
const location = useLocation();
return <footer>Адрес: {location.pathname}</footer>;
}
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; }
Маршрутът е <Route path="*" element={<NotFound />} />. В ProductPage.jsx: const navigate = useNavigate(); в тялото, а navigate('/cart') в buy след onAdd(product). Кошницата е в App, затова броят в менюто и CartPage я виждат веднага.
Проверката гледа и менюто: на страницата на кошницата връзката „Кошница (1)“ има aria-current="page". Това рутерът го прави сам.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Състояние · Изчислено състояние · Вдигане на състоянието · Общо състояние
- Продължава в: Бързина: кога се рисува наново · От пясъчника към проект · Витрината на магазина
- Задачи по темата: Цяло приложение