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

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

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

108 питань

Стандартний набір для тестування Vue-компонентів:

  • Vitest - запускач тестів на основі Vite: та сама конфігурація, що й у проєкті, підтримка .vue-файлів і TypeScript без додаткових налаштувань;
  • Vue Test Utils (@vue/test-utils) - монтує компонент, дає змогу знаходити елементи, взаємодіяти з ними й перевіряти результат;
  • jsdom чи happy-dom - імітація DOM браузера в Node.js.
npm i -D vitest @vue/test-utils jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: { environment: 'jsdom' },
})

Тест:

import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Counter from './Counter.vue'

describe('Counter', () => {
  it('starts from the given value and increments', async () => {
    const wrapper = mount(Counter, { props: { start: 2 } })

    expect(wrapper.get('[data-test="count"]').text()).toBe('2')

    await wrapper.get('button').trigger('click')

    expect(wrapper.get('[data-test="count"]').text()).toBe('3')
  })
})

Ключові моменти:

  • mount рендерить компонент разом з усіма дочірніми;
  • props передають значення як від батька; також є slots і global (плагіни, заглушки, provide);
  • trigger повертає Promise - await потрібен, щоб Vue встиг оновити DOM;
  • get кидає помилку, якщо елемент не знайдено (з зрозумілим повідомленням), find повертає порожню обгортку - зручно для перевірки відсутності: expect(wrapper.find('.error').exists()).toBe(false).

Атрибути data-test для пошуку елементів кращі за класи й структуру: стилі й розмітка змінюються, а тест - ні.

Що перевіряти: те, що бачить і робить користувач - текст, видимі елементи, події, виклики API, - а не внутрішні змінні компонента. Тоді рефакторинг не ламає тести.

Запуск: npx vitest (режим спостереження) чи npx vitest run у CI.

Докладніше в документації: Vue Test Utils: початок

Введення в поле - setValue, клік чи інша подія - trigger. Обидва повертають Promise, який завершується після оновлення DOM:

const wrapper = mount(SearchForm)

await wrapper.get('input[name="query"]').setValue('laravel')
await wrapper.get('select').setValue('php')
await wrapper.get('input[type="checkbox"]').setValue(true)
await wrapper.get('form').trigger('submit.prevent')

setValue встановлює значення і генерує відповідну подію (input чи change), тож v-model спрацює як у браузері.

Клавіші й модифікатори:

await wrapper.get('input').trigger('keydown', { key: 'Enter' })
await wrapper.get('input').trigger('keydown.enter')

Перевірка подій компонента - emitted():

await wrapper.get('[data-test="save"]').trigger('click')

expect(wrapper.emitted()).toHaveProperty('save')
expect(wrapper.emitted('save')).toHaveLength(1)
expect(wrapper.emitted('save')![0]).toEqual([{ id: 7, title: 'Новий' }])

emitted('save') - масив викликів, кожен виклик - масив аргументів. Тому для emit('changed', 3) результат [[3]], а не [3].

v-model на власному компоненті перевіряється через подію оновлення:

expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['нове значення'])

Що варто пам'ятати:

  • await після trigger/setValue обов'язковий, якщо далі перевіряється DOM. Без нього тест бачить старий стан;
  • запити й інші Promise всередині обробника можуть завершитися пізніше за наступний тік рендеру - тоді потрібен flushPromises();
  • вимкнені елементи: trigger на кнопці з disabled подію не генерує - це відповідає браузеру і зручно для перевірки, що заблокована кнопка нічого не робить;
  • події, яких не має бути, перевіряються так само: expect(wrapper.emitted('save')).toBeUndefined().

Перевіряйте результат, а не виклик методу. Тест «після кліку з'явилось повідомлення й пішла подія» витримає рефакторинг, а «викликався метод onSave» - ні.

Докладніше в документації: Vue Test Utils: обробка подій

Props передають при монтуванні й змінюють пізніше через setProps:

const wrapper = mount(PriceTag, {
  props: { amount: 1250, currency: 'UAH' },
})

expect(wrapper.text()).toContain('1 250')

await wrapper.setProps({ amount: 99 })
expect(wrapper.text()).toContain('99')

setProps повертає Promise - після await DOM уже оновлений. Так перевіряють, що компонент правильно реагує на зміну props, а не лише на початкові значення.

Слоти - рядками з розміткою, компонентами чи функціями рендеру:

const wrapper = mount(Card, {
  slots: {
    default: '<p>Основний вміст</p>',
    header: '<h2>Заголовок</h2>',
    footer: FooterActions,
  },
})

expect(wrapper.get('header').text()).toBe('Заголовок')
expect(wrapper.html()).toContain('Основний вміст')

Scoped slots - шаблон отримує параметри слоту через params:

const wrapper = mount(DataList, {
  props: { items: [{ id: 1, name: 'Оля' }] },
  slots: {
    item: '<span class="name">{{ params.item.name }}</span>',
  },
})

expect(wrapper.get('.name').text()).toBe('Оля')

Так перевіряється контракт слоту: які дані компонент передає назовні.

Атрибути, що не є props (class, id, data-*), перевіряються на корені:

const wrapper = mount(BaseButton, { attrs: { class: 'w-full', 'data-test': 'submit' } })
expect(wrapper.classes()).toContain('w-full')

Що варто перевіряти:

  • граничні значення props: порожній масив, null, дуже довгий текст, нуль - саме там живуть баги;
  • відсутній слот: компонент має коректно рендеритися, якщо необов'язковий слот не передали (і, наприклад, не показувати порожню обгортку);
  • заглушка за замовчуванням у слоті показується, коли вміст не передано.

Чого не варто: перевіряти, що prop «дійшов» у внутрішню змінну через wrapper.vm - це деталь реалізації. Перевіряйте вивід.

Докладніше в документації: Vue Test Utils: передача даних

Найпростіший підхід - один реактивний об'єкт з усіма полями форми і v-model на кожному полі:

<script setup lang="ts">
import { reactive, ref } from 'vue'

const initial = () => ({ name: '', email: '', role: 'editor', agree: false })

const form = reactive(initial())
const submitting = ref(false)

async function submit() {
  submitting.value = true
  try {
    await api.post('/users', { ...form })
    Object.assign(form, initial())   // скидання
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model.trim="form.name" />
    <input v-model="form.email" type="email" />
    <select v-model="form.role">
      <option value="editor">Редактор</option>
      <option value="admin">Адміністратор</option>
    </select>
    <input v-model="form.agree" type="checkbox" />
    <button :disabled="submitting">Зберегти</button>
  </form>
</template>

reactive чи ref:

reactive({...}) ref({...})
доступ у <script> form.email form.value.email
замінити весь об'єкт ні - лише Object.assign так: form.value = initial()
деструктуризація ламає реактивність теж ламає (form.value - реактивний об'єкт)

ref зручніший, коли форму треба повністю замінити (завантажили дані для редагування). reactive - коротший запис полів.

Модифікатори v-model: .trim прибирає пробели, .number перетворює на число, .lazy оновлює значення на change, а не на кожен введений символ.

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

  • @submit без .prevent - браузер відправляє форму звичайним запитом і перезавантажує сторінку;
  • скидання через form = initial() для reactive - зміниться лише локальна змінна, а шаблон лишиться прив'язаним до старого об'єкта;
  • відправка самого реактивного проксі - бібліотеки зазвичай серіалізують його коректно, але безпечніше передавати звичайну копію ({ ...form } чи toRaw(form));
  • checkbox для масиву: кілька чекбоксів з одним v-model і масивом у стані дають список вибраних значень;
  • немає захисту від подвійного кліку - кнопка має бути вимкнена на час відправки.

Для складних форм (валідація, вкладені масиви, стан «змінено») варто взяти бібліотеку форм, а не винаходити все самому.

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

Коли валідація Laravel не проходить у запиті, що очікує JSON, сервер відповідає статусом 422 і тілом:

{
  "message": "The email has already been taken. (and 1 more error)",
  "errors": {
    "email": ["The email has already been taken."],
    "items.0.qty": ["The items.0.qty field must be at least 1."]
  }
}

Ключі - назви полів, для вкладених даних - через крапку. Значення - масив повідомлень.

Обробка в Vue:

<script setup lang="ts">
import { reactive, ref } from 'vue'
import axios from 'axios'

const form = reactive({ name: '', email: '' })
const errors = ref<Record<string, string[]>>({})

async function submit() {
  errors.value = {}
  try {
    await axios.post('/api/users', form, { headers: { Accept: 'application/json' } })
  } catch (e) {
    if (axios.isAxiosError(e) && e.response?.status === 422) {
      errors.value = e.response.data.errors
      return
    }
    throw e   // інші помилки - далі, до глобального обробника
  }
}
</script>

<template>
  <input v-model="form.email" :aria-invalid="!!errors.email" />
  <p v-if="errors.email" class="error">{{ errors.email[0] }}</p>
</template>

З fetch код схожий, але fetch не кидає виняток на 422 - перевіряти треба response.status самостійно, а тіло читати через await response.json().

Важливі деталі:

  • заголовок Accept: application/json обов'язковий: без нього Laravel вважає запит звичайним переходом форми й відповідає редиректом назад з помилками в сесії, а не JSON;
  • очищайте помилки перед новою відправкою, інакше старі повідомлення лишаються на екрані;
  • прибирайте помилку поля при його зміні - watch на поле чи очищення в @input - користувач бачить, що виправлення прийнято;
  • вкладені ключі (items.0.qty) треба зіставити з полем у списку - зберігайте помилки за тим самим шаблоном назв;
  • 401 і 419 - це не валідація: 401 - не автентифікований, 419 - прострочений CSRF-токен (для SPA на Sanctum спершу потрібен запит до /sanctum/csrf-cookie).

Типізація: опишіть тип відповіді 422 ({ message: string; errors: Record<string, string[]> }) один раз і використовуйте в усіх формах - через спільну функцію чи composable useServerErrors().

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

Доступна форма - та, яку можна заповнити з клавіатури й зрозуміти з екранним читачем. Більшість потрібного дає правильний HTML, а Vue лише має не зламати його.

1. Мітка для кожного поля, пов'язана з полем:

<script setup>
import { useId } from 'vue'
const id = useId()
</script>

<template>
  <label :for="id">Email</label>
  <input :id="id" v-model="form.email" type="email" autocomplete="email" required />
</template>

Плейсхолдер - не мітка: він зникає при введенні й часто має низький контраст. useId() (Vue 3.5) генерує стабільний id, однаковий на сервері й у браузері при SSR.

2. Помилка, пов'язана з полем:

<input
  :id="id"
  v-model="form.email"
  :aria-invalid="errors.email ? 'true' : undefined"
  :aria-describedby="errors.email ? `${id}-error` : undefined"
/>
<p v-if="errors.email" :id="`${id}-error`">{{ errors.email[0] }}</p>
  • aria-invalid повідомляє, що значення некоректне;
  • aria-describedby - екранний читач прочитає текст помилки разом з назвою поля;
  • помилка не повинна передаватися лише кольором (червона рамка) - потрібен текст.

3. Фокус на першому полі з помилкою після відправки:

import { nextTick } from 'vue'

async function onInvalid() {
  await nextTick()   // дочекатися рендеру повідомлень
  document.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
}

Без цього користувач клавіатури чи екранного читача натискає «Зберегти» - і нічого не відбувається з його точки зору.

4. Зведення помилок для довгих форм: блок з role="alert" нагорі зі списком помилок-посилань на поля.

Ще кілька правил:

  • <button type="submit"> у <form>, а не div з @click - тоді працює Enter і екранні читачі розуміють, що це форма;
  • групи перемикачів - у <fieldset> з <legend>;
  • autocomplete (email, new-password, one-time-code) - менеджери паролів і мобільні клавіатури;
  • стан відправки - aria-busy чи текст на кнопці, а не лише спінер;
  • не вимикайте кнопку відправки до заповнення всіх полів: користувач не розуміє, чого бракує. Краще дозволити натиснути й показати помилки.

Перевірка: пройти форму лише з клавіатури (Tab, Enter, Space) і запустити axe DevTools чи Lighthouse.

Докладніше в документації: MDN: aria-invalid

  • watch(source, callback) стежить за явно вказаним джерелом. Колбек отримує нове й старе значення і за замовчуванням не запускається одразу - лише при першій зміні.
  • watchEffect(callback) запускається одразу і сам відстежує всі реактивні значення, які прочитав під час виконання. Старого значення немає.
watch(userId, async (id, oldId) => {
  user.value = await fetchUser(id);
});

watchEffect(async () => {
  user.value = await fetchUser(userId.value); // залежність від userId знайдена сама
});

Опції watch:

  • immediate: true - викликати колбек одразу з поточним значенням, як watchEffect.
  • deep: true - реагувати на зміни вкладених полів об'єкта, а не лише на заміну всього значення. За reactive-об'єктом watch стежить глибоко автоматично. З Vue 3.5 deep приймає й число - глибину обходу.
  • once: true (3.4+) - спрацювати лише раз.

Нюанси:

  • watchEffect відстежує лише те, що прочитано синхронно до першого await. Значення, прочитані після, у залежності не потрапляють.
  • deep на великій структурі - дорогий обхід при кожній зміні. Краще стежити за конкретним полем: watch(() => form.email, ...).
  • Гонки: якщо userId змінився двічі швидко, відповіді можуть прийти не в тому порядку. onCleanup (чи onWatcherCleanup у 3.5) дозволяє скасувати попередній запит.

Правило: watch - коли важливо, що саме змінилося і нові/старі значення; watchEffect - коли ефект просто має відповідати поточному стану.

Докладніше в документації: Спостерігачі

Реактивність Vue працює через проксі: Vue знає про зміну, бо зміна йде через проксі (state.count = 1). Деструктуризація копіює значення полів у звичайні змінні - зв'язок із проксі втрачається.

const state = reactive({ count: 0, name: 'Оля' });

const { count } = state;   // просто число 0
state.count++;             // count лишився 0

Як уникнути:

  • toRefs перетворює кожне поле на ref, пов'язаний з початковим об'єктом:
const { count, name } = toRefs(state);
count.value++;             // змінює state.count
  • toRef - для одного поля: const count = toRef(state, 'count').
  • Повертати з composables ref-и, а не reactive, - тоді деструктуризація на місці виклику безпечна.

Props: props - теж реактивний об'єкт. До Vue 3.5 const { title } = defineProps(...) давав нереактивну змінну. З Vue 3.5 деструктуризація defineProps у <script setup> реактивна: компілятор сам перетворює title на props.title. Але передати таку змінну у функцію чи watch напряму не вийде - потрібен геттер: watch(() => title, ...).

Ще місця, де реактивність губиться:

  • Заміна всього reactive-об'єкта: state = reactive({...}).
  • Передача примітиву в функцію: useSomething(state.count) передає число, а не реактивне джерело. Передавайте toRef(state, 'count') чи геттер () => state.count.

Докладніше в документації: Обмеження reactive()

Ці утиліти розв'язують одну проблему: як передати реактивне значення далі, не втративши реактивності.

toRefs(reactiveObj) - перетворює кожну властивість reactive-об'єкта на ref, пов'язаний з оригіналом. Головне застосування - повернути стан з composable так, щоб його можна було деструктуризувати:

function useMouse() {
  const state = reactive({ x: 0, y: 0 });
  // ...
  return toRefs(state);
}

const { x, y } = useMouse();   // x і y - ref-и, реактивні

toRef - один ref:

const title = toRef(props, 'title');   // ref, що читає props.title
const fullName = toRef(() => `${props.first} ${props.last}`);   // ref тільки для читання з геттера

toValue(source) (3.3+) - «розгорнути що завгодно»: ref - .value, геттер - результат виклику, звичайне значення - як є.

Composable, що приймає ref, геттер або значення - тип MaybeRefOrGetter:

import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue';

export function useFetch<T>(url: MaybeRefOrGetter<string>) {
  const data = ref<T | null>(null);

  watchEffect(async () => {
    data.value = null;
    const response = await fetch(toValue(url));   // читання всередині ефекту = залежність
    data.value = await response.json();
  });

  return { data };
}
useFetch('/api/users');                              // рядок
useFetch(userUrl);                                   // ref
useFetch(() => `/api/users/${props.id}`);           // геттер від props

Чому toValue треба викликати всередині watchEffect/computed: саме там Vue відстежує читання. Якщо розгорнути значення один раз на початку composable, зміни props.id вже не підхопляться.

Типова помилка з props:

useFetch(`/api/users/${props.id}`);   // рядок обчислено один раз - не реактивно
useFetch(() => `/api/users/${props.id}`);   // правильно

unref - старший аналог toValue, що не викликає геттери. У новому коді краще toValue.

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

До Vue 3.5 деструктуризація props губила реактивність: const { title } = defineProps(...) давала звичайне значення на момент створення компонента.

З Vue 3.5 деструктуризація з defineProps реактивна:

<script setup>
const { title, count = 0 } = defineProps(['title', 'count']);
</script>

<template>
  <h1>{{ title }}</h1>
  <p>{{ count }}</p>
</template>

Компілятор <script setup> переписує звернення title на __props.title, тож значення завжди актуальне. Заодно це найзручніший спосіб задати значення за замовчуванням - звичайним синтаксисом JavaScript замість withDefaults.

Пастка - передача у функцію чи watch:

const { id } = defineProps(['id']);

watch(id, () => { /* ... */ });          // помилка компіляції: передано значення, а не джерело
watch(() => id, () => { /* ... */ });    // правильно: геттер

useFetch(id);                            // composable отримає число, а не реактивне джерело
useFetch(() => id);                      // правильно

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

Спостереження за props без деструктуризації:

const props = defineProps(['id', 'filters']);

watch(() => props.id, (id) => load(id));
watch(() => props.filters, reload, { deep: true });   // об'єкт - глибоко

watch(props.id, ...) - та сама помилка: значення замість джерела.

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

  • props змінювати не можна - вони належать батьківському компоненту. Для локальної копії - ref(props.initial) (свідомо не реактивна до змін батька) або computed;
  • об'єкти в props передаються за посиланням, і зміна їх полів технічно спрацює, але порушує односпрямований потік даних - батько не знатиме, хто змінив його стан;
  • для v-model на компоненті - defineModel(), а не зміна props.

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

Типова задача: при зміні id завантажити дані. Якщо id змінюється швидко (користувач клацає по списку), запити летять паралельно, і відповідь на старий запит може прийти пізніше за новий - на екрані опиняться дані не того запису.

watch(id, async (newId) => {
  const response = await fetch(`/api/users/${newId}`);
  user.value = await response.json();   // може перезаписати свіжі дані старими
});

Очищення побічного ефекту. Перед наступним запуском спостерігача (і при його зупинці) Vue викликає функцію очищення - туди кладуть скасування попереднього запиту:

import { watch, onWatcherCleanup } from 'vue';

watch(id, async (newId) => {
  const controller = new AbortController();
  onWatcherCleanup(() => controller.abort());   // Vue 3.5+

  try {
    const response = await fetch(`/api/users/${newId}`, { signal: controller.signal });
    user.value = await response.json();
  } catch (error) {
    if (error.name !== 'AbortError') throw error;
  }
});

Важливо: onWatcherCleanup треба викликати синхронно, до першого await - після нього Vue вже не знає, до якого спостерігача він належить.

До Vue 3.5 - третій аргумент колбеку:

watch(id, async (newId, oldId, onCleanup) => {
  const controller = new AbortController();
  onCleanup(() => controller.abort());
  // ...
});

Цей варіант працює й зараз і не має обмеження щодо await.

Інші застосування очищення: зняти таймер (clearInterval), відписатися від WebSocket-каналу, знищити екземпляр сторонньої бібліотеки, створений для попереднього значення.

Зупинка спостерігача:

  • спостерігачі, створені синхронно в setup, зупиняються автоматично разом з компонентом;
  • створені асинхронно (після await чи в setTimeout) - ні: їх треба зупинити вручну, інакше витік пам'яті:
const stop = watch(source, callback);
stop();

У Vue 3.5 повернений об'єкт має ще й pause() і resume().

Альтернатива для даних, що залежать від параметрів, - бібліотеки на кшталт TanStack Query, що вже розв'язують гонитву, кешування й повтори.

Докладніше в документації: Спостерігачі: очищення побічних ефектів

Composable - функція, яка використовує Composition API (ref, computed, watch, хуки життєвого циклу) і повертає стан та методи. Спосіб винести логіку зі стейтом із компонента й перевикористати її.

// useMouse.js
import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
  const x = ref(0);
  const y = ref(0);

  const update = (event) => { x.value = event.pageX; y.value = event.pageY; };

  onMounted(() => window.addEventListener('mousemove', update));
  onUnmounted(() => window.removeEventListener('mousemove', update));

  return { x, y };
}
const { x, y } = useMouse();

Чим це краще за mixins з Vue 2:

  • Зрозуміле джерело. З mixin незрозуміло, звідки в компоненті взялося this.isLoading. З composable видно: const { isLoading } = useFetch(...).
  • Немає конфліктів імен. Два mixin з полем data тихо перезаписують одне одне. Результати composable можна перейменувати при деструктуризації.
  • Параметри. Composable приймає аргументи, зокрема реактивні: useFetch(() => /api/users/${id.value}).
  • Типізація працює природно, бо це звичайні функції.

Правила: назва з use, виклик синхронно в setup (щоб хуки життєвого циклу прив'язалися до компонента), повертати ref-и, щоб деструктуризація не губила реактивність.

Готові composables для типових задач є в бібліотеці VueUse.

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

Слот - місце в шаблоні компонента, куди батько вставляє свою розмітку. Компонент задає рамку, а вміст вирішує той, хто його використовує.

<!-- Card.vue -->
<div class="card">
  <header><slot name="title" /></header>
  <slot />                         <!-- слот за замовчуванням -->
</div>
<Card>
  <template #title>Замовлення №42</template>
  <p>Деталі замовлення...</p>
</Card>

Scoped slot - слот, у який компонент передає дані назад батькові. Компонент знає дані, а батько - як їх показати.

<!-- DataTable.vue -->
<tr v-for="row in rows" :key="row.id">
  <slot name="row" :row="row" :selected="selectedId === row.id" />
</tr>
<DataTable :rows="orders">
  <template #row="{ row, selected }">
    <td :class="{ 'font-bold': selected }">{{ row.number }}</td>
    <td>{{ formatMoney(row.total) }}</td>
  </template>
</DataTable>

Навіщо: таблиці, списки, випадаючі меню й інші компоненти з логікою (сортування, вибір, пагінація), але без жорстко заданого вигляду. Це той самий патерн, що render props у React.

Корисне:

  • $slots.title (чи useSlots()) дозволяє перевірити, чи передали слот, і не рендерити порожню обгортку.
  • Вміст слота компілюється в області батька: він бачить змінні батька, а дані дитини - лише ті, що передані через scoped slot.

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

Батько отримує доступ до дочірнього компонента через template ref:

<script setup>
import { useTemplateRef } from 'vue';
import VideoPlayer from './VideoPlayer.vue';

const player = useTemplateRef('player');   // Vue 3.5+; раніше - const player = ref(null)

function startOver() {
  player.value?.seek(0);
  player.value?.play();
}
</script>

<template>
  <VideoPlayer ref="player" />
  <button @click="startOver">Спочатку</button>
</template>

Але компоненти з <script setup> закриті за замовчуванням: через ref батько не бачить нічого - ні змінних, ні функцій дитини. Те, що можна викликати ззовні, дитина оголошує явно:

<!-- VideoPlayer.vue -->
<script setup>
import { useTemplateRef } from 'vue';

const video = useTemplateRef('video');

function play() { video.value.play(); }
function seek(seconds) { video.value.currentTime = seconds; }

defineExpose({ play, seek });
</script>

<template>
  <video ref="video" src="/intro.mp4"></video>
</template>

Це навмисне рішення: публічний інтерфейс компонента - props, events, slots і явно відкриті методи, а не будь-яка внутрішня змінна.

Коли виклик методу дитини доречний - імперативні дії, які погано виражаються через стан:

  • фокус на полі (input.focus()), прокрутка до елемента;
  • керування медіа (play, pause, seek);
  • відкриття модального вікна сторонньої бібліотеки, скидання форми.

Коли краще інакше: якщо батько змінює дані дитини - це props чи v-model. Виклик методів для синхронізації стану робить потік даних непрозорим: незрозуміло, хто й коли змінив стан.

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

  • ref заповнюється після монтування - у setup батька він ще null; звертатися в onMounted чи обробниках подій;
  • ref на компонент усередині v-if стає null, коли компонент прибрано, - звідси ?.;
  • ref у v-for дає масив екземплярів;
  • TypeScript: тип відкритого API береться з defineExpose - useTemplateRef<InstanceType<typeof VideoPlayer>>('player') чи автоматичне виведення в Vue 3.5 з Volar.

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

v-model на компоненті - це скорочення для пари «prop + подія оновлення». З Vue 3.4 для цього є макрос defineModel():

<!-- QuantityInput.vue -->
<script setup>
const quantity = defineModel({ type: Number, default: 1 });
</script>

<template>
  <button type="button" @click="quantity--" :disabled="quantity <= 1">-</button>
  <span>{{ quantity }}</span>
  <button type="button" @click="quantity++">+</button>
</template>
<QuantityInput v-model="item.qty" />

defineModel() повертає ref: читання дає значення від батька, запис - надсилає подію update:modelValue, і батько оновлює свою змінну.

Що генерує компілятор (так писали до 3.4 і це варто розуміти):

const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
// v-model="item.qty" на батьку = :modelValue="item.qty" @update:modelValue="v => item.qty = v"

Кілька v-model на одному компоненті - іменовані моделі:

<script setup>
const firstName = defineModel('firstName');
const lastName = defineModel('lastName');
</script>
<UserName v-model:first-name="user.first" v-model:last-name="user.last" />

Модифікатори (власні чи вбудовані на кшталт .trim) читаються з другого елемента й обробляються в set:

const [title, modifiers] = defineModel({
  set(value) {
    return modifiers.capitalize ? value.charAt(0).toUpperCase() + value.slice(1) : value;
  },
});

Пастки:

  • значення за замовчуванням в defineModel({ default: ... }) і відсутність v-model на батьку: дитина бачить своє значення, а батько - undefined. Стан розсинхронізований;
  • об'єкт у моделі: зміна поля model.value.name = 'x' змінює об'єкт батька напряму, без події. Для об'єктів правильніше присвоювати новий об'єкт (model.value = { ...model.value, name: 'x' }) або мати окремі моделі на поля;
  • для нативного поля всередині - простіше прокинути v-model далі: <input v-model="value">, де value = defineModel().

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

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

Рівні
Junior 36 Middle 37 Senior 35

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