Skip to main content

Vue router: маршрутизація та охоронці навігації

Vue Router відображає URL-шляхи на компоненти Vue і керує навігацією через охоронці (guards) - код, що запускається до, під час або після переходу між маршрутами.

Теорія

TL;DR

  • Vue Router = адміністратор готелю: URL це номер кімнати, компонент це сама кімната, охоронець перевіряє перепустку на вході
  • Порядок виконання: beforeEach (глобальний) → beforeEnter (для маршруту) → завантаження async-компонентів → beforeResolve (глобальний) → навігація підтверджується → afterEach
  • beforeEach для перевірки auth і редиректів, beforeResolve для prefetch даних, afterEach для аналітики
  • Маршрут /:pathMatch(.*)* завжди в кінці масиву - інакше він перехопить усі URL раніше за специфічні маршрути

Швидкий приклад

typescript
// router/index.ts import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: () => import('./Home.vue') }, { path: '/dashboard', component: () => import('./Dashboard.vue'), meta: { requiresAuth: true } // зчитується в охоронці нижче }, { path: '/:pathMatch(.*)*', component: () => import('./NotFound.vue') } // wildcard завжди останній ] }) router.beforeEach((to) => { // Запускається перед кожною навігацією if (to.meta.requiresAuth && !localStorage.getItem('token')) { return { path: '/login', query: { redirect: to.fullPath } } } }) export default router

Перехід на /dashboard без токена редиректить на /login?redirect=/dashboard. З токеном у localStorage компонент Dashboard завантажується звичайно.

Охоронці vs базовий routing

Без охоронців Vue Router пасивний: URL змінився, компонент підмінився, і все. Охоронці перетворюють це на активне управління потоком. Вони ставлять навігацію на паузу, запускають твій код і продовжують лише якщо ти дозволяєш.

Це важливо коли в застосунку є авторизація, права доступу або дані, які треба завантажити до рендеру сторінки. Базовий routing цього не вміє. Охоронці вміють.

Три типи охоронців

Глобальні охоронці прив'язані до екземпляра router і запускаються для кожної навігації в застосунку:

typescript
router.beforeEach((to) => { const auth = useAuthStore() if (to.meta.requiresAuth && !auth.isLoggedIn) { return '/login' } }) router.afterEach((to) => { analytics.track(to.fullPath) // після кожної успішної навігації })

Охоронці маршруту (beforeEnter) задаються прямо в конфігурації маршруту. Зручно, коли логіка стосується одного маршруту і не повинна засмічувати глобальний beforeEach:

typescript
const routes = [ { path: '/admin', component: () => import('@/views/Admin.vue'), beforeEnter: async (to, from) => { const user = await fetchUser() if (!user.isAdmin) return '/unauthorized' } } ]

Охоронці в компоненті використовують composables всередині самого компонента. Головний сценарій - не дати користувачу піти зі сторінки з незбереженою формою:

vue
<script setup> import { onBeforeRouteLeave } from 'vue-router' onBeforeRouteLeave((to, from) => { if (hasUnsavedChanges.value) { return window.confirm('Вийти без збереження?') } }) </script>

Порядок виконання охоронців

Ось послідовність кроків, яку Vue Router виконує під час кожної навігації:

  1. router.beforeEach (глобальний)
  2. beforeEnter на цільовому маршруті
  3. Завантаження async-компонентів (dynamic imports запускаються тут)
  4. router.beforeResolve (глобальний, після завантаження компонентів)
  5. Навігація підтверджується, URL змінюється
  6. router.afterEach (глобальний, після завершення навігації)

beforeEach запускається до завантаження будь-якого компонента, тому він дешевий для редиректів. beforeResolve краще підходить для запитів до API - до цього моменту компонент точно завантажений. Бачив команди, що клали важкі API-запити в beforeEach і дивувалися чому компоненти встигають мигнути до редиректу. Саме тому.

Meta-поля маршрутів

Meta-поля дозволяють прикріпити довільні дані до маршруту і зчитати їх в охоронцях:

typescript
const routes = [ { path: '/profile/:id', component: UserProfile, meta: { requiresAuth: true, role: 'user' } } ] router.beforeEach((to) => { if (to.meta.requiresAuth && !store.auth.isLoggedIn) { return '/login?redirect=' + to.fullPath } })

to.meta доступний в будь-якому охоронці, включно з глобальними. Meta-поля батьківського маршруту також доступні в дочірніх через to.matched.

Типові помилки

Подвійний виклик next(). Callback-стиль з параметром next досі підтримується, але часто збиває з пантелику. Якщо не повернути після виклику next, обидві гілки виконуються:

typescript
// Неправильно: обидві гілки виконуються коли умова хибна router.beforeEach((to, from, next) => { if (!auth) next('/login') // провалюється далі! next() }) // Правильно: ранній return router.beforeEach((to, from, next) => { if (!auth) return next('/login') next() }) // Краще: Vue Router 4 дозволяє просто повертати значення router.beforeEach((to) => { if (!auth) return '/login' })

Wildcard маршрут на неправильній позиції. /:pathMatch(.*)* відповідає будь-якому шляху. Якщо поставити його першим, жоден інший маршрут в масиві ніколи не спрацює:

typescript
// Неправильно: /users/:id ніколи не матчиться routes: [ { path: '/:pathMatch(.*)*', component: NotFound }, // перехоплює все { path: '/users/:id', component: UserProfile } ] // Правильно: wildcard останній routes: [ { path: '/users/:id', component: UserProfile }, { path: '/:pathMatch(.*)*', component: NotFound } ]

afterEach логує зайві перегляди сторінок. Виклик next(false) скасовує навігацію, але afterEach все одно виконується. Аналітика зафіксує перегляд сторінки, на якій користувач так і не опинився. Перевіряй to.fullPath !== from.fullPath всередині afterEach, або встановлюй прапорець при скасуванні.

Запити до API в beforeEach замість beforeResolve. В момент виконання beforeEach async-компонент ще не завантажений. Запит стартує до підтвердження маршруту. Для даних, що прив'язані до компонента, використовуй beforeResolve.

Де використовується

  • Nuxt 3 огортає охоронці у defineNuxtRouteMiddleware і сам розрізняє SSR і клієнт (на сервері читає cookies через useRequestHeaders(), на клієнті - localStorage)
  • Quasar Framework реєструє глобальні охоронці в boot-файлах для перевірки PWA offline-стану перед навігацією
  • Pinia auth stores централізують логіку охоронців: useAuthStore().check() в beforeEach тримає перевірку токена в одному місці
  • VitePress використовує beforeEach для оновлення пошукового індексу під час навігації

Питання на співбесіді

Q: Який порядок виконання охоронців у Vue Router?
A: router.beforeEachbeforeEnter маршруту → завантаження async-компонентів → router.beforeResolve → підтвердження навігації → router.afterEach.

Q: Коли використовувати beforeResolve замість beforeEach?
A: beforeEach запускається до завантаження компонентів - підходить для редиректів та перевірки auth. beforeResolve запускається після завантаження компонентів, але до рендеру - саме там роблять prefetch даних.

Q: Як обробити async-операції в охоронці?
A: Поверни Promise з охоронця. Router чекатиме на його виконання. Якщо використовуєш next, викликай його тільки після завершення async-роботи.

Q: Як передати дані в охоронець через конфігурацію маршруту?
A: Додай будь-яке поле до об'єкта meta в конфігурації маршруту і зчитуй як to.meta.твоєПоле в будь-якому охоронці.

Q: (Senior) В Nuxt SSR як не допустити помилку hydration через охоронці що читають localStorage?
A: На сервері localStorage недоступний. Використовуй process.client щоб обмежити такий код тільки клієнтом, а для читання auth-cookies на сервері - useRequestHeaders(). Middleware в Nuxt 3 через defineNuxtRouteMiddleware обробляє цей поділ автоматично.

Q: (Senior) Користувач натискає "вийти" поки охоронець beforeEach виконує async-запит профілю. Що зламається і як виправити?
A: await виконається вже після logout, і стан auth буде застарілим - навігація може пройти. Рішення: AbortController для запиту і повторна перевірка authStore.isLoggedIn після await. Якщо false, одразу return '/login'.

Приклади

Охоронець авторизації зі збереженням цільового URL

typescript
// router/index.ts import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '@/stores/auth' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: () => import('@/views/Home.vue') }, { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/profile/:id', component: () => import('@/views/UserProfile.vue'), meta: { requiresAuth: true } } ] }) router.beforeEach((to) => { const auth = useAuthStore() if (to.meta.requiresAuth && !auth.isLoggedIn) { // Зберігаємо цільовий шлях для редиректу після входу return { path: '/login', query: { redirect: to.fullPath } } } }) export default router

Перехід на /profile/42 без авторизації редиректить на /login?redirect=/profile/42. Після успішного входу сторінка логіну читає route.query.redirect і відправляє користувача одразу на /profile/42.

Async перевірка ролі в охоронці маршруту

typescript
const routes = [ { path: '/dashboard', component: () => import('@/views/Dashboard.vue'), beforeEnter: async (to, from) => { const user = await fetchUser() // API-запит тільки при переході на /dashboard if (!user.isAdmin) return '/login' } } ]

API-запит виконується лише при відвідуванні /dashboard. Не-адміни потрапляють на /login. Охоронець живе прямо в конфігурації маршруту, тому глобальний beforeEach залишається чистим.

Вкладені маршрути зі спільним layout

typescript
const routes = [ { path: '/dashboard', component: () => import('@/layouts/DashboardLayout.vue'), meta: { requiresAuth: true }, // поширюється на всі дочірні маршрути children: [ { path: '', component: () => import('@/views/DashboardHome.vue') }, // /dashboard { path: 'settings', component: () => import('@/views/Settings.vue') }, // /dashboard/settings { path: 'profile', component: () => import('@/views/Profile.vue') } // /dashboard/profile ] } ]
vue
<!-- DashboardLayout.vue --> <template> <div class="dashboard-layout"> <Sidebar /> <main> <RouterView /> <!-- тут рендеряться дочірні компоненти --> </main> </div> </template>

meta: { requiresAuth: true } на батьківському маршруті поширюється на всіх дітей. Одна перевірка в beforeEach покриває весь розділ dashboard без повторення meta-поля на кожному дочірньому маршруті.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?