Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: 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.

Докладніше в документації: Vue Router: динамічні маршрути

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: визначення стору

Стор 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 у компонентах: деструктуризація реактивного об'єкта завжди «знімає копію».

Докладніше в документації: Pinia: деструктуризація стору

Якщо всі сторінки імпортовані статично, збирач кладе їх в один бандл: користувач, що відкрив головну, завантажує й код адмінки, звітів і налаштувань.

Ліниве завантаження - замість компонента передається функція, що його імпортує:

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 лінивими робляться автоматично.

Докладніше в документації: Vue Router: ліниве завантаження

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.

Докладніше в документації: Vue Router: навігаційні guards

При переході між маршрутами з тим самим компонентом (/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.

Докладніше в документації: Vue Router: параметри як props

На відміну від 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 = ... у звичайному коді.

Докладніше в документації: Pinia: стан

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/'), і той самий префікс у налаштуваннях збирача.

Докладніше в документації: Vue Router: режими історії

Плагін 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.

Докладніше в документації: Pinia: плагіни

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 перемалює сторінку іншими даними, і гідрація зламається.

Схема:

  1. на сервері після рендеру взяти весь стан: pinia.state.value;
  2. серіалізувати з екрануванням і вставити в HTML;
  3. на клієнті до першого 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: SSR

Тестувати сам стор просто: створити свіжий екземпляр 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).

Докладніше в документації: Pinia: тестування