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

Vue: питання на співбесіді рівня Middle

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

37 питань

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

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

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

Інші рівні
Junior 36 Senior 35

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