Питання на співбесіді з Vue
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
108 питань
Тестувати сам стор просто: створити свіжий екземпляр 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).
Сторінка з важкою статистикою віддається повільно: сервер не відправить відповідь, доки не порахує все. Deferred props дають змогу показати сторінку одразу, а дорогі дані довантажити окремим запитом.
return Inertia::render('Dashboard', [
'user' => $request->user()->only('id', 'name'),
'orders' => Inertia::defer(fn () => Order::recentFor($request->user())),
'stats' => Inertia::defer(fn () => Stats::heavy(), 'analytics'),
'chart' => Inertia::defer(fn () => Stats::chart(), 'analytics'),
]);
<script setup>
import { Deferred } from '@inertiajs/vue3';
defineProps({ orders: Array, stats: Object, chart: Object });
</script>
<template>
<Deferred data="orders">
<template #fallback><OrdersSkeleton /></template>
<OrdersTable :orders="orders" />
</Deferred>
<Deferred :data="['stats', 'chart']" #default="{ reloading }">
<template #fallback><div class="h-64 animate-pulse" /></template>
<Stats :class="{ 'opacity-50': reloading }" :stats="stats" :chart="chart" />
</Deferred>
</template>
Групи: за замовчуванням усі відкладені props довантажуються одним запитом після рендеру. Другий аргумент ('analytics') - назва групи: різні групи йдуть паралельними запитами, щоб повільна статистика не затримувала замовлення.
Помилки: Inertia::defer(fn () => ..., rescue: true) - виняток не ламає відповідь, prop пропускається, а помилка йде в звіт; у <Deferred> для цього слот #rescue з кнопкою повтору.
Once props - для даних, що рідко змінюються (тарифи, країни, курси валют):
'plans' => Inertia::once(fn () => Plan::all()),
'rates' => Inertia::once(fn () => ExchangeRate::all())->until(now()->addDay()),
Клієнт запам'ятовує значення, а наступні відповіді сторінок з цим prop його не обчислюють і не передають. fresh() примушує оновити, as('roles') дає спільний ключ для різних сторінок. Поєднується з відкладеними: Inertia::defer(...)->once().
Що враховувати:
- відкладене - не для SEO-важливого вмісту: у початковому HTML його немає;
- кожна група - окремий запит з повним стеком middleware;
- once props забуваються при переході на сторінку без них - і на наступній сторінці обчислюються знову;
- заглушка (
fallback) з тим самим розміром, що й вміст, - щоб сторінка не «стрибала».
Inertia за замовчуванням замінює prop новим значенням. Для «Завантажити ще» потрібно дописувати нові елементи до вже показаних - для цього є злиття props.
public function index(Request $request)
{
return Inertia::render('Feed', [
'posts' => Inertia::merge(
Post::query()->latest()->paginate(15)
)->append('data', matchOn: 'id'),
]);
}
<script setup>
import { router } from '@inertiajs/vue3';
const props = defineProps({ posts: Object });
function loadMore() {
router.reload({
only: ['posts'],
data: { page: props.posts.current_page + 1 },
preserveUrl: true,
});
}
</script>
<template>
<PostCard v-for="post in posts.data" :key="post.id" :post="post" />
<button v-if="posts.next_page_url" @click="loadMore">Завантажити ще</button>
</template>
Як працює злиття:
- лише при часткових перезавантаженнях. Звичайний візит на сторінку завжди замінює prop повністю;
Inertia::merge($items)- дописати в кінець масиву на верхньому рівні;->prepend()- на початок (нові повідомлення чату);->append('data')- для пагінатора: дописати лише масивdata, аcurrent_page,next_page_url- замінити;matchOn: 'id'- якщо елемент з таким id вже є, він оновлюється, а не дублюється. Важливо, коли за час прокрутки на початок стрічки додалися нові записи й «сторінки» зсунулися;Inertia::deepMerge()- глибоке злиття всієї структури.
Нескінченна прокрутка - готове рішення на тому самому механізмі: Inertia::scroll(fn () => Post::paginate()) на сервері (сам налаштовує злиття й метадані пагінації, працює з paginate, simplePaginate і cursorPaginate) і компонент <InfiniteScroll> на клієнті, що довантажує сторінки при прокрутці.
Пастки:
- пагінація через OFFSET зсувається, якщо записи додаються: сторінка 2 повторить останній елемент сторінки 1.
matchOnприбирає дублікати, але курсорна пагінація (cursorPaginate) надійніша; - пам'ять: після сотні сторінок масив з тисяч елементів живе в props і в стані історії браузера;
- повернення «Назад» відновлює весь довантажений список з історії - добре для користувача, але великий стан історії браузери можуть обмежувати;
preserveUrl- щоб адреса не ставала?page=7, а оновлення сторінки не відкривало сьому сторінку без перших шести.
Inertia не змінює модель безпеки Laravel - маршрути, middleware, політики працюють як завжди. Ризики з'являються там, де дані йдуть у браузер.
1. Усі props публічні. Кожен prop і всі спільні дані - у JSON сторінки, який видно у вихідному коді й вкладці Network. Типові витоки:
'user' => $request->user()у спільних даних - усі атрибути моделі на кожній сторінці;- колекції моделей цілком (
User::all()) - email, телефони, службові поля всіх користувачів; - «прихований» prop, який шаблон просто не показує.
Ліки - явні поля (only(), get([...])), API Resources, перевірка JSON сторінок у тестах.
2. Авторизація лише на сервері. v-if="can.delete" ховає кнопку, але запит DELETE /posts/5 можна відправити й без неї. Кожна дія контролера перевіряє права ($this->authorize(), політики); у props передаються лише булеві прапорці для інтерфейсу.
3. Історія браузера. Inertia зберігає props сторінок у стані історії, щоб «Назад» працював миттєво. Після виходу з облікового запису інша людина за тим самим комп'ютером натисне «Назад» - і побачить сторінки з персональними даними з історії, без запиту до сервера.
Шифрування історії:
// глобально: config/inertia.php - history.encrypt => true
// або для окремих маршрутів
Route::middleware('inertia::encrypt')->group(function () { /* ... */ });
// при виході - зробити старі записи нечитабельними
public function logout(Request $request)
{
Auth::logout();
Inertia::clearHistory();
return redirect('/');
}
Inertia шифрує стан сторінки ключем у sessionStorage (Web Crypto API). clearHistory() змінює ключ - старі записи розшифрувати неможливо, і Inertia запитує сторінку з сервера, де вже діє перевірка автентифікації. Потребує HTTPS.
4. CSRF. Inertia покладається на cookie XSRF-TOKEN Laravel; помилку 419 (сесія завершилася) варто обробити - перезавантажити сторінку чи показати повідомлення.
5. XSS. Vue екранує {{ }}, але v-html з даними користувача - діра, як і в будь-якому Vue-застосунку. При SSR дані сторінки вбудовуються в HTML - адаптер робить це безпечно (<script type="application/json"> у v3), власні вставки треба екранувати так само.
Без SSR перше завантаження Inertia-сторінки - це майже порожній HTML з даними в JSON, а вміст з'являється після завантаження й виконання JavaScript. Пошукові системи й соцмережі (прев'ю посилань) бачать порожню сторінку, а користувач - білий екран довше.
SSR рендерить Vue-компонент сторінки на сервері: у HTML одразу є вміст, а в браузері Vue гідрує його - підключається до готової розмітки.
Як це влаштовано: рендеринг виконує Node.js-процес, а Laravel надсилає йому дані сторінки й отримує HTML. На сервері потрібен Node.
Inertia 3 спростила налаштування:
- плагін
@inertiajs/viteналаштовує SSR автоматично; - у розробці SSR працює просто з
npm run dev- без окремої збірки й окремого сервера; - у продакшені - збірка
vite build && vite build --ssrі процесphp artisan inertia:start-ssr(під наглядом supervisor), за потреби з кластеризацією на кілька потоків.
Вимкнути SSR для частини застосунку (адмінка, де SEO не потрібне): властивість $withoutSsr у middleware чи Inertia::withoutSsr(['admin/*']); повністю - ssr.enabled => false у конфігурації або Inertia::disableSsr().
Код, що працює лише в браузері (window, localStorage, бібліотеки з DOM), на сервері падає. Його виконують в onMounted або загортають у компонент WhenMounted, що рендерить вміст лише після монтування в браузері.
Версіонування ассетів. SPA живе в браузері годинами - після деплою користувач продовжує працювати зі старим JavaScript, який може не відповідати новим props. Inertia передає версію ассетів у кожному запиті; якщо версія на сервері змінилася, Inertia робить повне перезавантаження сторінки замість звичайного візиту, і браузер отримує нові файли.
- з Vite версія обчислюється автоматично (метод
version()уHandleInertiaRequests); - з Inertia 3.6 зміна версії, помічена у фоновому запиті (опитування,
router.reload()), не перезавантажує сторінку - щоб не знищити незбережені дані, яких користувач не чекав втратити.
Пастка деплою: SSR-процес Node треба перезапускати при кожному деплої, інакше він рендеритиме старими компонентами.
Компоненти на кшталт таблиці, списку з вибором чи автодоповнення працюють з будь-яким типом елементів. Без узагальнень доводиться писати items: any[], і батько втрачає перевірку типів у слотах і подіях.
Атрибут generic на <script setup>:
<!-- DataTable.vue -->
<script setup lang="ts" generic="T extends { id: number | string }">
defineProps<{
rows: T[]
columns: Array<{ key: keyof T; label: string }>
}>()
const emit = defineEmits<{
select: [row: T]
}>()
</script>
<template>
<table>
<tr v-for="row in rows" :key="row.id" @click="emit('select', row)">
<td v-for="column in columns" :key="String(column.key)">
<slot :name="String(column.key)" :row="row">{{ row[column.key] }}</slot>
</td>
</tr>
</table>
</template>
Що отримує батько:
<DataTable
:rows="orders"
:columns="[{ key: 'total', label: 'Сума' }]"
@select="(order) => open(order.number)"
/>
T виводиться з переданого rows як Order: key: 'totla' - помилка, у @select параметр має тип Order, а в слоті row - теж Order.
Можливості:
- кілька параметрів і обмеження:
generic="T extends Item, K extends keyof T"; - імпортовані типи в обмеженнях;
- типізовані слоти через
defineSlots<{ default(props: { item: T }): any }>().
Обмеження й нюанси:
- runtime не знає про
T- узагальнення існують лише для TypeScript. Vue перевіряє props за базовим типом (Array), а не заT; - виведення типу йде з props. Якщо
Tне можна вивести з жодного prop, він стає обмеженням (unknownчи тип зextends); - перевірку в шаблоні батька дає лише
vue-tsc/ Vue - Official; без них узагальнення - просто документація; - ref на узагальнений компонент типізується складніше - звичайний
InstanceType<typeof DataTable>не працює, бо компонент - функція з параметром типу. Для таких випадків використовуютьComponentExposedз пакетаvue-component-type-helpers.
Коли не варто: якщо компонент завжди працює з одним типом - узагальнення лише ускладнюють читання.
provide/inject передає значення від предка до будь-якого нащадка без прокидання через props. З рядковим ключем TypeScript не знає, що там лежить:
provide('theme', theme)
const theme = inject('theme') // unknown - і помилка в назві ключа ніде не підсвітиться
InjectionKey<T> - символ, що несе тип значення:
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface ThemeContext {
mode: Ref<'light' | 'dark'>
toggle: () => void
}
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
// провайдер
provide(ThemeKey, { mode, toggle }) // TypeScript перевірить форму значення
// споживач
const theme = inject(ThemeKey) // ThemeContext | undefined
Чому undefined у типі: компонент може опинитися поза деревом провайдера - тоді inject поверне undefined (з попередженням у режимі розробки). Варіанти:
const theme = inject(ThemeKey, defaultTheme) // значення за замовчуванням
function useTheme(): ThemeContext {
const theme = inject(ThemeKey)
if (!theme) throw new Error('useTheme() потрібно викликати всередині ThemeProvider')
return theme
}
Обгортка-composable з явною помилкою - найпрактичніший варіант: зрозуміле повідомлення замість Cannot read properties of undefined десь глибоко.
Чому символ кращий за рядок:
- немає зіткнень: дві бібліотеки з ключем
'theme'перезапишуть одна одну; символи завжди унікальні; - тип прив'язаний до ключа - не треба дублювати тип у кожному
inject; - рефакторинг і пошук працюють за імпортом ключа, а не за рядком.
Що надавати:
- реактивні значення (
ref,computed) - інакше нащадки не побачать змін; - функції для зміни замість відкритого змінюваного стану - провайдер контролює, як змінюється дані. Для захисту від прямої зміни -
readonly(state).
Коли не provide/inject: глобальний стан застосунку (кошик, користувач) - Pinia з DevTools і плагінами. Provide/inject - для контексту піддерева: форма й її поля, таблиця й колонки, тема окремого віджета.
Докладніше в документації: TypeScript: типізація provide / inject
Плагіни й app.config.globalProperties додають властивості, доступні в кожному компоненті ($t для перекладів, route() від Ziggy в Laravel-проєктах). Глобально зареєстровані компоненти (app.component('Icon', Icon)) доступні в усіх шаблонах без імпорту. TypeScript про них нічого не знає, поки їх не оголосити.
Глобальні властивості - розширення модуля vue:
// types/vue.d.ts
import type { route as routeFn } from 'ziggy-js'
export {}
declare module 'vue' {
interface ComponentCustomProperties {
route: typeof routeFn
$t: (key: string, params?: Record<string, unknown>) => string
}
}
Тепер у шаблоні {{ route('posts.show', post.id) }} і $t('cart.empty') мають типи, а друкарська помилка в назві - помилка vue-tsc.
Глобальні компоненти:
declare module 'vue' {
interface GlobalComponents {
Icon: typeof import('./components/Icon.vue')['default']
Link: typeof import('@inertiajs/vue3')['Link']
}
}
Розширення Vue - Official і vue-tsc перевіряють props таких компонентів у шаблонах так само, як імпортованих.
Важливі деталі:
- файл оголошень має бути модулем (
export {}чи будь-який імпорт/експорт), інакшеdeclare module 'vue'замінить типи Vue замість розширення; - файл має потрапити в
includeуtsconfig; - бібліотеки (Vue Router, Pinia, Inertia) самі постачають такі розширення -
$route,$routerтипізовані без ваших зусиль.
Краще уникати глобальних властивостей у Composition API. У <script setup> доступ до них через getCurrentInstance() - незручний і крихкий. Явний імпорт чи composable (const { t } = useI18n()) зрозуміліший, краще працює з tree shaking і тестами: у тесті достатньо замокати модуль, а не налаштовувати глобальні властивості.
Глобальні компоненти теж мають ціну: збирач не знає, чи вони використовуються, тож не може їх викинути чи винести в окрему частину. Реєструвати глобально варто лише справді повсюдні (іконка, посилання), решту - імпортувати там, де потрібні.
Докладніше в документації: TypeScript: розширення глобальних властивостей
eslint-plugin-vue - офіційний плагін ESLint, що розбирає .vue-файли, включно з шаблонами. Для TypeScript у Vue-файлах поруч ставлять @vue/eslint-config-typescript (обгортка над typescript-eslint, що знає про SFC). create-vue налаштовує все це сам.
Плоска конфігурація (ESLint 9+):
// eslint.config.js
import pluginVue from 'eslint-plugin-vue'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
export default defineConfigWithVueTs(
pluginVue.configs['flat/recommended'],
vueTsConfigs.recommended,
{
rules: {
'vue/multi-word-component-names': 'off',
},
},
)
Рівні наборів правил: flat/essential (помилки), flat/strongly-recommended (+ читабельність), flat/recommended (+ узгодженість стилю).
Правила, що ловлять справжні баги:
vue/no-mutating-props- зміна props у дочірньому компоненті;vue/require-v-for-keyіvue/valid-v-for-v-forбез ключа;vue/no-use-v-if-with-v-for-v-ifіv-forна одному елементі (пріоритетv-ifвищий, і змінна циклу в ньому недоступна);vue/no-side-effects-in-computed-properties- зміна стану вcomputed;vue/no-ref-as-operand-count + 1замістьcount.value + 1;vue/no-setup-props-reactivity-loss- деструктуризація props зі втратою реактивності;vue/no-v-html- потенційний XSS;vue/require-explicit-emits- неоголошені події.
З TypeScript-правилами на основі типів (vueTsConfigs.recommendedTypeChecked) додаються, наприклад, @typescript-eslint/no-floating-promises - необроблені Promise в обробниках. Вони повільніші, бо будують програму TypeScript.
Розділення відповідальності:
- форматування - Prettier, а правила стилю ESLint, що з ним конфліктують, вимикаються (
@vue/eslint-config-prettier); - типи -
vue-tsc, а не ESLint; - ESLint - помилки логіки й небезпечні шаблони.
У CI лінтер запускають з --max-warnings 0 - інакше попередження накопичуються й перестають щось означати.
У звичайному Vue-застосунку (SPA) сервер віддає майже порожній HTML, а сторінку будує JavaScript у браузері. SSR рендерить ті самі компоненти на сервері в готовий HTML, а в браузері Vue «оживляє» його - гідрує.
Як це влаштовано:
- на сервері (Node.js) створюється застосунок, завантажуються дані, компоненти рендеряться в рядок HTML (
renderToStringзvue/server-renderer); - браузер отримує готову сторінку - вміст видно одразу, ще до завантаження JavaScript;
- завантажується той самий код застосунку, Vue проходить по існуючому DOM і прив'язує до нього реактивність та обробники подій, не перестворюючи елементи;
- далі застосунок працює як звичайний SPA.
Що це дає:
- швидший перший показ вмісту (LCP), особливо на повільних пристроях;
- SEO й превью соцмереж: пошукові роботи й боти соцмереж бачать вміст без виконання JavaScript;
- кращий досвід на поганому з'єднанні.
Ціна:
- потрібен Node.js-сервер (або платформа з підтримкою SSR) і його навантаження - рендер на кожен запит;
- код має працювати в обох середовищах: на сервері немає
window,document,localStorage. Звертатися до них можна лише вonMountedчи з перевірками; - стан між запитами: на сервері один процес обслуговує всіх користувачів - глобальні змінні модулів (синглтон-стор) ділитимуться між запитами і можуть показати дані одного користувача іншому. Застосунок і стор треба створювати на кожен запит;
- помилки гідрації, коли серверний і клієнтський HTML не збігаються.
Як роблять на практиці:
- Nuxt - фреймворк над Vue, що бере на себе SSR, маршрутизацію, завантаження даних і розгортання;
- Inertia SSR у Laravel-проєктах - Laravel віддає дані, окремий Node-процес (
php artisan inertia:start-ssr) рендерить сторінку Vue; - статична генерація (SSG) - HTML генерується під час збирання, якщо вміст не змінюється на кожен запит (документація, блог).
Коли SSR не потрібен: внутрішні панелі й кабінети за авторизацією - SEO там не важливий, а SPA простіший в експлуатації.
Під час гідрації Vue очікує, що DOM, отриманий із сервера, точно відповідає тому, що відрендерив би клієнт з тими самими даними. Якщо ні - у консолі Hydration mismatch, а Vue виправляє розбіжність, перебудовуючи частину DOM. Це повільно, а в продакшені може лишити неправильний вміст.
Типові причини:
1. Дані, що відрізняються на сервері й клієнті:
<span>{{ new Date().toLocaleTimeString() }}</span> <!-- час сервера ≠ час браузера -->
<span>{{ Math.random() }}</span>
<p>{{ formatDate(order.createdAt) }}</p> <!-- різні часові пояси -->
2. Код, що залежить від браузера:
<div v-if="window.innerWidth > 768">...</div> <!-- на сервері window немає -->
<p>{{ localStorage.getItem('name') }}</p>
3. Невалідна вкладеність HTML. <div> всередині <p>, <tr> без <tbody> - браузер «виправляє» розмітку при розборі, і DOM уже не відповідає серверному HTML.
4. Сторонні скрипти й розширення браузера, що змінюють DOM до гідрації.
5. Генеровані id, які на сервері й клієнті виходять різними (лічильник, Math.random()).
Як виправляти:
- значення, що мають бути лише на клієнті, - встановлювати в
onMounted(він не виконується на сервері):
const now = ref<string | null>(null)
onMounted(() => { now.value = new Date().toLocaleTimeString() })
- детерміновані дані: форматування дат з явним часовим поясом (
Intl.DateTimeFormat('uk', { timeZone: 'Europe/Kyiv' })), а не поясом середовища; useId()(Vue 3.5) для id, стабільних між сервером і клієнтом;- клієнтські компоненти - рендерити лише в браузері (
<ClientOnly>у Nuxt чи власна обгортка з прапорцем, встановленим вonMounted); - валідна розмітка - перевіряється валідатором HTML.
Свідома розбіжність. Якщо значення має відрізнятися (час, локальна дата), Vue 3.5 дозволяє придушити попередження атрибутом:
<span data-allow-mismatch="text">{{ localTime }}</span>
Значення атрибута обмежує тип розбіжності: text, children, class, style, attribute. Це для точкових випадків, а не спосіб приховати справжні баги.
Діагностика: у режимі розробки попередження показує, який саме вузол не збігся. Для продакшен-збірки детальні повідомлення вмикаються прапорцем __VUE_PROD_HYDRATION_MISMATCH_DETAILS__.
У SSR сторінка приходить готовим HTML, але інтерактивною стає лише після гідрації - коли Vue пройде по всіх компонентах сторінки й прив'яже обробники. На важких сторінках це секунди роботи головного потоку, за які кліки не працюють і погіршується INP.
Лінива гідрація (Vue 3.5+) дозволяє гідрувати асинхронні компоненти не одразу, а за умовою. Поки компонент не гідровано, він показує серверний HTML - вміст видно, просто без інтерактивності.
import { defineAsyncComponent, hydrateOnVisible, hydrateOnIdle, hydrateOnInteraction } from 'vue'
const Comments = defineAsyncComponent({
loader: () => import('./Comments.vue'),
hydrate: hydrateOnVisible(), // коли з'явиться в області видимості
})
const Footer = defineAsyncComponent({
loader: () => import('./Footer.vue'),
hydrate: hydrateOnIdle(), // коли браузер вільний
})
const Rating = defineAsyncComponent({
loader: () => import('./Rating.vue'),
hydrate: hydrateOnInteraction(['click', 'focus']), // при першій взаємодії
})
Стратегії:
hydrateOnIdle(timeout?)- черезrequestIdleCallback;hydrateOnVisible(options?)- черезIntersectionObserver(можна задатиrootMargin);hydrateOnMediaQuery('(max-width: 500px)')- лише якщо виконується медіазапит;hydrateOnInteraction(events)- при першій події; подія, що спричинила гідрацію, відтворюється після неї, тож клік не губиться;- власна стратегія - функція, що отримує
hydrateі повертає функцію прибирання.
Що це дає:
- головний потік вільний раніше - сторінка відповідає на дії швидше;
- код компонента може завантажитися лише тоді, коли він знадобиться (з динамічним
import()); - нижня частина довгих сторінок не займає час на старті.
Обмеження й ризики:
- працює лише з SSR і асинхронними компонентами; у звичайному SPA гідрації немає;
- до гідрації компонент не реагує - якщо користувач клікне на кнопку з
hydrateOnVisibleраніше, ніж та гідрується, клік не спрацює (на відміну відhydrateOnInteraction); - стан, що залежить від інших компонентів, - компонент, гідрований пізніше, не бачив попередніх подій;
- Nuxt має власні обгортки над цими стратегіями (
hydrate-on-visibleтощо) для компонентів з префіксомLazy.
Підхід схожий на «острівці» (Astro): інтерактивність вмикається лише там і тоді, де вона потрібна.
Докладніше в документації: Асинхронні компоненти: лінива гідрація
Багатьом компонентам потрібні унікальні id для зв'язку елементів: <label for> і <input id>, aria-describedby для підказок і помилок, aria-controls для вкладок і розкривних блоків.
Наївні способи ламаються:
const id = `input-${Math.random().toString(36).slice(2)}` // різний на сервері й клієнті
let counter = 0
const id = `input-${++counter}` // залежить від порядку створення
- з SSR сервер і браузер згенерують різні значення - помилка гідрації, і
forвказуватиме на неіснуючий елемент; - глобальний лічильник на сервері продовжує рахувати між запитами різних користувачів, а в браузері починає з нуля;
- асинхронні компоненти й ліниве завантаження змінюють порядок створення компонентів.
useId() (Vue 3.5+) генерує id, стабільний між сервером і клієнтом та унікальний у межах застосунку:
<script setup lang="ts">
import { useId } from 'vue'
defineProps<{ label: string; error?: string }>()
const id = useId()
</script>
<template>
<label :for="id">{{ label }}</label>
<input :id="id" :aria-describedby="error ? `${id}-error` : undefined" />
<p v-if="error" :id="`${id}-error`">{{ error }}</p>
</template>
Один useId() на компонент - а похідні id (${id}-error, ${id}-hint) будуються суфіксами.
Правила:
- викликати в
setup(на верхньому рівні<script setup>), а не вcomputed, обробниках чи циклах - id прив'язаний до екземпляра компонента; - кілька застосунків на одній сторінці (наприклад, кілька окремих віджетів Vue на сторінці Blade) можуть згенерувати однакові id - для них задають префікс через
app.config.idPrefix; - id не варто використовувати як ключ даних чи зберігати - це лише зв'язок елементів DOM.
Навіть без SSR useId кращий за власні лічильники: передбачуваний, без глобального стану, без випадкових зіткнень.
Чому це важливо: зв'язані через id мітки й описи - основа доступності форм. Зчитувач екрана озвучує мітку поля й текст помилки, а клік на мітці фокусує поле. Зламаний id мовчки руйнує це для частини користувачів.
Докладніше в документації: Допоміжні функції Composition API: useId
Pinia. Пакет @pinia/testing дає createTestingPinia - тестову версію стора, що підключається як плагін:
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import { useCartStore } from '@/stores/cart'
const wrapper = mount(CartButton, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: { cart: { items: [{ id: 1, qty: 2 }] } },
}),
],
},
})
const cart = useCartStore() // той самий екземпляр, що й у компоненті
await wrapper.get('button').trigger('click')
expect(cart.add).toHaveBeenCalledWith(1)
Що важливо знати:
- дії замінено шпигунами за замовчуванням (
stubActions: true) - викликcart.add()реєструється, але не виконується: стан не зміниться. Тест перевіряє, що компонент викликав дію, а логіку дії тестують окремо; - щоб дії виконувалися -
stubActions: false; initialStateзадає стан за id стора;- геттери можна перевизначити прямо в тесті:
cart.total = 999(тестовий стор це дозволяє) - щоб перевірити відображення без побудови стану; - сам стор тестують без компонентів:
setActivePinia(createPinia())уbeforeEachі виклики дій напряму.
Vue Router. Два підходи:
1. Справжній маршрутизатор з пам'яттю - найближче до реальності:
import { createRouter, createMemoryHistory } from 'vue-router'
const router = createRouter({ history: createMemoryHistory(), routes })
router.push('/orders/7')
await router.isReady()
const wrapper = mount(OrderPage, { global: { plugins: [router] } })
createMemoryHistory не чіпає URL середовища тестів; кожен тест створює новий маршрутизатор, щоб стан не протікав.
2. Моки - для компонентів, яким потрібні лише useRoute()/useRouter():
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({ params: { id: '7' } })),
useRouter: vi.fn(() => ({ push: vi.fn() })),
}))
Швидко, але тест не перевіряє справжню навігацію й захисників маршрутів.
RouterLinkStub з Test Utils замінює <RouterLink> без маршрутизатора й дає перевірити to.
Захисників маршрутів (beforeEach з перевіркою автентифікації) варто тестувати на справжньому маршрутизаторі: перейти на захищений маршрут і перевірити, де опинився користувач.
jsdom і happy-dom - імітація DOM у Node.js. Вони швидкі, але не є браузером: немає розкладки (розміри елементів - нулі), IntersectionObserver, ResizeObserver, canvas, справжнього фокуса, прокрутки, CSS-анімацій і :hover. Тести компонентів, що від цього залежать, або неможливі, або перевіряють моки замість поведінки.
Три рівні тестування фронтенду:
1. Компонентні тести в jsdom (Vitest + Vue Test Utils) - більшість тестів. Логіка компонента, рендер за props, події, взаємодія з мокованим API. Мілісекунди на тест.
2. Компонентні тести в браузері - Vitest Browser Mode (через Playwright чи WebdriverIO): ті самі тести компонентів, але в справжньому Chromium/Firefox/WebKit:
// vitest.config.ts (фрагмент); playwright() - з пакета @vitest/browser-playwright
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
}
import { render } from 'vitest-browser-vue'
import { page } from 'vitest/browser'
it('opens the dropdown', async () => {
render(Dropdown, { props: { items } })
await page.getByRole('button', { name: 'Меню' }).click()
await expect.element(page.getByRole('menu')).toBeVisible()
})
Доречні для: віртуалізованих списків, перетягування, позиціонування спливаючих підказок, фокуса й навігації з клавіатури, роботи з canvas і медіа. Повільніші за jsdom, тож для них - лише те, що справді потребує браузера.
3. E2E-тести (Playwright, Cypress) - весь застосунок із бекендом: вхід, оформлення замовлення, оплата в тестовому режимі. Перевіряють, що частини працюють разом. Повільні й крихкіші - тому їх мало, лише для критичних сценаріїв.
Розподіл, що працює: багато швидких компонентних тестів, менше браузерних - для поведінки, яку jsdom не відтворює, і кілька e2e на ключові шляхи користувача.
Пошук елементів за роллю й текстом (getByRole('button', { name: 'Зберегти' })) - спільна практика для всіх рівнів: так тести збігаються з тим, як бачить сторінку користувач і зчитувач екрана, і заразом перевіряють доступність.
У Laravel-проєкті e2e-сценарії зручно писати браузерними тестами Pest (на Playwright) - разом з фабриками й станом бази.
Питання з реальних технічних співбесід - 108 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії