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

Питання на співбесіді з Vue

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

108 питань

Компонент повідомляє батька про події через emit. Оголошувати події не обов'язково технічно, але дуже бажано.

<script setup>
const emit = defineEmits(['save', 'cancel']);

function onSubmit() {
  emit('save', { title: title.value });
}
</script>
<PostForm @save="createPost" @cancel="close" />

Чому оголошувати:

  • документація інтерфейсу: видно, які події компонент генерує, - як props для вхідних даних;
  • відсутність подвійних спрацювань: неоголошена подія прокидається як нативний слухач на кореневий елемент. Якщо дитина робить emit('click'), а click не оголошено, обробник батька спрацює і від нативного кліку, і від emit - двічі;
  • TypeScript перевіряє назви й типи параметрів подій.

Валідація параметрів - об'єктний синтаксис:

const emit = defineEmits({
  save: (payload) => {
    if (!payload?.title) {
      console.warn('Подія save без title');
      return false;
    }
    return true;
  },
  cancel: null,   // без перевірки
});

Невдала перевірка лише виводить попередження в режимі розробки - подія все одно відправляється. Це інструмент налагодження, а не захист.

З TypeScript - типова сигнатура, що краще за валідатори:

const emit = defineEmits<{
  save: [payload: { title: string }];
  cancel: [];
}>();

Іменування: у скрипті - camelCase (emit('itemSelected')), у шаблоні батька можна писати kebab-case (@item-selected) - Vue перетворює автоматично.

Чим події компонентів відрізняються від подій DOM:

  • не спливають - подію дитини чує лише її безпосередній батько. Для передачі через кілька рівнів - повторний emit на кожному рівні, provide/inject з функцією або стор;
  • синхронні: обробники батька виконуються в момент emit.

emit у setup поза <script setup> - другий аргумент setup(props, { emit }).

Докладніше в документації: Події компонентів

Повна форма директиви: v-назва:аргумент.модифікатор="значення". Наприклад, v-bind:href="url", v-on:submit.prevent="save".

Динамічний аргумент - назва атрибута чи події з виразу в квадратних дужках:

<a :[attributeName]="url">...</a>
<button @[eventName]="handler">...</button>

Якщо attributeName = 'href', це те саме, що :href="url". Значення null прибирає прив'язку.

Обмеження динамічних аргументів:

  • вираз має давати рядок або null;
  • без пробілів і лапок усередині дужок: :['data-' + key] - синтаксична помилка. Складні назви обчислюють у computed;
  • у шаблонах прямо в HTML-сторінці (не в .vue-файлах) браузер переводить атрибути в нижній регістр: :[someAttr] стане :[someattr] і не знайде змінну.

Прив'язка об'єкта атрибутів - v-bind без аргументу:

<input v-bind="{ id: 'email', type: 'email', required: true }">
<BaseInput v-bind="field" />   <!-- розгорнути об'єкт у props/атрибути -->

Скорочення з однаковою назвою (Vue 3.4+): якщо атрибут і змінна називаються однаково, значення можна опустити:

<img :src :alt>          <!-- те саме, що :src="src" :alt="alt" -->
<UserCard :user :role />

Kebab-case атрибут шукає camelCase змінну: :data-id → dataId.

Логічні атрибути: :disabled="isSaving" - атрибут з'являється при істинному значенні й зникає при хибному (false, null, undefined). Порожній рядок '' для логічних атрибутів вважається істинним, як у HTML.

Модифікатори v-bind:

  • .prop - встановити як властивість DOM, а не атрибут (:value.prop, скорочено .value);
  • .attr - навпаки, примусово як атрибут (:width.attr, скорочено ^width);
  • .camel - перетворити kebab-case на camelCase (для атрибутів SVG на кшталт viewBox у HTML-шаблонах).

Чому це корисно на практиці: спільні компоненти-обгортки (поле форми, кнопка-посилання), що приймають набір атрибутів ззовні, і генерація форм з конфігурації, де назви полів і подій приходять з даних.

Докладніше в документації: Синтаксис шаблонів

Vue сам керує DOM, але інколи потрібен прямий доступ до елемента: фокус, прокрутка, вимір розміру, ініціалізація сторонньої бібліотеки. Для цього - template ref.

Vue 3.5+ - useTemplateRef:

<script setup>
import { useTemplateRef, onMounted } from 'vue';

const input = useTemplateRef('search');

onMounted(() => input.value.focus());
</script>

<template>
  <input ref="search" type="search">
</template>

Рядок у useTemplateRef('search') збігається зі значенням атрибута ref.

До 3.5 - ref з тією самою назвою, що й атрибут:

const search = ref(null);   // <input ref="search">

Цей спосіб працює й далі, але useTemplateRef явніший: видно, що змінна - саме посилання на елемент, і не потрібно збігу назв змінних.

Коли ref заповнений:

  • після монтування - в setup і onBeforeMount він null;
  • під v-if стає null, коли елемент прибрано, - звідси перевірки input.value?.focus();
  • після зміни стану DOM оновлюється асинхронно: щоб звернутися до щойно показаного елемента, потрібен await nextTick():
async function openEditor() {
  editing.value = true;
  await nextTick();
  editorInput.value.focus();
}

Ref у v-for дає масив елементів (порядок не гарантовано збігається з масивом даних):

const items = useTemplateRef('items');   // <li v-for="..." ref="items">

Ref-функція - для повного контролю: :ref="(el) => { if (el) map.set(id, el) }" викликається з елементом при монтуванні й з null при видаленні.

Ref на компонент дає екземпляр компонента - але з <script setup> доступне лише те, що відкрито через defineExpose.

Коли НЕ треба ref: змінювати вміст, класи, атрибути напряму через DOM (el.textContent = ..., el.classList.add). Наступний рендер Vue перезапише зміни, а стан стане неузгодженим. Усе, що можна виразити через стан і шаблон, - через стан і шаблон.

Докладніше в документації: Template refs

Модальне вікно логічно належить компоненту, що його відкриває (кнопка, стан, обробники - поруч). Але візуально воно має бути поверх усієї сторінки. Якщо розмітка вікна лишається глибоко в DOM усередині батьків, CSS батьків ламає позиціонування:

  • transform, filter, perspective на предку роблять position: fixed відносним до цього предка, а не до вікна;
  • overflow: hidden обрізає вікно;
  • z-index обмежений контекстом накладання (stacking context) батька.

<Teleport> рендерить вміст в інше місце DOM, лишаючи його логічно частиною компонента:

<script setup>
const open = ref(false);
</script>

<template>
  <button @click="open = true">Видалити акаунт</button>

  <Teleport to="body">
    <div v-if="open" class="fixed inset-0 z-50 grid place-items-center bg-black/50">
      <div role="dialog" aria-modal="true" class="rounded bg-white p-6">
        <p>Ви впевнені?</p>
        <button @click="open = false">Скасувати</button>
      </div>
    </div>
  </Teleport>
</template>

Що зберігається: реактивність, props, події, provide/inject - вміст лишається дочірнім у дереві компонентів. Змінюється лише місце в DOM.

Параметри:

  • to - CSS-селектор чи елемент; ціль має існувати на момент монтування;
  • :disabled="isMobile" - рендерити на місці (наприклад, на мобільних вікно стає звичайним блоком);
  • defer (Vue 3.5+) - відкласти пошук цілі до кінця поточного монтування, щоб ціллю міг бути елемент, який Vue рендерить пізніше в тому самому дереві.

Кілька Teleport в одну ціль дописують вміст по черзі - зручно для стеку сповіщень.

Що ще треба для якісного модального вікна (Teleport цього не робить):

  • фокус: перевести фокус у вікно при відкритті, втримувати його всередині (focus trap) і повертати на кнопку після закриття;
  • закриття по Escape і кліку на підкладку;
  • блокування прокрутки сторінки під вікном;
  • role="dialog", aria-modal, aria-labelledby для зчитувачів екрана.

Нативна альтернатива - елемент <dialog> з showModal(): браузер сам дає верхній шар (top layer, без проблем із z-index), фокус і Escape. У сучасних застосунках це часто простіше за Teleport.

SSR: телепортований вміст рендериться окремо - його треба вставити в HTML вручну, інакше буде розбіжність при гідратації.

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

<Transition> анімує один елемент чи компонент, коли той з'являється або зникає через v-if, v-show, зміну динамічного компонента чи key.

<Transition name="fade">
  <p v-if="visible">Збережено</p>
</Transition>

<style>
.fade-enter-active,
.fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>

Класи, які Vue додає по черзі (name - префікс):

  • -enter-from → -enter-active → -enter-to - поява;
  • -leave-from → -leave-active → -leave-to - зникнення.

Vue сам визначає, коли анімація закінчилася (за transitionend/animationend), і лише тоді прибирає елемент з DOM.

З Tailwind - класи напряму через props:

<Transition
  enter-active-class="transition duration-200 ease-out"
  enter-from-class="opacity-0 -translate-y-2"
  leave-active-class="transition duration-150 ease-in"
  leave-to-class="opacity-0"
>

Режими для заміни одного елемента іншим: mode="out-in" - спершу зникає старий, потім з'являється новий (без накладання).

<TransitionGroup> - для списків (v-for): анімує додавання, видалення й переміщення елементів:

<TransitionGroup name="list" tag="ul">
  <li v-for="item in items" :key="item.id">{{ item.title }}</li>
</TransitionGroup>

<style>
.list-move { transition: transform 0.3s; }   /* плавне переміщення при зміні порядку */
.list-leave-active { position: absolute; }  /* щоб сусіди плавно зсувалися */
</style>

Обов'язково :key на кожному елементі - без нього Vue не знає, що анімувати.

JavaScript-хуки (@before-enter, @enter, @leave з колбеком done) - для анімацій бібліотеками (GSAP, Web Animations API).

Пастки:

  • <Transition> очікує рівно один кореневий елемент - компонент з кількома коренями всередині не анімується;
  • анімація не запускається при першому рендері - для цього атрибут appear;
  • prefers-reduced-motion - користувачі, які вимкнули анімації в системі, мають їх не бачити: @media (prefers-reduced-motion: reduce) чи motion-reduce: у Tailwind;
  • для переходів між сторінками в сучасних браузерах можна використати View Transitions API.

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

Navigation guards - функції, що виконуються перед переходом і можуть його дозволити, скасувати чи перенаправити.

Глобальний guard з перевіркою meta маршруту:

const routes = [
  { path: '/login', name: 'login', component: Login },
  { path: '/dashboard', component: Dashboard, meta: { requiresAuth: true } },
];

router.beforeEach((to, from) => {
  const auth = useAuthStore();

  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { name: 'login', query: { redirect: to.fullPath } };
  }
  // нічого не повертати (або true) - перехід дозволено
});

Що може повернути guard:

  • undefined / true - продовжити;
  • false - скасувати перехід;
  • маршрут (рядок чи об'єкт) - перенаправити;
  • Promise - роутер дочекається результату (async-guard з запитом до API).

Третій аргумент next досі підтримується, але документація радить повертати значення: з next легко забути виклик чи викликати його двічі.

meta успадковується: перевірка to.meta.requiresAuth спрацює й для вкладених маршрутів, якщо мета задана на батьківському (точніше - to.matched.some(r => r.meta.requiresAuth) враховує всі рівні).

Інші guards:

  • beforeEnter у конфігурації маршруту - лише для нього;
  • onBeforeRouteLeave у компоненті - попередити про незбережені зміни;
  • onBeforeRouteUpdate - коли змінюються параметри того самого маршруту;
  • router.afterEach - аналітика, заголовок сторінки (не може скасувати перехід).

Головне застереження: guard - це зручність інтерфейсу, а не захист. Код сторінки вже завантажено в браузер, а будь-хто може змінити стан стору в devtools. Справжній захист - на сервері: API має перевіряти автентифікацію й права на кожен запит.

Пастка нескінченного перенаправлення: guard, що відправляє на /login, не повинен застосовуватися до самого /login.

Докладніше в документації: Vue Router: навігаційні guards

При переході між маршрутами з тим самим компонентом (/users/1 → /users/2) Vue Router не перестворює компонент - він лише оновлює route.params. Хуки onMounted і setup не виконуються вдруге, і дані першого користувача лишаються на екрані.

<script setup>
const route = useRoute();
const user = ref(null);

onMounted(async () => {
  user.value = await fetchUser(route.params.id);   // лише один раз!
});
</script>

Рішення 1 - стежити за параметром:

watch(
  () => route.params.id,
  async (id) => { user.value = await fetchUser(id); },
  { immediate: true },
);

Рішення 2 - guard оновлення в компоненті:

onBeforeRouteUpdate(async (to) => {
  user.value = await fetchUser(to.params.id);
});

Рішення 3 - примусово перестворювати компонент через key:

<RouterView :key="$route.fullPath" />

Просто, але дорого: при кожній зміні query перемонтовується вся сторінка, губиться її стан.

Параметри як props - компонент не залежить від роутера:

{ path: '/users/:id', component: UserProfile, props: true }

// перетворення: число замість рядка, плюс query
{
  path: '/search',
  component: SearchPage,
  props: (route) => ({ query: route.query.q, page: Number(route.query.page ?? 1) }),
}
<script setup>
const props = defineProps({ id: String });
watch(() => props.id, loadUser, { immediate: true });
</script>

Переваги props:

  • компонент можна використати поза роутером і легко тестувати - достатньо передати props;
  • приведення типів і значення за замовчуванням в одному місці (функція props).

Проблема «компонент не оновився» з props не зникає - стежити за зміною все одно треба, але через props.id.

Докладніше в документації: Vue Router: параметри як props

На відміну від Vuex, у Pinia немає мутацій - стан можна змінювати кількома способами.

1. Пряме присвоєння - найпростіше:

const cart = useCartStore();
cart.coupon = 'SALE10';
cart.items.push(item);

2. $patch з об'єктом - кілька полів за раз:

cart.$patch({ coupon: 'SALE10', deliveryMethod: 'courier' });

3. $patch з функцією - для масивів і складних змін:

cart.$patch((state) => {
  state.items = state.items.filter((i) => i.qty > 0);
  state.updatedAt = Date.now();
});

Об'єктна форма для масивів незручна: щоб видалити елемент, довелося б передати весь новий масив. Функція також об'єднує кілька змін в один запис у devtools.

4. Дії - для змін з логікою, перевірками чи запитами до API. Правило: якщо зміна має бізнес-сенс («оформити замовлення»), вона - дія стору, а не набір присвоєнь у компоненті.

$reset() - повернути початковий стан:

  • в option store вбудований: викликає state() заново;
  • у setup store його немає - пишуть самі:
export const useFiltersStore = defineStore('filters', () => {
  const initial = () => ({ city: null, salaryFrom: null });
  const filters = ref(initial());

  function $reset() {
    filters.value = initial();
  }

  return { filters, $reset };
});

$subscribe - реагувати на будь-яку зміну стану:

cart.$subscribe((mutation, state) => {
  localStorage.setItem('cart', JSON.stringify(state.items));
}, { detached: true });
  • за замовчуванням підписка знімається разом із компонентом; detached: true - лишити;
  • flush: 'sync' - викликати після кожної зміни, а не раз після групи;
  • для дій є окремий $onAction - до і після виконання, з обробкою помилок.

Замінити стан повністю - pinia.state.value = {...} (для SSR і тестів), а не cart.$state = ... у звичайному коді.

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

Vue Router підтримує кілька способів зберігати маршрут в URL.

createWebHistory() - звичайні адреси через History API:

https://example.com/users/7
  • красиві URL, які індексують пошукові системи;
  • потребує налаштування сервера. Перехід усередині застосунку обробляє JavaScript, але якщо користувач оновить сторінку чи відкриє посилання напряму, браузер запитає в сервера /users/7. Сервер, який про такий маршрут не знає, поверне 404.

createWebHashHistory() - маршрут після #:

https://example.com/#/users/7
  • частина після # на сервер не надсилається - сервер завжди віддає index.html, нічого налаштовувати не треба;
  • погано для SEO і виглядає застаріло. Доречно для внутрішніх інструментів, статичних хостингів без налаштувань, Electron.

createMemoryHistory() - без URL узагалі: для SSR на сервері й тестів.

Налаштування сервера для createWebHistory - будь-який невідомий шлях віддає точку входу SPA:

location / {
  try_files $uri $uri/ /index.html;
}

У Laravel, якщо SPA живе всередині застосунку:

// routes/web.php - останнім, після всіх інших маршрутів
Route::view('/app/{any?}', 'spa')->where('any', '.*');

Пастки:

  • справжні 404 зникають: сервер на будь-яку адресу віддає 200 з SPA. Сторінку «не знайдено» робить роутер (/:pathMatch(.*)*), але HTTP-статус лишається 200 - для SEO це «м'які 404»;
  • catch-all маршрут має бути останнім і не перехоплювати /api/*, інакше запити до API отримають HTML;
  • base - якщо застосунок не в корені домену: createWebHistory('/app/'), і той самий префікс у налаштуваннях збирача.

Докладніше в документації: Vue Router: режими історії

Деякі дані потрібні майже на кожній сторінці: поточний користувач у шапці, назва застосунку, налаштування. Передавати їх у кожному контролері незручно - для цього є спільні дані в middleware HandleInertiaRequests:

class HandleInertiaRequests extends Middleware
{
    public function share(Request $request): array
    {
        return [
            ...parent::share($request),
            'appName' => config('app.name'),
            'auth' => [
                'user' => fn () => $request->user()?->only('id', 'name', 'avatar_url'),
            ],
        ];
    }
}
<script setup>
import { computed } from 'vue';
import { usePage } from '@inertiajs/vue3';

const page = usePage();
const user = computed(() => page.props.auth.user);
</script>

Спільні дані зливаються з props сторінки - тож простір імен (auth.user) захищає від зіткнень із props контролерів.

Головні правила:

  • спільні дані йдуть з кожною відповіддю - кожен перехід і кожне часткове перезавантаження. Важкий запит тут - податок на всі сторінки. Замикання (fn () => ...) обчислюються лише тоді, коли prop справді потрібен;
  • рідко змінні дані (список країн, налаштування) - Inertia::once() чи метод shareOnce(): клієнт запам'ятовує значення й не отримує його з кожною відповіддю;
  • усе видно в браузері. $request->user() без only() віддасть усі атрибути моделі в JSON кожної сторінки - email, телефон, службові прапорці, усе, що не в $hidden. Передавайте явний перелік полів чи ресурс;
  • ролі й права - готовими булевими значеннями для інтерфейсу (can.manageUsers), а не списком усіх дозволів системи.

Валідаційні помилки адаптер Laravel уже додає в спільні дані (errors), а флеш-дані в Inertia 3 мають окремий механізм Inertia::flash().

Версія ассетів визначається в тому самому middleware (метод version()) - для Vite вона обчислюється автоматично.

Докладніше в документації: Inertia: спільні дані

У класичній SPA сервер на помилку валідації повертає 422 з JSON, а клієнт розкладає помилки по полях. Inertia працює інакше - так само, як звичайні форми Laravel:

  1. форма відправляється Inertia-запитом;
  2. $request->validate() не проходить - Laravel перенаправляє назад і кладе помилки в сесію;
  3. адаптер Inertia додає помилки з сесії в props сторінки (errors);
  4. Inertia бачить непорожній page.props.errors і вважає відправку невдалою - викликає onError, а не onSuccess.

Відповідей 422 Inertia-застосунок не генерує взагалі.

public function store(Request $request)
{
    $request->validate([
        'email' => ['required', 'email', 'unique:users'],
        'name' => ['required', 'max:100'],
    ]);
    // ...
}

З useForm помилки потрапляють у form.errors автоматично. Без нього - через prop:

<script setup>
defineProps({ errors: Object });
</script>
<template>
  <p v-if="errors.email">{{ errors.email }}</p>
</template>

Введене не губиться: після перенаправлення назад Inertia за замовчуванням зберігає стан компонента сторінки, тож поля не очищаються.

Error bags - для сторінки з кількома формами з однаковими назвами полів (дві форми з полем email):

router.post('/companies', data, { errorBag: 'createCompany' });
router.post('/users', data, { errorBag: 'createUser' });
// помилки прийдуть у page.props.errors.createCompany / .createUser

З useForm error bags не потрібні - помилки й так прив'язані до екземпляра форми.

Усі помилки поля, а не лише перша. За замовчуванням адаптер Laravel повертає по одній помилці на поле. Для всіх - protected $withAllErrors = true; у HandleInertiaRequests; тоді кожне поле містить масив рядків.

Часткові перезавантаження й помилки. errors передається завжди, тож порожній набір помилок з сервера затре клієнтські. Щоб зберегти помилки під час router.reload({ only: [...] }), у Inertia 3 є опція preserveErrors: true.

Попередня валідація (до відправки) - Laravel Precognition, підтримку якої вбудовано у форми Inertia.

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

Сторінка списку користувачів має props users і companies (для фільтра). Користувач змінює фільтр - потрібні нові users, а companies не змінилися. Отримувати й перераховувати все - марна робота.

Часткове перезавантаження - запит лише вибраних props тієї самої сторінки:

import { router } from '@inertiajs/vue3';

router.reload({ only: ['users'], data: { company: 3 } });
router.reload({ except: ['companies'] });
<Link href="/users?active=1" :only="['users']">Активні</Link>

Працює лише для візитів на той самий компонент сторінки.

Щоб сервер справді не виконував зайве - props мають бути замиканнями:

return Inertia::render('Users/Index', [
    'users' => fn () => User::query()->filter(request()->all())->paginate(),
    'companies' => fn () => Company::orderBy('name')->get(['id', 'name']),
]);

Без замикання Company::all() виконається на кожен запит, навіть якщо результат не потрапить у відповідь.

Типи props:

Запис Звичайний візит Часткове перезавантаження Обчислюється
User::all() так за запитом завжди
fn () => User::all() так за запитом лише коли потрібен
Inertia::optional(fn () => ...) ні за запитом лише коли потрібен
Inertia::always(...) так завжди завжди
  • Inertia::optional() - prop, який сторінка отримує лише на явний запит (only): дані для вкладки, яку відкривають рідко. У Inertia 3 він замінив видалений Inertia::lazy();
  • Inertia::always() - потрапляє навіть у часткові перезавантаження (так передаються помилки валідації).

Inertia 3: only/except підтримують крапкову нотацію для вкладених props (only: ['auth.notifications']), а optional, defer, merge працюють на будь-якій глибині масиву.

Поєднання з once(): Inertia::optional(fn () => ...)->once() - завантажити один раз і пам'ятати на клієнті між переходами.

Докладніше в документації: Inertia: часткові перезавантаження

Повідомлення «Користувача створено» має з'явитися один раз після дії й не повторюватися. Раніше в Inertia-застосунках його передавали через session()->flash() і спільні дані middleware. У Inertia 3 для цього є окремий механізм - флеш-дані.

На сервері:

use Inertia\Inertia;

public function store(StoreUserRequest $request)
{
    $user = User::create($request->validated());

    Inertia::flash('toast', ['type' => 'success', 'message' => 'Користувача створено']);

    return back();
    // або коротше: return Inertia::flash('newUserId', $user->id)->back();
}

flash() можна поєднувати і з render(): Inertia::render(...)->flash('highlight', $id).

На клієнті - у page.flash, окремо від props:

<script setup>
import { usePage } from '@inertiajs/vue3';
const page = usePage();
</script>

<template>
  <div v-if="page.flash.toast" class="toast">{{ page.flash.toast.message }}</div>
</template>

Або реагувати подією - в одному місці макета:

router.on('flash', (event) => {
  if (event.detail.flash.toast) showToast(event.detail.flash.toast);
});

router.post('/users', data, {
  onFlash: ({ newUserId }) => highlight(newUserId),
});

Чим кращий за флеш через сесію в props:

  • не потрапляє в історію браузера. Props зберігаються в стані історії: користувач натиснув «Назад» - і старе «Збережено» знову на екрані. Флеш-дані в історію не пишуться;
  • не змішується з props сторінки;
  • працює однаково з редиректом і без нього: middleware сам зберігає флеш у сесію при перенаправленні.

Що варто знати:

  • обробники router.on(...), зареєстровані в компонентах, треба знімати при демонтажі (функція, яку повертає on), інакше в непостійних макетах вони накопичуються;
  • флеш - для одноразових повідомлень, а не для даних, які мають бути на сторінці після оновлення;
  • як і props, флеш-дані видно в браузері - лише публічна інформація.

Докладніше в документації: Inertia: флеш-дані

defineEmits оголошує, які події компонент може випромінювати і з якими аргументами.

Сучасний синтаксис (Vue 3.3+) - іменовані кортежі:

<script setup lang="ts">
const emit = defineEmits<{
  change: [id: number]
  update: [value: string, source: 'keyboard' | 'paste']
  close: []
}>()

emit('change', 42)
emit('update', 'текст', 'paste')
emit('change', 'abc')   // помилка TypeScript
emit('unknown')         // помилка TypeScript
</script>

Старіший синтаксис - сигнатури викликів, працює так само:

const emit = defineEmits<{
  (e: 'change', id: number): void
  (e: 'close'): void
}>()

Батьківський компонент отримує перевірку типів у шаблоні: @change="(id) => select(id)" - id має тип number, а підписка на неоголошену подію підсвічується редактором (з розширенням Vue - Official і vue-tsc).

Навіщо оголошувати події взагалі:

  • документація компонента: з першого рядка видно його «вихідний» інтерфейс;
  • атрибути-слухачі не протікають на корінь: оголошена подія не потрапляє у $attrs. Неоголошена - потрапляє і може навісити нативний обробник на кореневий елемент, через що @click спрацює двічі;
  • перевірка в режимі розробки: з runtime-валідацією (об'єктний синтаксис з функціями-валідаторами) Vue попередить про некоректне корисне навантаження.

Типові помилки:

  • події в стилі camelCase і kebab-case: у шаблоні батька пишуть @item-selected, у дочірньому emit('itemSelected') - Vue їх зіставляє, але в типах і пошуку по коду краще одна форма;
  • зміна props замість події: дочірній компонент мутує об'єкт із props («він же не примітив, працює») - потік даних стає непрозорим. Зміну даних батька - лише через подію чи defineModel;
  • занадто багато подій - знак, що компонент робить забагато або що частину стану варто винести в стор.

Докладніше в документації: TypeScript: типізація emits

v-model на компоненті - це скорочення для пари «prop + подія оновлення»:

<PriceInput v-model="price" />
<!-- те саме, що -->
<PriceInput :modelValue="price" @update:modelValue="(v) => price = v" />

До Vue 3.4 дочірній компонент мусив оголосити prop, подію і зв'язати їх вручну. defineModel (3.4+) робить це одним рядком:

<!-- PriceInput.vue -->
<script setup lang="ts">
const model = defineModel<number>({ required: true })
</script>

<template>
  <input type="number" v-model="model" />
</template>

model - ref: читання повертає значення від батька, присвоєння model.value = 10 випромінює update:modelValue. Компілятор створює і prop modelValue, і подію автоматично.

Кілька моделей і назви:

<!-- батько -->
<DateRange v-model:from="startDate" v-model:to="endDate" />

<!-- DateRange.vue -->
<script setup lang="ts">
const from = defineModel<string>('from')
const to = defineModel<string>('to')
</script>

Модифікатори (v-model.trim) доступні через деструктуризацію:

const [model, modifiers] = defineModel<string>({
  set(value) {
    return modifiers.trim ? value.trim() : value
  },
})

Значення за замовчуванням і розсинхронізація. Якщо модель має default, а батько не передав їй значення (його змінна undefined), дочірній компонент працюватиме зі своїм значенням за замовчуванням: у батька undefined, у дитини, наприклад, 1. Документація прямо попереджає про цю розсинхронізацію - надійніше, щоб початкове значення задавав батько.

Пастки:

  • model.value.push(x) для масиву змінює об'єкт батька напряму, без події - краще присвоювати новий масив (model.value = [...model.value, x]);
  • v-model на <input> усередині з тим самим model - нормальний і найкоротший шлях обгорнути нативне поле.

Докладніше в документації: v-model на компонентах

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

Рівні
Junior 36 Middle 37 Senior 35

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