Питання на співбесіді з Vue
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
108 питань
Кожен computed, watch і watchEffect, створений у setup компонента, Vue автоматично прив'язує до компонента і зупиняє, коли компонент знищується. Поза компонентом такої прив'язки немає - ефекти живуть, доки їх не зупинити вручну.
effectScope збирає всі ефекти, створені всередині, в одну групу, яку можна зупинити одним викликом:
import { effectScope, ref, watch, computed } from 'vue';
const scope = effectScope();
scope.run(() => {
const query = ref('');
const results = computed(() => search(query.value));
watch(query, (value) => saveToHistory(value));
watchEffect(() => console.log(results.value.length));
});
// пізніше - зупинити все разом
scope.stop();
Де це потрібно:
1. Спільний стан composable для кількох компонентів («спільний composable»): ефекти мають жити, поки є хоча б один споживач, і зупинитися, коли зник останній:
function createSharedComposable(factory) {
let consumers = 0;
let state, scope;
return () => {
consumers++;
if (!state) {
scope = effectScope(true); // detached: не прив'язаний до поточного компонента
state = scope.run(factory);
}
onScopeDispose(() => {
if (--consumers === 0) {
scope.stop();
state = scope = undefined;
}
});
return state;
};
}
Саме так влаштований createSharedComposable у VueUse.
2. Бібліотеки й сховища. Pinia створює effectScope для кожного стора - тому $dispose() прибирає всі його спостерігачі.
3. Тести - ізолювати й гарантовано прибрати ефекти між тестами.
4. Тимчасові ефекти - наприклад, на час відкритого модального вікна чи активного режиму редагування.
onScopeDispose(fn) - реєструє очищення для поточної області: в компоненті спрацює при його знищенні, в effectScope - при stop(). Тому composable, що використовує onScopeDispose замість onUnmounted, працює і в компонентах, і поза ними.
getCurrentScope() - дізнатися, чи код виконується всередині області (наприклад, щоб composable попередив, що його викликали поза компонентом).
Без цього composable, викликаний поза setup (у сторі, в роутері, в модулі), створює спостерігачів, які ніколи не зупиняться, - витоки пам'яті в довгоживучих SPA.
customRef дає змогу створити ref з власною логікою відстеження й сповіщення про зміни. Фабрика отримує дві функції:
track()- зареєструвати залежність (викликати при читанні);trigger()- сповістити залежних про зміну (викликати, коли значення справді змінилося).
Класичний приклад - ref із затримкою (debounce):
import { customRef } from 'vue';
export function useDebouncedRef(initial, delay = 300) {
let value = initial;
let timeout;
return customRef((track, trigger) => ({
get() {
track();
return value;
},
set(newValue) {
clearTimeout(timeout);
timeout = setTimeout(() => {
value = newValue;
trigger();
}, delay);
},
}));
}
<script setup>
const query = useDebouncedRef('', 400);
watch(query, (value) => search(value)); // запит лише після паузи у введенні
</script>
<template>
<input v-model="query">
</template>
Поле оновлюється в шаблоні... тільки після затримки - бо значення змінюється в set із запізненням. Для пошуку це прийнятно, а для звичайного введення - ні: користувач не бачитиме набраного тексту. Тому частіше роблять інакше: звичайний ref для поля і окремий debounced-ref (чи watch з затримкою) для запиту.
Інші застосування customRef:
- ref, синхронізований з
localStorage- читання з кешу, запис у сховище; - ref над зовнішнім джерелом (значення з бібліотеки, що має власні події змін) -
trigger()з обробника події бібліотеки; - валідація чи нормалізація при записі (обрізати пробіли, обмежити діапазон);
- лінива ініціалізація дорогого значення при першому читанні.
Що варто знати:
getмає повертати однакове значення для однакового стану - Vue може викликати його багато разів;- не забувати
track()- без нього шаблон не оновиться, хочаtrigger()викликано; - документація окремо застерігає від
get, що щоразу створює новий об'єкт: якщо такий ref передано в props, кожен рендер батька дає дочірньому компоненту «нове» значення, і той перерендерюється без потреби; - готові варіанти вже є у VueUse:
refDebounced,useLocalStorage,refThrottled.
reactive() повертає проксі над оригінальним об'єктом, а не сам об'єкт:
const raw = { a: 1 };
const state = reactive(raw);
state === raw; // false
toRaw(state) === raw; // true
reactive(raw) === state; // true - для одного оригіналу Vue повертає той самий проксі
Зміни через проксі змінюють оригінал (і запускають оновлення), а зміни оригіналу напряму - не запускають нічого.
Де це вилазить:
- порівняння й пошук:
items.value.indexOf(obj)чиSet.has(obj)з оригінальним об'єктом, коли в масиві лежать проксі (або навпаки), дають «не знайдено». Vue перехоплюєindexOf/includesу реактивних масивах, щоб пошук сирого об'єкта працював, але вMap,Setі власному коді порівняння за посиланням легко помилитися; - передача в сторонні бібліотеки: бібліотека отримує проксі. Бібліотеки, що порівнюють об'єкти за посиланням, змінюють їх інтенсивно чи покладаються на приватні поля (
#field), можуть працювати неправильно або повільно; - класи з приватними полями: метод, викликаний через проксі, отримує
this= проксі, а в проксі немає#field-TypeError; structuredClone(state)падає на проксі - потрібноstructuredClone(toRaw(state)).
toRaw(proxy) - отримати оригінал. Корисно для:
- передачі даних у сторонні бібліотеки,
postMessage,IndexedDB,structuredClone; - тимчасового читання/запису без відстеження й оновлень (масові зміни, після яких одне оновлення).
markRaw(obj) - позначити об'єкт як ніколи не реактивний: Vue не обгортатиме його в проксі навіть усередині реактивного стану.
const map = markRaw(new mapboxgl.Map({ container: 'map' }));
state.map = map; // лишається звичайним об'єктом
Застосовують для екземплярів сторонніх бібліотек (карти, редактори, графіки), складних класів, великих незмінних структур.
shallowRef / shallowReactive - реактивність лише на верхньому рівні: заміна значення відстежується, зміни всередині - ні. Добре для великих даних, що замінюються цілком.
Застереження: toRaw і markRaw - інструменти для конкретних проблем. Змішування сирих і реактивних версій того самого об'єкта в коді - джерело помилок «чому не оновилося». Документація Vue радить тримати стабільну межу: або завжди проксі, або завжди сирий.
provide/inject передає значення від предка до будь-якого нащадка в його піддереві без проміжних props:
// Form.vue
provide('form', { errors, register });
// глибоко вкладений FormField.vue
const form = inject('form');
Pinia - глобальний стор: стан, геттери й дії в окремому модулі, доступні з будь-якого компонента застосунку.
provide/inject доречний, коли:
- стан належить конкретному піддереву, а не всьому застосунку: форма і її поля, таблиця і її колонки, вкладки і їхні панелі;
- таких піддерев може бути кілька одночасно - у кожної форми свій стан, а глобальний стор для цього незручний;
- це бібліотека компонентів, яка не повинна залежати від стору застосунку.
Pinia доречна, коли:
- стан справді глобальний: користувач, кошик, налаштування, кеш даних;
- потрібні DevTools (історія змін, інспекція стану), SSR-гідратація, плагіни (збереження в
localStorage); - з даними працюють непов'язані частини інтерфейсу.
Практичні поради:
- Ключі
provide- черезSymbolзInjectionKey<T>, а не рядки: без колізій і з типізацією. - Передавайте через
provideреактивні значення (ref,readonly(ref)) і функції для змін - так нащадки не змінюють стан предка напряму. - Не все треба класти в стор. Стан, що використовує один компонент, лишається в ньому. Дані з сервера часто зручніше тримати в бібліотеці запитів (TanStack Query), а не в Pinia.
Коли список змінюється, Vue порівнює старий і новий віртуальний DOM і намагається перевикористати наявні елементи. key каже, який новий елемент відповідає якому старому.
<TodoItem v-for="todo in todos" :key="todo.id" :todo="todo" />
Без key або з індексом Vue зіставляє елементи за позицією. Поки список лише дописують у кінець, усе гаразд. Але після видалення, вставки на початок чи сортування:
- елемент на позиції 0 тепер показує інші дані, але його внутрішній стан лишився від попереднього: введений в
<input>текст, розгорнутий блок, локальний стан дочірнього компонента, фокус; - анімації
<TransitionGroup>рухають не ті елементи; - Vue оновлює вміст кожного зсунутого елемента замість того, щоб просто перемістити один.
// Видалили перший елемент: індекси зсунулися,
// і стан TodoItem[0] тепер «належить» колишньому другому
todos.value.splice(0, 1);
Хороший key - стабільний і унікальний серед сусідів: id з бази, стабільний ідентифікатор. Погані: індекс (для списків, що змінюються), Math.random() (новий щоразу - Vue перестворюватиме всі елементи на кожен рендер).
Індекс допустимий, коли список статичний або елементи не мають власного стану й не змінюють порядок.
Корисний прийом: зміна key на одному компоненті примусово його перестворює - простий спосіб «скинути» компонент: <UserForm :key="userId" />.
Шаблони Vue компілюються в рендер-функції - функції, що повертають віртуальні вузли (VNode). Ці функції можна писати й вручну.
import { h, ref } from 'vue';
export default {
props: { level: { type: Number, default: 2 } },
setup(props, { slots }) {
return () => h(`h${props.level}`, { class: 'heading' }, slots.default?.());
},
};
h(тег або компонент, props/атрибути, діти). З плагіном @vitejs/plugin-vue-jsx те саме можна писати як JSX:
setup(props, { slots }) {
const Tag = `h${props.level}`;
return () => <Tag class="heading">{slots.default?.()}</Tag>;
}
Коли рендер-функції справді потрібні:
- дуже динамічна структура, яку незручно виражати директивами: тег обирається програмно, дерево будується з конфігурації (конструктор форм за JSON-схемою, рендер Markdown-AST у компоненти);
- компоненти-«обгортки», що маніпулюють слотами: переставити, обгорнути кожен дочірній вузол, вставити роздільники між елементами списку;
- функціональні компоненти без стану - проста функція
(props, { slots }) => h(...); - бібліотеки компонентів, де потрібен повний контроль над VNode.
Чому для звичайних компонентів кращі шаблони:
- оптимізації компілятора. Компілятор шаблонів знає, які частини статичні, а які динамічні: піднімає статичні вузли, позначає динамічні прапорцями (patch flags), будує «блоки» з плоским списком динамічних вузлів. Під час оновлення Vue пропускає все статичне. Рукописна рендер-функція цих підказок не має - порівнюється все дерево;
- читабельність для команди й дизайнерів, близькість до HTML;
- інструменти: підсвітка, перевірка типів у шаблонах (Volar), форматування.
Пастки рендер-функцій:
- VNode не можна використовувати двічі в одному дереві - для повторення треба створювати нові;
v-model,v-if,v-forу JSX - це звичайний JavaScript (тернарні оператори,map), аv-modelпотребує ручногоmodelValue+onUpdate:modelValue;- слоти передаються об'єктом функцій:
h(Comp, null, { default: () => ..., header: () => ... }).
Практичне правило: шаблони - за замовчуванням, рендер-функції - точково там, де шаблон стає незручним.
Помилка в рендері, хуку життєвого циклу, спостерігачі чи обробнику події одного компонента без обробки може зламати весь застосунок - користувач побачить «завмерлий» чи порожній екран.
Глобальний обробник - останній рубіж, місце для відправки в моніторинг:
const app = createApp(App);
app.config.errorHandler = (error, instance, info) => {
// info - де сталася помилка: 'render function', 'mounted hook', 'watcher callback'...
Sentry.captureException(error, { extra: { info } });
};
Він ловить помилки з рендеру, хуків, спостерігачів, обробників подій шаблону, setup, provide/inject - тобто з коду, який викликає Vue. Помилки з setTimeout, промісів без await і сторонніх колбеків він не бачить - для них window.onerror і unhandledrejection.
onErrorCaptured - перехоплення помилок нащадків у компоненті-предку. Основа для «межі помилок» (error boundary), як у React:
<!-- ErrorBoundary.vue -->
<script setup>
import { ref, onErrorCaptured } from 'vue';
const error = ref(null);
onErrorCaptured((err, instance, info) => {
error.value = err;
report(err, info);
return false; // не передавати помилку вище
});
</script>
<template>
<div v-if="error" class="widget-error">
Блок не завантажився. <button @click="error = null">Спробувати ще</button>
</div>
<slot v-else />
</template>
<ErrorBoundary>
<RevenueChart />
</ErrorBoundary>
Зламаний графік показує запасний вигляд, решта дашборду працює.
Правила поширення:
- помилка йде вгору ланцюжком батьків, викликаючи кожен
onErrorCaptured; return falseзупиняє поширення - вище й доapp.config.errorHandlerвона не дійде;- якщо сам
onErrorCapturedкидає помилку, вона теж іде до глобального обробника.
Пастки:
- рендер запасного вигляду не повинен знову рендерити зламаний компонент - інакше нескінченний цикл помилок. Тому
v-if/v-else, а не показ поверх; - асинхронні помилки в
setupпісляawaitчи в звичайних промісах можуть не дійти доonErrorCaptured- async-код варто обробляти явно (try/catch); app.config.warnHandler- окремо для попереджень (лише в режимі розробки).
Плагін - об'єкт з методом install(app, options) (або сама функція), який додає щось на рівні всього застосунку. Підключається через app.use():
// plugins/i18n.js
export default {
install(app, options) {
const translate = (key) =>
key.split('.').reduce((obj, part) => obj?.[part], options.messages) ?? key;
app.provide('i18n', { translate }); // для Composition API
app.config.globalProperties.$t = translate; // для шаблонів і Options API
app.directive('t', (el, binding) => (el.textContent = translate(binding.value)));
app.component('LocaleSwitcher', LocaleSwitcher);
},
};
// main.js
app.use(i18n, { messages: uk });
Що плагін може зареєструвати:
- глобальні компоненти (
app.component) і директиви (app.directive); - значення для
inject(app.provide) - сервіси, конфігурацію, клієнт API; - глобальні властивості (
app.config.globalProperties) - доступні в шаблонах як$t,$route; - глобальні обробники (
app.config.errorHandler), міксини (застарілий підхід).
Так влаштовані Vue Router (app.use(router)), Pinia, бібліотеки UI, i18n.
Як споживачам отримати доступ у <script setup>: не через globalProperties (вони доступні лише в шаблоні й this Options API), а через inject - найкраще обгорнути в composable:
export const I18nKey = Symbol('i18n');
export const useI18n = () => inject(I18nKey);
Символ як ключ - замість рядка, щоб плагіни не перезаписали значення одне одного. З TypeScript - InjectionKey<T> для типізації.
Типізація globalProperties - доповнення модуля:
declare module 'vue' {
interface ComponentCustomProperties {
$t: (key: string) => string;
}
}
Що варто враховувати:
- повторне
app.use()того самого плагіна Vue ігнорує; - SSR: застосунок створюється на кожен запит - плагін не повинен тримати стан на рівні модуля, інакше дані одного користувача «протечуть» до іншого. Стан - усередині
install(на екземпляр застосунку); - порядок має значення: плагін, що використовує роутер, підключається після роутера;
- глобальні компоненти й директиви з плагіна потрапляють у збірку повністю - для великих бібліотек краще імпорт окремих компонентів.
Власні директиви - для повторного використання низькорівневої роботи з DOM на звичайних елементах: фокус, перехоплення кліків поза елементом, підказки, виділення тексту, ліниве завантаження.
У <script setup> будь-яка змінна з назвою vЩось стає директивою v-щось:
<script setup>
const vFocus = {
mounted: (el) => el.focus(),
};
</script>
<template>
<input v-focus>
</template>
Повний набір хуків (усі необов'язкові):
const vClickOutside = {
mounted(el, binding) {
el._onClick = (event) => {
if (!el.contains(event.target)) binding.value(event);
};
document.addEventListener('click', el._onClick);
},
unmounted(el) {
document.removeEventListener('click', el._onClick);
},
};
<div v-click-outside="closeMenu">...</div>
Хуки: created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount, unmounted. Аргумент binding містить value, oldValue, arg (v-tooltip:top → 'top') і modifiers (v-tooltip.delay → { delay: true }).
Скорочена форма - функція замість об'єкта: викликається в mounted і updated.
Глобальна реєстрація - app.directive('focus', vFocus).
Коли директива - правильний вибір, а коли ні:
- так: логіка прив'язана до конкретного DOM-елемента й не має власного шаблону чи складного стану;
- ні - краще компонент: є розмітка (підказка з вмістом, випадне меню);
- ні - краще composable: логіка зі станом, яку зручно використати в
setup(useClickOutside(target, handler),useIntersectionObserver). Composable краще типізується й тестується; - ні - краще вбудовані директиви: документація радить декларативні
v-bind,v-showтам, де можливо, - вони ефективніші й дружні до SSR.
Пастки:
- на компонентах директиви не рекомендовані: застосовуються до кореневого елемента, а на компоненті з кількома коренями ігноруються з попередженням (на відміну від атрибутів, їх не передати через
$attrs); - аргументи хуків - лише для читання, крім
el. Спільні дані між хуками - черезel.datasetчиWeakMap, а не довільні властивості на елементі (якel._onClickу прикладі - працює, але це компроміс); - прибирання в
unmountedобов'язкове для глобальних слухачів і спостерігачів; - SSR: хуки директив на сервері не викликаються (крім
getSSRPropsдля атрибутів).
За замовчуванням Vue екранує дані: інтерполяція {{ }} і прив'язки атрибутів вставляють значення як текст, а не як HTML.
<p>{{ comment.body }}</p> <!-- <script> покажеться як текст -->
<a :title="comment.author">...</a> <!-- значення атрибута екрановано -->
Де захист закінчується:
1. v-html вставляє рядок як HTML:
<div v-html="comment.body"></div> <!-- XSS, якщо body від користувача -->
Для контенту від користувачів - лише після санітизації (DOMPurify на клієнті чи HTML Purifier на сервері), а краще - зберігати Markdown і рендерити безпечним рендерером з білим списком тегів.
2. URL у атрибутах: екранування не захищає від схеми javascript::
<a :href="user.website">Сайт</a> <!-- javascript:alert(1) виконається при кліку -->
Перевіряйте протокол (http:/https:) перед виведенням посилань з даних користувача.
3. :style з даних користувача - можливі атаки через CSS (підміна інтерфейсу, витік через url()).
4. Шаблон з даних користувача. Найнебезпечніший випадок - компіляція рядка, що містить ввід користувача, як шаблону Vue:
createApp({ template: `<div>${userInput}</div>` }); // виконання довільних виразів
Шаблон Vue - це код. Будь-що, що компілюється як шаблон, має повністю контролюватися розробником.
5. Серверний рендер + Vue в браузері (Blade, Laravel). Якщо Vue монтується на розмітку, яку сервер згенерував з даними користувача (in-DOM шаблон), то {{ }} у даних користувача Vue виконає як вираз. Користувач пише коментар {{ constructor.constructor('alert(1)')() }} - Blade екранує HTML, але не фігурні дужки, і Vue їх обчислить. Захист: не монтувати Vue на області з користувацьким вмістом, позначати такі ділянки v-pre, передавати дані через props/JSON, а не через розмітку.
Вирази шаблонів у «пісочниці»: у шаблоні доступний лише обмежений набір глобальних об'єктів (Math, Date тощо), а не window. Але це не межа безпеки - через ланцюжок конструкторів можна дістатися до Function, тому вимога «шаблони лише від розробника» лишається.
Що ще: Content Security Policy знижує наслідки XSS. Повна збірка Vue з компілятором шаблонів у браузері потребує unsafe-eval; збірка лише з runtime (шаблони скомпільовані Vite) - ні.
Три етапи роботи Vue:
- компіляція - шаблон перетворюється на рендер-функцію (зазвичай під час збирання, через Vite і
@vitejs/plugin-vue); - монтування - рендер-функція повертає дерево віртуальних вузлів (VNode), з якого створюється DOM. Під час виконання реактивна система запам'ятовує, які дані читалися;
- оновлення (patch) - змінилися дані - рендер-функція виконується знову, нове віртуальне дерево порівнюється зі старим, і в DOM застосовуються лише відмінності.
Чому шаблон - це не просто «зручний синтаксис». Компілятор бачить структуру шаблону цілком і додає підказки, яких немає в рукописній рендер-функції. Vue називає це «віртуальний DOM з підказками компілятора»:
- піднімання статичного (static hoisting): вузли без динамічних прив'язок створюються один раз і використовуються в усіх рендерах. Великі статичні фрагменти можуть бути перетворені на рядок HTML;
- прапорці оновлення (patch flags): на кожен динамічний вузол компілятор записує, що саме в ньому може змінитися - лише текст, лише
class, лише конкретні props. При оновленні Vue перевіряє тільки це; - блоки й «сплощення дерева» (tree flattening): кожен блок зберігає плоский список своїх динамічних нащадків. При оновленні Vue проходить лише цей список, а не все дерево. Межі блоків - структурні директиви (
v-if,v-for), де форма дерева може змінитися.
Результат: вартість оновлення залежить від кількості динамічних частин, а не від розміру шаблону.
Можна подивитися самому: Vue Template Explorer показує, у що компілюється шаблон, з підсвіченими прапорцями.
Дві збірки Vue:
- runtime-only - без компілятора (менша й без
eval). Підходить, коли всі шаблони -.vue-файли, скомпільовані Vite; - повна збірка (
vue/dist/vue.esm-bundler.jsз компілятором) - потрібна, якщо шаблон компілюється в браузері: опціяtemplateу компоненті чи in-DOM шаблон (Vue монтується на розмітку, згенеровану Blade). Вона більша й потребуєunsafe-evalу CSP.
Практичні висновки:
- шаблони ефективніші за рукописні рендер-функції для звичайних компонентів;
- розділення статичного й динамічного має сенс: великий шаблон з кількома динамічними місцями оновлюється дешево;
v-onceіv-memo- ручні підказки для випадків, де компілятор не може знати, що частина не змінюється.
Vue можна використовувати не лише як SPA, а й «острівцями» на серверних сторінках Laravel: змонтувати компонент на елемент Blade-шаблону. Тут є кілька пасток.
1. Конфлікт фігурних дужок. Blade і Vue обидва використовують {{ }}. Blade обробить їх першим і спробує виконати PHP:
<div id="app">
@{{ message }} {{-- @ - Blade лишить {{ message }} для Vue --}}
@verbatim
<p>{{ user.name }}</p> {{-- цілий блок без обробки Blade --}}
@endverbatim
</div>
2. Шаблони в HTML (in-DOM) розбирає браузер, а не компілятор Vue:
- регістр не враховується:
<BlogPost :postTitle="t">браузер перетворить на<blogpost :posttitle="t">. У HTML-шаблонах - лише kebab-case:<blog-post :post-title="t">; - самозакривних тегів немає:
<my-component />браузер прочитає як відкритий тег, і наступні елементи стануть його дітьми. Потрібно<my-component></my-component>; - обмеження вкладеності: у
<table>,<ul>,<select>браузер викидає «чужі» теги. Компонент-рядок таблиці - через<tr is="vue:table-row">.
У .vue-файлах цих обмежень немає - їх компілює Vite.
3. Потрібна повна збірка Vue з компілятором шаблонів, якщо шаблон береться з DOM сторінки: псевдонім vue → vue/dist/vue.esm-bundler.js. Збірка більша і вимагає unsafe-eval у Content Security Policy.
4. Безпека - найважливіше. Якщо в області, на яку змонтовано Vue, є дані користувача, виведені Blade, Vue скомпілює їх як частину шаблону. Коментар із текстом {{ ... }} стане виразом Vue - Blade екранує HTML, але не фігурні дужки. Захист:
- не монтувати Vue на області з користувацьким вмістом;
- позначати такі ділянки
v-pre(Vue не компілюватиме вміст); - передавати дані в компоненти через props з JSON, а не вбудовувати в розмітку.
5. Передача даних з Laravel у компонент:
<user-profile :user='@json($user)'></user-profile>
{{-- або --}}
<script>window.App = @js(['user' => $user->only('id', 'name')]);</script>
@json/Js::from безпечно екранують значення. Передавайте лише потрібні поля - усе, що в JSON, видно в коді сторінки.
Альтернативи «острівцям»: Inertia (повноцінні Vue-сторінки з маршрутизацією Laravel) або Livewire з Alpine, якщо інтерактивність помірна.
Плагін 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 - вручну налаштовувати не треба.
Питання з реальних технічних співбесід - 108 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії