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

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

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

Сторінка з важкою статистикою віддається повільно: сервер не відправить відповідь, доки не порахує все. 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: відкладені props

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: злиття props

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), власні вставки треба екранувати так само.

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

Без 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 треба перезапускати при кожному деплої, інакше він рендеритиме старими компонентами.

Докладніше в документації: Inertia: серверний рендеринг

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

Коли не варто: якщо компонент завжди працює з одним типом - узагальнення лише ускладнюють читання.

Докладніше в документації: script setup: generics

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 - інакше попередження накопичуються й перестають щось означати.

Докладніше в документації: eslint-plugin-vue

У звичайному Vue-застосунку (SPA) сервер віддає майже порожній HTML, а сторінку будує JavaScript у браузері. SSR рендерить ті самі компоненти на сервері в готовий HTML, а в браузері Vue «оживляє» його - гідрує.

Як це влаштовано:

  1. на сервері (Node.js) створюється застосунок, завантажуються дані, компоненти рендеряться в рядок HTML (renderToString з vue/server-renderer);
  2. браузер отримує готову сторінку - вміст видно одразу, ще до завантаження JavaScript;
  3. завантажується той самий код застосунку, Vue проходить по існуючому DOM і прив'язує до нього реактивність та обробники подій, не перестворюючи елементи;
  4. далі застосунок працює як звичайний 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: невідповідність гідрації

У 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 з перевіркою автентифікації) варто тестувати на справжньому маршрутизаторі: перейти на захищений маршрут і перевірити, де опинився користувач.

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

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) - разом з фабриками й станом бази.

Докладніше в документації: Vitest: режим браузера

Питання з реальних технічних співбесід - 108 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 36 Middle 37 Senior 35

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії