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

Middle: питання на співбесіді з теми «Компоненти й Composition API»

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

5 питань

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 на компонентах

Компонент повідомляє батька про події через 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 }).

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