22. SvelteKit: Маршрутизация
🗺️ SvelteKit Роутинг: от простого к сложному
Заголовок раздела «🗺️ SvelteKit Роутинг: от простого к сложному»Привет! 👋 Роутинг в SvelteKit — это как карта города: у каждой улицы есть своё название (URL), у каждого здания — своя функция (компонент). И самое приятное — структура папок и есть маршруты. Никаких отдельных конфигурационных файлов!
Думай о роутинге SvelteKit как об оглавлении книги: src/routes/ — это оглавление, а каждая папка — это глава. Создал папку — создал маршрут. Магия!
📄 Базовые файлы роутинга
Заголовок раздела «📄 Базовые файлы роутинга»В SvelteKit файлы маршрутов начинаются с +. Это не просто соглашение — это синтаксис, который позволяет SvelteKit отличать файлы маршрутов от вспомогательных файлов в той же папке.
src/routes/├── +page.svelte ← Страница по пути /├── +layout.svelte ← Layout для всех страниц├── +error.svelte ← Страница ошибки├── +page.ts ← Загрузка данных для /├── about/│ └── +page.svelte ← Страница /about├── blog/│ ├── +page.svelte ← Страница /blog│ ├── +layout.svelte ← Layout только для /blog/**│ └── [slug]/│ └── +page.svelte ← Страница /blog/любой-слаг└── (marketing)/ ← Группа роутов (нет в URL!) ├── +layout.svelte ← Layout только для этой группы ├── landing/ │ └── +page.svelte ← Страница /landing (не /(marketing)/landing!) └── pricing/ └── +page.svelte ← Страница /pricing🏗️ +layout.svelte: общий каркас страниц
Заголовок раздела «🏗️ +layout.svelte: общий каркас страниц»+layout.svelte — это оболочка, в которую вставляются дочерние страницы. Идеально для навигации, футеров, провайдеров тем:
<script lang="ts"> import { page } from '$app/stores'; import Navigation from '$lib/components/Navigation.svelte'; import Footer from '$lib/components/Footer.svelte';
// Слот children — это место, куда вставится дочерняя страница let { children } = $props();</script>
<div class="app"> <Navigation currentPath={$page.url.pathname} />
<main> <!-- Здесь рендерится контент страницы --> {@render children()} </main>
<Footer /></div>
<style> .app { display: flex; flex-direction: column; min-height: 100vh; }
main { flex: 1; padding: 2rem; max-width: 1200px; margin: 0 auto; width: 100%; }</style>Вложенные layouts:
<!-- src/routes/blog/+layout.svelte — только для /blog/** --><script lang="ts"> let { children, data } = $props();</script>
<div class="blog-layout"> <aside class="sidebar"> <h3>Категории</h3> {#each data.categories as cat} <a href="/blog/category/{cat.slug}">{cat.name}</a> {/each} </aside>
<article class="content"> {@render children()} </article></div>Цепочка layouts:
Запрос /blog/my-post: 1. +layout.svelte (корневой — навигация + футер) 2. blog/+layout.svelte (блог — сайдбар) 3. blog/[slug]/+page.svelte (контент поста)❌ +error.svelte: красивые страницы ошибок
Заголовок раздела «❌ +error.svelte: красивые страницы ошибок»<!-- src/routes/+error.svelte — для любых ошибок в любом маршруте --><script lang="ts"> import { page } from '$app/stores';</script>
<div class="error-page"> <h1>{$page.status}</h1> <p>{$page.error?.message}</p>
{#if $page.status === 404} <p>Страница не найдена 🕵️</p> {:else if $page.status === 500} <p>Что-то пошло не так на сервере 💥</p> {/if}
<a href="/">На главную</a></div><!-- Можно создавать error.svelte для конкретных секций: --><!-- src/routes/blog/+error.svelte — только для ошибок в /blog/** --><script lang="ts"> import { page } from '$app/stores';</script>
<div> <h2>Ошибка в блоге</h2> <p>Пост "{$page.params.slug}" не найден</p></div>🎯 Динамические маршруты: [param]
Заголовок раздела «🎯 Динамические маршруты: [param]»src/routes/├── blog/│ └── [slug]/+page.svelte ← /blog/hello-world├── users/│ └── [id]/│ ├── +page.svelte ← /users/123│ └── settings/│ └── +page.svelte ← /users/123/settings├── products/│ └── [category]/│ └── [id]/+page.svelte ← /products/electronics/42<script lang="ts"> import type { PageData } from './$types'; let { data }: { data: PageData } = $props();
// data.slug доступен из load функции // или через $page.params.slug</script>
<h1>{data.post.title}</h1><div class="content">{@html data.post.html}</div>import type { PageServerLoad } from './$types';import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => { const { slug } = params; // Тип: string
const post = await db.posts.findUnique({ where: { slug }, });
if (!post) { error(404, { message: `Пост "${slug}" не найден` }); }
return { post };};Валидация параметров с матчерами:
// src/params/integer.ts — кастомный матчерimport type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => { return /^\d+$/.test(param); // Только целые числа};// Использование матчера в имени папки:src/routes/users/[id=integer]/+page.svelte ← /users/123 ✅, /users/abc ❌🌊 Catch-all маршруты: […rest]
Заголовок раздела «🌊 Catch-all маршруты: […rest]»<!-- Совпадает с: /docs/intro, /docs/api/getting-started, /docs/a/b/c --><script lang="ts"> import { page } from '$app/stores';
// $page.params.path будет строкой: "api/getting-started" let pathSegments = $page.params.path.split('/');</script>
<nav> {#each pathSegments as segment, i} <span> {#if i < pathSegments.length - 1} <a href="/docs/{pathSegments.slice(0, i + 1).join('/')}">{segment}</a> / {:else} <strong>{segment}</strong> {/if} </span> {/each}</nav>Необязательные параметры:
src/routes/[[lang]]/about/+page.svelte ← Совпадает и с /about, и с /ru/about, и с /en/about// load функция для [[lang]]:export const load: PageServerLoad = async ({ params }) => { const lang = params.lang ?? 'ru'; // Необязательный параметр const content = await getContent('about', lang); return { content, lang };};🎪 Группы маршрутов: (group)
Заголовок раздела «🎪 Группы маршрутов: (group)»Круглые скобки в имени папки создают группы роутов — они не добавляются в URL, но позволяют делиться layout-ами:
src/routes/├── (app)/ ← Группа для авторизованных страниц│ ├── +layout.svelte ← Проверяет авторизацию!│ ├── dashboard/│ │ └── +page.svelte ← URL: /dashboard (без /(app)/)│ ├── profile/│ │ └── +page.svelte ← URL: /profile│ └── settings/│ └── +page.svelte ← URL: /settings├── (auth)/ ← Группа для страниц авторизации│ ├── +layout.svelte ← Минимальный layout (без навигации)│ ├── login/│ │ └── +page.svelte ← URL: /login│ └── register/│ └── +page.svelte ← URL: /register└── (marketing)/ ← Публичные маркетинговые страницы ├── +layout.svelte ← Layout с Hero секцией ├── +page.svelte ← URL: / (главная) ├── pricing/ │ └── +page.svelte ← URL: /pricing └── about/ └── +page.svelte ← URL: /about<!-- src/routes/(app)/+layout.svelte — защита авторизованных маршрутов --><script lang="ts"> import { page } from '$app/stores'; import { goto } from '$app/navigation'; import type { LayoutData } from './$types';
let { children, data }: { children: any, data: LayoutData } = $props();
// Редирект если не авторизован (лучше делать в +layout.server.ts!)</script>
{#if data.user} <nav> <a href="/dashboard">Dashboard</a> <a href="/profile">Профиль</a> <a href="/settings">Настройки</a> <span>Привет, {data.user.name}!</span> </nav> {@render children()}{:else} <p>Загрузка...</p>{/if}🧭 $page store: информация о текущей странице
Заголовок раздела «🧭 $page store: информация о текущей странице»<script lang="ts"> import { page } from '$app/stores';
// $page содержит: // $page.url — текущий URL (объект URL) // $page.params — параметры маршрута { slug: 'hello' } // $page.route — информация о маршруте { id: '/blog/[slug]' } // $page.status — HTTP статус // $page.error — объект ошибки // $page.data — данные из load функций // $page.form — данные форм (Form Actions)
$effect(() => { console.log('Текущий URL:', $page.url.pathname); console.log('Параметры:', $page.params); });</script>
<!-- Активная навигация с $page --><nav> {#each [ { href: '/', label: 'Главная' }, { href: '/blog', label: 'Блог' }, { href: '/about', label: 'О нас' }, ] as link} <a href={link.href} class:active={$page.url.pathname === link.href} > {link.label} </a> {/each}</nav>🚗 Навигация: goto() и ссылки
Заголовок раздела «🚗 Навигация: goto() и ссылки»<script lang="ts"> import { goto, beforeNavigate, afterNavigate, preloadData, preloadCode } from '$app/navigation';
// Программная навигация: async function handleLogin() { await login(); goto('/dashboard', { replaceState: true, // Не добавлять в историю invalidateAll: true, // Инвалидировать все load функции state: { fromLogin: true }, // Состояние истории (history.state) }); }
// Перехватчики навигации: beforeNavigate(({ to, from, type, cancel }) => { if (hasUnsavedChanges) { const confirmed = confirm('Есть несохранённые изменения. Уйти?'); if (!confirmed) { cancel(); // Отменяем навигацию } } console.log(`Уходим с ${from?.url.pathname} на ${to?.url.pathname}`); console.log('Тип навигации:', type); // 'link' | 'goto' | 'popstate' | 'enter' | 'leave' });
afterNavigate(({ to, from, type }) => { // Аналитика: analytics.track('page_view', { path: to?.url.pathname }); });
// Prefetch данных (как наводить мышь в Next.js): function handleMouseEnter(href: string) { preloadData(href); // Загружает данные для страницы заранее }
function handleMouseEnterCode(href: string) { preloadCode(href); // Загружает JavaScript код для страницы }</script>
<!-- Обычные ссылки SvelteKit — автоматически перехватывают клики --><a href="/about">О нас</a>
<!-- data-sveltekit-preload-data — prefetch при наведении --><a href="/blog" data-sveltekit-preload-data="hover">Блог</a>
<!-- data-sveltekit-preload-data="tap" — prefetch при клике (мобильные) --><a href="/shop" data-sveltekit-preload-data="tap">Магазин</a>
<!-- Отключить клиентскую навигацию: --><a href="/external" data-sveltekit-reload>Внешняя страница</a>🔄 beforeNavigate / afterNavigate хуки
Заголовок раздела «🔄 beforeNavigate / afterNavigate хуки»<script lang="ts"> import { beforeNavigate, afterNavigate } from '$app/navigation'; import { page } from '$app/stores';
let isLoading = $state(false); let previousPage = $state('');
// Показываем индикатор загрузки: beforeNavigate(() => { isLoading = true; });
afterNavigate(({ from }) => { isLoading = false; previousPage = from?.url.pathname ?? '';
// Скролл наверх при навигации: window.scrollTo({ top: 0, behavior: 'smooth' }); });</script>
{#if isLoading} <div class="loading-bar"></div>{/if}
{#if previousPage} <button onclick={() => history.back()}>← Назад ({previousPage})</button>{/if}🔗 Link preloading настройка
Заголовок раздела «🔗 Link preloading настройка»<!-- В +layout.svelte — глобальный prefetch --><svelte:head> <!-- Prefetch критических страниц: --> <link rel="prefetch" href="/api/user" /></svelte:head><!-- app.html — настройка глобального prefetch --><!DOCTYPE html><html lang="ru"> <head> %sveltekit.head% </head> <!-- data-sveltekit-preload-data на body — prefetch для всех ссылок: --> <body data-sveltekit-preload-data="hover"> <div style="display: contents">%sveltekit.body%</div> </body></html>🔧 Параллельные slots и именованные snippets
Заголовок раздела «🔧 Параллельные slots и именованные snippets»Svelte 5 + SvelteKit поддерживает именованные snippets для более сложных layout-ов:
<!-- src/routes/+layout.svelte с именованными слотами (Svelte 5) --><script lang="ts"> let { children, sidebar } = $props();</script>
<div class="two-column"> <aside> {#if sidebar} {@render sidebar()} {:else} <!-- Дефолтный сайдбар --> <DefaultSidebar /> {/if} </aside>
<main> {@render children()} </main></div>🛡️ Защищённые маршруты
Заголовок раздела «🛡️ Защищённые маршруты»// src/routes/(app)/+layout.server.tsimport { redirect } from '@sveltejs/kit';import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals, url }) => { // locals.user устанавливается в hooks.server.ts if (!locals.user) { redirect(307, `/login?redirectTo=${url.pathname}`); }
return { user: locals.user, };};// src/hooks.server.ts — заполняем locals.userimport type { Handle } from '@sveltejs/kit';import { verifyToken } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => { const token = event.cookies.get('session');
if (token) { const user = await verifyToken(token); event.locals.user = user; }
return resolve(event);};