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

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

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

5 питань

Основа - Proxy і два хуки: track і trigger.

  • reactive() загортає об'єкт у Proxy. Обробник get викликає track: запам'ятовує, що поточний «ефект» (рендер компонента, computed, watch) читає це поле.
  • Обробник set викликає trigger: знаходить усі ефекти, що читали поле, і планує їх перезапуск.
  • ref робить те саме через геттер і сеттер на .value - тому він працює з примітивами, які проксувати неможливо.
// спрощено
const state = new Proxy(target, {
  get(obj, key) { track(obj, key); return obj[key]; },
  set(obj, key, value) { obj[key] = value; trigger(obj, key); return true; },
});

Рендер компонента - теж ефект: під час рендеру він «підписується» рівно на ті поля, які прочитав шаблон. Зміна поля, якого шаблон не читає, рендер не запускає.

Планувальник і nextTick. Зміни не перемальовують DOM одразу. Vue збирає всі ефекти, які треба перезапустити, у чергу й виконує їх один раз в мікрозадачі. Десять змін поспіль - один рендер.

Звідси nextTick: одразу після зміни стану DOM ще старий.

items.value.push(newItem);
await nextTick();
listEl.value.lastElementChild.scrollIntoView(); // DOM уже оновлено

Наслідки для продуктивності:

  • Глибока реактивність проксує вкладені об'єкти ліниво, при доступі. Великі незмінні дані (результат API на тисячі рядків) дешевше тримати в shallowRef, де відстежується лише заміна .value.
  • markRaw позначає об'єкт, який Vue ніколи не має робити реактивним: екземпляри сторонніх бібліотек, карти, редактори.

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

Глибока реактивність має ціну: кожен вкладений об'єкт стає проксі, кожне читання поля - виклик track. На списку з десятків тисяч об'єктів з десятками полів це помітно і в пам'яті, і в часі.

Інструменти:

  • shallowRef - реактивна лише заміна .value, вміст не проксується. Ідеально для даних з API, які не редагують по полю, а замінюють цілком:
const rows = shallowRef([]);
rows.value = await fetchRows();            // рендер спрацює
rows.value[0].name = 'x';                  // а тут - ні, і це очікувано
rows.value = [...rows.value];              // явна заміна, щоб оновити
  • shallowReactive - реактивні лише поля верхнього рівня.
  • markRaw - об'єкт ніколи не стане реактивним: екземпляри Mapbox, Chart.js, редакторів, класи з власним станом. Проксі навколо них не лише дорогий, а й може ламати їхню внутрішню логіку.
  • Object.freeze на даних, які не змінюються, - Vue пропускає їх при перетворенні на реактивні.

Не реактивністю єдиною:

  • Віртуалізація списків - рендерити лише видимі рядки (vue-virtual-scroller, TanStack Virtual). Тисяча DOM-вузлів важча за будь-які проксі.
  • v-memo - пропускати оновлення частини шаблону, доки не змінилися вказані значення.
  • Стабільні props: передавати в дочірні компоненти примітиви чи стабільні посилання, а не нові об'єкти на кожен рендер.
  • v-once - для частин шаблону, що відмальовуються раз і не змінюються.

Як знайти проблему: вкладка Performance у Vue DevTools показує час рендеру кожного компонента, а браузерний профайлер - скільки часу йде на проксі й track.

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

Кожен computed, watch і watchEffect, створений у setup компонента, Vue автоматично прив'язує до компонента і зупиняє, коли компонент знищується. Поза компонентом такої прив'язки немає - ефекти живуть, доки їх не зупинити вручну.

effectScope збирає всі ефекти, створені всередині, в одну групу, яку можна зупинити одним викликом:

import { effectScope, ref, watch, computed } from 'vue';

const scope = effectScope();

scope.run(() => {
  const query = ref('');
  const results = computed(() => search(query.value));

  watch(query, (value) => saveToHistory(value));
  watchEffect(() => console.log(results.value.length));
});

// пізніше - зупинити все разом
scope.stop();

Де це потрібно:

1. Спільний стан composable для кількох компонентів («спільний composable»): ефекти мають жити, поки є хоча б один споживач, і зупинитися, коли зник останній:

function createSharedComposable(factory) {
  let consumers = 0;
  let state, scope;

  return () => {
    consumers++;
    if (!state) {
      scope = effectScope(true);   // detached: не прив'язаний до поточного компонента
      state = scope.run(factory);
    }
    onScopeDispose(() => {
      if (--consumers === 0) {
        scope.stop();
        state = scope = undefined;
      }
    });
    return state;
  };
}

Саме так влаштований createSharedComposable у VueUse.

2. Бібліотеки й сховища. Pinia створює effectScope для кожного стора - тому $dispose() прибирає всі його спостерігачі.

3. Тести - ізолювати й гарантовано прибрати ефекти між тестами.

4. Тимчасові ефекти - наприклад, на час відкритого модального вікна чи активного режиму редагування.

onScopeDispose(fn) - реєструє очищення для поточної області: в компоненті спрацює при його знищенні, в effectScope - при stop(). Тому composable, що використовує onScopeDispose замість onUnmounted, працює і в компонентах, і поза ними.

getCurrentScope() - дізнатися, чи код виконується всередині області (наприклад, щоб composable попередив, що його викликали поза компонентом).

Без цього composable, викликаний поза setup (у сторі, в роутері, в модулі), створює спостерігачів, які ніколи не зупиняться, - витоки пам'яті в довгоживучих SPA.

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

customRef дає змогу створити ref з власною логікою відстеження й сповіщення про зміни. Фабрика отримує дві функції:

  • track() - зареєструвати залежність (викликати при читанні);
  • trigger() - сповістити залежних про зміну (викликати, коли значення справді змінилося).

Класичний приклад - ref із затримкою (debounce):

import { customRef } from 'vue';

export function useDebouncedRef(initial, delay = 300) {
  let value = initial;
  let timeout;

  return customRef((track, trigger) => ({
    get() {
      track();
      return value;
    },
    set(newValue) {
      clearTimeout(timeout);
      timeout = setTimeout(() => {
        value = newValue;
        trigger();
      }, delay);
    },
  }));
}
<script setup>
const query = useDebouncedRef('', 400);

watch(query, (value) => search(value));   // запит лише після паузи у введенні
</script>

<template>
  <input v-model="query">
</template>

Поле оновлюється в шаблоні... тільки після затримки - бо значення змінюється в set із запізненням. Для пошуку це прийнятно, а для звичайного введення - ні: користувач не бачитиме набраного тексту. Тому частіше роблять інакше: звичайний ref для поля і окремий debounced-ref (чи watch з затримкою) для запиту.

Інші застосування customRef:

  • ref, синхронізований з localStorage - читання з кешу, запис у сховище;
  • ref над зовнішнім джерелом (значення з бібліотеки, що має власні події змін) - trigger() з обробника події бібліотеки;
  • валідація чи нормалізація при записі (обрізати пробіли, обмежити діапазон);
  • лінива ініціалізація дорогого значення при першому читанні.

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

  • get має повертати однакове значення для однакового стану - Vue може викликати його багато разів;
  • не забувати track() - без нього шаблон не оновиться, хоча trigger() викликано;
  • документація окремо застерігає від get, що щоразу створює новий об'єкт: якщо такий ref передано в props, кожен рендер батька дає дочірньому компоненту «нове» значення, і той перерендерюється без потреби;
  • готові варіанти вже є у VueUse: refDebounced, useLocalStorage, refThrottled.

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

reactive() повертає проксі над оригінальним об'єктом, а не сам об'єкт:

const raw = { a: 1 };
const state = reactive(raw);

state === raw;            // false
toRaw(state) === raw;     // true
reactive(raw) === state;  // true - для одного оригіналу Vue повертає той самий проксі

Зміни через проксі змінюють оригінал (і запускають оновлення), а зміни оригіналу напряму - не запускають нічого.

Де це вилазить:

  • порівняння й пошук: items.value.indexOf(obj) чи Set.has(obj) з оригінальним об'єктом, коли в масиві лежать проксі (або навпаки), дають «не знайдено». Vue перехоплює indexOf/includes у реактивних масивах, щоб пошук сирого об'єкта працював, але в Map, Set і власному коді порівняння за посиланням легко помилитися;
  • передача в сторонні бібліотеки: бібліотека отримує проксі. Бібліотеки, що порівнюють об'єкти за посиланням, змінюють їх інтенсивно чи покладаються на приватні поля (#field), можуть працювати неправильно або повільно;
  • класи з приватними полями: метод, викликаний через проксі, отримує this = проксі, а в проксі немає #field - TypeError;
  • structuredClone(state) падає на проксі - потрібно structuredClone(toRaw(state)).

toRaw(proxy) - отримати оригінал. Корисно для:

  • передачі даних у сторонні бібліотеки, postMessage, IndexedDB, structuredClone;
  • тимчасового читання/запису без відстеження й оновлень (масові зміни, після яких одне оновлення).

markRaw(obj) - позначити об'єкт як ніколи не реактивний: Vue не обгортатиме його в проксі навіть усередині реактивного стану.

const map = markRaw(new mapboxgl.Map({ container: 'map' }));
state.map = map;   // лишається звичайним об'єктом

Застосовують для екземплярів сторонніх бібліотек (карти, редактори, графіки), складних класів, великих незмінних структур.

shallowRef / shallowReactive - реактивність лише на верхньому рівні: заміна значення відстежується, зміни всередині - ні. Добре для великих даних, що замінюються цілком.

Застереження: toRaw і markRaw - інструменти для конкретних проблем. Змішування сирих і реактивних версій того самого об'єкта в коді - джерело помилок «чому не оновилося». Документація Vue радить тримати стабільну межу: або завжди проксі, або завжди сирий.

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