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.
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).