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

Senior: питання на співбесіді з теми «Vue Router і Pinia»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

Плагін 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: тестування