Питання на співбесіді: Vue Router і Pinia
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
12 питань
Vue Router зіставляє адресу в браузері з компонентом і показує його без перезавантаження сторінки.
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Home },
{ path: '/users/:id', name: 'user', component: UserProfile },
{ path: '/:pathMatch(.*)*', component: NotFound }, // усе інше - 404
],
});
app.use(router);
<template>
<nav>
<RouterLink to="/">Головна</RouterLink>
<RouterLink :to="{ name: 'user', params: { id: 7 } }">Профіль</RouterLink>
</nav>
<RouterView /> <!-- тут рендериться компонент поточного маршруту -->
</template>
Динамічні параметри. :id у шляху - параметр. У компоненті він доступний через useRoute():
<script setup>
import { useRoute, useRouter } from 'vue-router';
const route = useRoute(); // поточний маршрут: params, query, hash, meta
const router = useRouter(); // навігація з коду
console.log(route.params.id); // '7' - завжди рядок
router.push({ name: 'user', params: { id: 8 } });
</script>
Що варто знати:
- параметри - рядки.
route.params.id === 7хибне - требаNumber(route.params.id); - іменовані маршрути (
name: 'user') надійніші за рядкові шляхи: при зміні URL посилання в коді не ламаються; RouterLinkзамість<a href>- інакше браузер перезавантажить сторінку;- query-параметри (
?page=2) - уroute.query, вони не входять у шаблон шляху.
Vue Router 5 зберіг API четвертої версії й додав маршрутизацію на основі файлів (колишній unplugin-vue-router): маршрути генеруються зі структури каталогу src/pages, а route.params отримують точні типи в TypeScript.
Pinia - офіційний стор для Vue: спільний стан, до якого мають доступ будь-які компоненти без передачі props через десять рівнів. Наступник Vuex, без мутацій і з повною підтримкою TypeScript.
Option store - схожий на Options API:
import { defineStore } from 'pinia';
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] }),
getters: {
total: (state) => state.items.reduce((sum, item) => sum + item.price * item.qty, 0),
},
actions: {
add(product) {
this.items.push({ ...product, qty: 1 });
},
},
});
Setup store - функція в стилі Composition API:
export const useCartStore = defineStore('cart', () => {
const items = ref([]);
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));
function add(product) {
items.value.push({ ...product, qty: 1 });
}
return { items, total, add };
});
ref стає станом, computed - гетером, функції - діями.
Використання однакове:
<script setup>
const cart = useCartStore();
</script>
<template>
<span>{{ cart.items.length }} товарів на {{ cart.total }} грн</span>
<button @click="cart.add(product)">У кошик</button>
</template>
Відмінності:
- setup store гнучкіший: у ньому можна використовувати
watch, інші composables, інжектовані значення; - у setup store треба повернути весь стан - неповернений
refне потрапить у devtools, SSR і плагіни; $reset()вбудований лише в option store; у setup store його пишуть вручну;- option store простіший для новачків і для тих, хто переходить з Vuex.
Перший аргумент defineStore - унікальний id; два стори з однаковим id конфліктують.
Pinia 4 змінила лише збірку: пакет тепер тільки ESM, а @vue/devtools-api встановлюється окремо. API сторів той самий.
Стор Pinia - об'єкт, обгорнутий у reactive(). Звичайна деструктуризація копіює поточні значення в локальні змінні - і зв'язок з реактивністю зникає:
const cart = useCartStore();
const { items, total } = cart; // items і total більше не оновлюються
Після cart.add(product) в інтерфейсі нічого не зміниться: total лишився числом, обчисленим у момент деструктуризації.
storeToRefs() перетворює стан і гетери на ref, що лишаються пов'язаними зі стором:
import { storeToRefs } from 'pinia';
const cart = useCartStore();
const { items, total } = storeToRefs(cart); // реактивні ref
const { add, clear } = cart; // дії - звичайною деструктуризацією
<template>
<p>Разом: {{ total }} грн</p> <!-- оновлюється -->
</template>
Чому дії деструктурують без storeToRefs: це звичайні функції, прив'язані до стору. storeToRefs() їх пропускає - він повертає лише стан і гетери.
Що ще варто знати:
- у скрипті значення з
storeToRefs- цеref:total.value, у шаблоні - простоtotal; - зміна
items.value = []змінює стан стору, а не локальну копію - це двосторонній зв'язок; toRefs()з Vue на сторі теж «працює», але перетворює на ref і дії, і внутрішні властивості - для сторів потрібен самеstoreToRefs();- найпростіший варіант без пасток - не деструктурувати взагалі й звертатися через
cart.total.
Та сама проблема виникає з reactive() і з props у компонентах: деструктуризація реактивного об'єкта завжди «знімає копію».
Якщо всі сторінки імпортовані статично, збирач кладе їх в один бандл: користувач, що відкрив головну, завантажує й код адмінки, звітів і налаштувань.
Ліниве завантаження - замість компонента передається функція, що його імпортує:
const routes = [
{ path: '/', component: () => import('./pages/Home.vue') },
{ path: '/reports', component: () => import('./pages/Reports.vue') },
{
path: '/admin',
component: () => import('./layouts/AdminLayout.vue'),
children: [
{ path: 'users', component: () => import('./pages/admin/Users.vue') },
],
},
];
Vite бачить динамічний import() і виносить кожну сторінку в окремий файл. Він завантажується лише при першому переході на маршрут, а далі береться з кешу.
Що це дає:
- менший початковий бандл - сторінка швидше стає інтерактивною;
- рідко потрібні частини (адмінка, звіти з важкими бібліотеками графіків) не завантажуються більшості користувачів.
Пастки:
- не використовуйте
defineAsyncComponentдля маршрутів - роутер сам уміє чекати на функцію-імпорт;defineAsyncComponent- для асинхронних компонентів усередині сторінок; - помилка завантаження частини після деплою. Користувач з давно відкритою вкладкою переходить на маршрут, а файл старої збірки вже видалено з сервера. Варто обробити
router.onError()і перезавантажити сторінку, а Vite генерує подіюvite:preloadError; - затримка першого переходу - для очікуваних маршрутів частину можна підвантажити заздалегідь (при наведенні на посилання);
- не дробити надто сильно: окремий файл для кожного дрібного маршруту - багато дрібних запитів. Пов'язані сторінки можна групувати.
У маршрутизації на основі файлів (Vue Router 5) сторінки з src/pages лінивими робляться автоматично.
Navigation guards - функції, що виконуються перед переходом і можуть його дозволити, скасувати чи перенаправити.
Глобальний guard з перевіркою meta маршруту:
const routes = [
{ path: '/login', name: 'login', component: Login },
{ path: '/dashboard', component: Dashboard, meta: { requiresAuth: true } },
];
router.beforeEach((to, from) => {
const auth = useAuthStore();
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } };
}
// нічого не повертати (або true) - перехід дозволено
});
Що може повернути guard:
undefined/true- продовжити;false- скасувати перехід;- маршрут (рядок чи об'єкт) - перенаправити;
- Promise - роутер дочекається результату (async-guard з запитом до API).
Третій аргумент next досі підтримується, але документація радить повертати значення: з next легко забути виклик чи викликати його двічі.
meta успадковується: перевірка to.meta.requiresAuth спрацює й для вкладених маршрутів, якщо мета задана на батьківському (точніше - to.matched.some(r => r.meta.requiresAuth) враховує всі рівні).
Інші guards:
beforeEnterу конфігурації маршруту - лише для нього;onBeforeRouteLeaveу компоненті - попередити про незбережені зміни;onBeforeRouteUpdate- коли змінюються параметри того самого маршруту;router.afterEach- аналітика, заголовок сторінки (не може скасувати перехід).
Головне застереження: guard - це зручність інтерфейсу, а не захист. Код сторінки вже завантажено в браузер, а будь-хто може змінити стан стору в devtools. Справжній захист - на сервері: API має перевіряти автентифікацію й права на кожен запит.
Пастка нескінченного перенаправлення: guard, що відправляє на /login, не повинен застосовуватися до самого /login.
При переході між маршрутами з тим самим компонентом (/users/1 → /users/2) Vue Router не перестворює компонент - він лише оновлює route.params. Хуки onMounted і setup не виконуються вдруге, і дані першого користувача лишаються на екрані.
<script setup>
const route = useRoute();
const user = ref(null);
onMounted(async () => {
user.value = await fetchUser(route.params.id); // лише один раз!
});
</script>
Рішення 1 - стежити за параметром:
watch(
() => route.params.id,
async (id) => { user.value = await fetchUser(id); },
{ immediate: true },
);
Рішення 2 - guard оновлення в компоненті:
onBeforeRouteUpdate(async (to) => {
user.value = await fetchUser(to.params.id);
});
Рішення 3 - примусово перестворювати компонент через key:
<RouterView :key="$route.fullPath" />
Просто, але дорого: при кожній зміні query перемонтовується вся сторінка, губиться її стан.
Параметри як props - компонент не залежить від роутера:
{ path: '/users/:id', component: UserProfile, props: true }
// перетворення: число замість рядка, плюс query
{
path: '/search',
component: SearchPage,
props: (route) => ({ query: route.query.q, page: Number(route.query.page ?? 1) }),
}
<script setup>
const props = defineProps({ id: String });
watch(() => props.id, loadUser, { immediate: true });
</script>
Переваги props:
- компонент можна використати поза роутером і легко тестувати - достатньо передати props;
- приведення типів і значення за замовчуванням в одному місці (функція
props).
Проблема «компонент не оновився» з props не зникає - стежити за зміною все одно треба, але через props.id.
На відміну від Vuex, у Pinia немає мутацій - стан можна змінювати кількома способами.
1. Пряме присвоєння - найпростіше:
const cart = useCartStore();
cart.coupon = 'SALE10';
cart.items.push(item);
2. $patch з об'єктом - кілька полів за раз:
cart.$patch({ coupon: 'SALE10', deliveryMethod: 'courier' });
3. $patch з функцією - для масивів і складних змін:
cart.$patch((state) => {
state.items = state.items.filter((i) => i.qty > 0);
state.updatedAt = Date.now();
});
Об'єктна форма для масивів незручна: щоб видалити елемент, довелося б передати весь новий масив. Функція також об'єднує кілька змін в один запис у devtools.
4. Дії - для змін з логікою, перевірками чи запитами до API. Правило: якщо зміна має бізнес-сенс («оформити замовлення»), вона - дія стору, а не набір присвоєнь у компоненті.
$reset() - повернути початковий стан:
- в option store вбудований: викликає
state()заново; - у setup store його немає - пишуть самі:
export const useFiltersStore = defineStore('filters', () => {
const initial = () => ({ city: null, salaryFrom: null });
const filters = ref(initial());
function $reset() {
filters.value = initial();
}
return { filters, $reset };
});
$subscribe - реагувати на будь-яку зміну стану:
cart.$subscribe((mutation, state) => {
localStorage.setItem('cart', JSON.stringify(state.items));
}, { detached: true });
- за замовчуванням підписка знімається разом із компонентом;
detached: true- лишити; flush: 'sync'- викликати після кожної зміни, а не раз після групи;- для дій є окремий
$onAction- до і після виконання, з обробкою помилок.
Замінити стан повністю - pinia.state.value = {...} (для SSR і тестів), а не cart.$state = ... у звичайному коді.
Vue Router підтримує кілька способів зберігати маршрут в URL.
createWebHistory() - звичайні адреси через History API:
https://example.com/users/7
- красиві URL, які індексують пошукові системи;
- потребує налаштування сервера. Перехід усередині застосунку обробляє JavaScript, але якщо користувач оновить сторінку чи відкриє посилання напряму, браузер запитає в сервера
/users/7. Сервер, який про такий маршрут не знає, поверне 404.
createWebHashHistory() - маршрут після #:
https://example.com/#/users/7
- частина після
#на сервер не надсилається - сервер завжди віддаєindex.html, нічого налаштовувати не треба; - погано для SEO і виглядає застаріло. Доречно для внутрішніх інструментів, статичних хостингів без налаштувань, Electron.
createMemoryHistory() - без URL узагалі: для SSR на сервері й тестів.
Налаштування сервера для createWebHistory - будь-який невідомий шлях віддає точку входу SPA:
location / {
try_files $uri $uri/ /index.html;
}
У Laravel, якщо SPA живе всередині застосунку:
// routes/web.php - останнім, після всіх інших маршрутів
Route::view('/app/{any?}', 'spa')->where('any', '.*');
Пастки:
- справжні 404 зникають: сервер на будь-яку адресу віддає 200 з SPA. Сторінку «не знайдено» робить роутер (
/:pathMatch(.*)*), але HTTP-статус лишається 200 - для SEO це «м'які 404»; - catch-all маршрут має бути останнім і не перехоплювати
/api/*, інакше запити до API отримають HTML; base- якщо застосунок не в корені домену:createWebHistory('/app/'), і той самий префікс у налаштуваннях збирача.
Плагін Pinia - функція, що викликається для кожного стору при його створенні й може додавати властивості, обгортати дії, підписуватися на зміни.
const pinia = createPinia();
pinia.use(({ store, options }) => {
// додати властивість усім сторам
store.createdAt = new Date();
// логувати всі дії
store.$onAction(({ name, args, after, onError }) => {
const start = performance.now();
after(() => console.debug(`${store.$id}.${name}`, performance.now() - start));
onError((error) => reportError(error, { store: store.$id, action: name }));
});
});
Плагін отримує store, pinia, app і options - опції, передані в defineStore. Через власні опції стор може «вмикати» поведінку плагіна.
Збереження стану між перезавантаженнями - мінімальний плагін:
pinia.use(({ store, options }) => {
if (!options.persist) return;
const key = `pinia:${store.$id}`;
const saved = localStorage.getItem(key);
if (saved) store.$patch(JSON.parse(saved));
store.$subscribe((_, state) => {
localStorage.setItem(key, JSON.stringify(state));
}, { detached: true });
});
export const useSettingsStore = defineStore('settings', {
state: () => ({ theme: 'light', perPage: 20 }),
persist: true,
});
На практиці частіше беруть готовий pinia-plugin-persistedstate - він уміє вибирати поля, сховище і серіалізацію.
Що враховувати при збереженні:
- не зберігати секрети й токени в
localStorage- їх прочитає будь-який скрипт на сторінці (XSS); - версіонування: після зміни структури стану старі збережені дані можуть зламати застосунок. Потрібна версія в ключі чи міграція;
- зберігати вибірково - фільтри й налаштування так, кешовані відповіді API - ні (застаріють);
- SSR:
localStorageна сервері немає - відновлення лише в браузері, інакше розбіжності при гідрації; - синхронізація вкладок - подія
storageдля оновлення стору, якщо інша вкладка змінила значення.
TypeScript: власні властивості й опції плагіна оголошуються розширенням модулів PiniaCustomProperties і DefineStoreOptionsBase.
useStore() усередині setup знаходить активний екземпляр Pinia через inject. Поза компонентом (у guard роутера, інтерсепторі HTTP-клієнта, звичайному модулі) інжекту немає, і Pinia покладається на «активний» екземпляр, встановлений app.use(pinia).
Типова помилка - виклик на рівні модуля:
// api.js
const auth = useAuthStore(); // виконується при імпорті - ДО app.use(pinia)
// Error: getActivePinia() was called but there was no active Pinia
Правильно - викликати всередині функції, яка виконається пізніше:
router.beforeEach((to) => {
const auth = useAuthStore(); // на момент навігації Pinia вже встановлена
if (to.meta.requiresAuth && !auth.isLoggedIn) return '/login';
});
http.interceptors.request.use((config) => {
const auth = useAuthStore();
if (auth.token) config.headers.Authorization = `Bearer ${auth.token}`;
return config;
});
Чому це критично в SSR. На сервері Node.js обслуговує багато запитів одним процесом. Якщо стор створюється на рівні модуля чи покладається на глобальний «активний» екземпляр, стан одного користувача може потрапити в запит іншого: кошик, профіль, токен.
Правила для SSR:
- новий екземпляр Pinia (і роутера, і застосунку) на кожен запит - у фабричній функції
createApp(); - поза
setupпередаватиpiniaявно:
router.beforeEach((to) => {
const auth = useAuthStore(pinia); // саме той екземпляр, що належить цьому запиту
});
- жодного стану на рівні модуля (
let currentUserв імпортованому файлі) - він спільний для всіх запитів процесу.
Ще одна пастка - стори, що використовують один одного. Виклик useOtherStore() у тілі setup store - нормально, але циклічне читання стану двох сторів одне з одного під час створення призводить до помилок. Доступ до іншого стору краще робити в гетерах і діях, а не на верхньому рівні.
Тестування: у юніт-тестах сторів перед кожним тестом - setActivePinia(createPinia()), щоб тести не ділили стан.
Докладніше в документації: Pinia: використання поза компонентами
При серверному рендерингу сервер заповнює стори (завантажує дані), рендерить HTML і має передати цей стан у браузер. Інакше на клієнті стори стартують порожніми, Vue перемалює сторінку іншими даними, і гідрація зламається.
Схема:
- на сервері після рендеру взяти весь стан:
pinia.state.value; - серіалізувати з екрануванням і вставити в HTML;
- на клієнті до першого
useStore()відновити його.
// сервер
import { uneval } from 'devalue';
const html = await renderToString(app);
const state = uneval(pinia.state.value);
// <script>window.__pinia = ${state}</script>
// клієнт - перед монтуванням і до будь-якого useStore()
const pinia = createPinia();
app.use(pinia);
if (window.__pinia) {
pinia.state.value = window.__pinia;
}
Чому JSON.stringify - діра. Стан майже завжди містить дані, які вводили користувачі (ім'я, коментар, назва товару). Рядок </script><script>stealCookies()</script> у полі name, вставлений через JSON.stringify у <script>, закриє тег і виконає чужий код. Документація Pinia прямо наголошує: стан треба екранувати, наприклад бібліотекою devalue (її використовує Nuxt), яка ще й зберігає Date, Map, Set.
Що не повинно гідруватися. У setup store можуть бути значення, що мають братися лише з браузера (наприклад, useLocalStorage). Їх позначають skipHydrate(), щоб серверне значення не затерло клієнтське.
Інші правила SSR зі сторами:
- новий екземпляр Pinia на кожен запит - інакше стан одного користувача потрапить у відповідь іншому;
- поза
setup(guard роутера,serverPrefetch) передавати екземпляр явно:useStore(pinia); - не класти в стан секрети: усе, що в сторі на сервері, буде у HTML сторінки;
- браузерні API (
window,localStorage) у сторах - лише в діях, що викликаються в браузері, чи з перевіркою середовища.
У Nuxt усе це зроблено модулем @pinia/nuxt - вручну налаштовувати не треба.
Тестувати сам стор просто: створити свіжий екземпляр Pinia і викликати дії.
import { setActivePinia, createPinia } from 'pinia';
beforeEach(() => setActivePinia(createPinia()));
test('додає товар у кошик', () => {
const cart = useCartStore();
cart.add({ id: 1, price: 100 });
expect(cart.total).toBe(100);
});
Тестувати компонент зі стором складніше: справжні дії ходять в API, а стан треба підготувати. Для цього пакет @pinia/testing:
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
test('кнопка оформлення викликає checkout', async () => {
const wrapper = mount(CartSummary, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: {
cart: { items: [{ id: 1, price: 100, qty: 2 }] },
},
}),
],
},
});
const cart = useCartStore();
await wrapper.get('button').trigger('click');
expect(cart.checkout).toHaveBeenCalledTimes(1);
expect(wrapper.text()).toContain('200');
});
Як це працює:
- дії замінені шпигунами (
stubActionsза замовчуванням увімкнено):checkoutне виконується, але можна перевірити, що її викликали й з якими аргументами.stubActions: false- дії виконуються по-справжньому, але все одно відстежуються; initialState- стан за назвою стору (id зdefineStore), без дій для його підготовки;createSpy: vi.fn- потрібен, якщо у Vitest не ввімкненоglobals: true;- гетери можна перевизначити в тесті присвоєнням (
cart.total = 999), аundefinedповертає звичайне обчислення.
Що варто пам'ятати:
useCartStore()у тесті треба викликати після монтування з тестовою Pinia - тоді повертається той самий екземпляр, що бачить компонент;- стан змінюється напряму (
cart.items = []) - і компонент реагує, бо реактивність справжня; - окремі тести для дій сторів з реальною логікою й замоканим HTTP-клієнтом - компонентні тести зі шпигунами не перевіряють, що дія робить правильні речі.
TypeScript: шпигуни мають тип звичайних функцій; для доступу до mock.calls - vi.mocked(cart.checkout).