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

Middle: питання на співбесіді з теми «Реактивність»

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

5 питань

  • 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, що вже розв'язують гонитву, кешування й повтори.

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