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

Питання на співбесіді: Реактивність

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

15 питань

Обидва створюють реактивний стан, за яким Vue стежить і перемальовує шаблон при змінах.

  • ref(value) - обгортка над будь-яким значенням: числом, рядком, об'єктом. Значення лежить у .value. У шаблоні .value писати не треба - Vue розгортає ref автоматично.
  • reactive(object) - реактивний проксі над об'єктом (чи масивом, Map). Поля читаються напряму, без .value.
import { ref, reactive } from 'vue';

const count = ref(0);
count.value++;

const form = reactive({ email: '', password: '' });
form.email = 'olia@example.com';

Обмеження reactive:

  • Працює лише з об'єктами - reactive(0) не має сенсу.
  • Не можна замінити цілком: form = reactive({...}) розриває зв'язок із шаблоном, бо шаблон стежить за старим проксі.
  • Деструктуризація губить реактивність: const { email } = form дає звичайний рядок.

Через ці обмеження документація Vue радить ref як основний інструмент: його можна передавати у функції, повертати з composables, перезаписувати цілком (items.value = newItems), і поведінка завжди однакова. reactive зручний для згрупованого стану на кшталт форми, який не замінюють і не деструктуризують.

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

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

const items = ref([]);
const total = computed(() => items.value.reduce((sum, i) => sum + i.price, 0));

Метод у шаблоні ({{ calcTotal() }}) викликається при кожному рендері компонента, навіть якщо items не змінювалися. Для важких обчислень це зайва робота.

watch - не значення, а побічна дія у відповідь на зміну: запит на сервер, запис у localStorage, ручна робота з DOM.

watch(searchQuery, async (query) => {
  results.value = await search(query);
});

Як обирати:

  • Потрібно значення для шаблону чи коду - computed.
  • Потрібно щось зробити, коли значення змінилося, - watch.
  • Потрібна дія з аргументами за запитом (обробник кліку) - метод.

Типова помилка - watch, що просто записує похідне значення в інший ref:

watch(items, () => { total.value = sum(items.value); }, { deep: true }); // зайве

Це той самий computed, але з ручною синхронізацією, яку легко зламати.

computed має бути чистим: без запитів, змін іншого стану чи DOM. Інакше результат залежатиме від того, коли й скільки разів Vue вирішить його перерахувати.

Докладніше в документації: Обчислювані властивості

У шаблоні .value писати не треба - але лише для ref верхнього рівня, тобто змінних, оголошених прямо в <script setup> (чи повернених з setup()).

<script setup>
import { ref } from 'vue';

const count = ref(1);
const item = { id: ref(1) };   // ref усередині звичайного об'єкта
</script>

<template>
  <p>{{ count + 1 }}</p>      <!-- 2 -->
  <p>{{ item.id }}</p>        <!-- 1 -->
  <p>{{ item.id + 1 }}</p>    <!-- [object Object]1 -->
</template>

Чому так:

  • count - ref верхнього рівня, у виразі він розгорнутий;
  • item.id - ref усередині звичайного об'єкта. Вираз item.id + 1 додає до об'єкта-ref число;
  • {{ item.id }} без обчислень показує 1 - це окреме правило: якщо кінцевий результат інтерполяції - ref, текст розгортається. Але для будь-якого виразу з ним - ні.

Як виправити:

const { id } = item;           // зробити ref верхнього рівня

або явно {{ item.id.value + 1 }}.

Інші правила розгортання:

  • у reactive() ref, що лежить як властивість, розгортається при доступі: state.count, а не state.count.value;
  • у масивах і колекціях (reactive([ref(1)]), Map) - не розгортається, потрібно .value;
  • props приходять уже розгорнутими значеннями - .value не потрібен.

Практичне правило, що знімає плутанину: не класти ref-и всередину звичайних об'єктів. Або окремі ref-и на верхньому рівні, або один reactive/ref з об'єктом, поля якого - звичайні значення.

Інструменти допомагають: Vue - Official (Volar) у редакторі підсвічує, де .value потрібен, а TypeScript покаже, що item.id + 1 додає Ref<number> до числа.

Докладніше в документації: Особливість розгортання в шаблонах

За замовчуванням computed - лише для читання: значення виводиться з інших даних. Спроба записати дає попередження в консолі, і нічого не змінюється.

Записувана обчислювана властивість - об'єкт з get і set:

import { ref, computed } from 'vue';

const firstName = ref('Оля');
const lastName = ref('Коваль');

const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value) => {
    [firstName.value, lastName.value] = value.split(' ');
  },
});

fullName.value = 'Іра Лі';
firstName.value;   // 'Іра'

set не зберігає значення сам - він змінює вихідні дані, з яких get потім перерахує результат.

Типові застосування:

1. v-model над перетвореним значенням - наприклад, ціна в копійках у стані й у гривнях у полі:

const priceCents = ref(12550);

const priceUah = computed({
  get: () => (priceCents.value / 100).toFixed(2),
  set: (value) => { priceCents.value = Math.round(Number(value) * 100); },
});
<input v-model="priceUah" type="number" step="0.01">

2. Обгортка над props + emit для v-model на компоненті (до Vue 3.4 - стандартний шаблон, тепер його замінює defineModel):

const value = computed({
  get: () => props.modelValue,
  set: (v) => emit('update:modelValue', v),
});

3. Синхронізація з URL чи сховищем: читання з параметра запиту, запис - через router.replace.

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

  • get має бути чистим: без побічних ефектів, запитів, змін іншого стану;
  • set не викликається, якщо змінити значення «всередині»: для об'єкта fullName.value.x = 1 змінює повернений об'єкт, а не викликає сеттер;
  • у Vue 3.4+ get отримує попереднє значення першим аргументом - зручно, щоб повернути той самий об'єкт, якщо нічого суттєво не змінилося, і не запускати зайві оновлення.

Докладніше в документації: Обчислювані властивості з записом

Vue оновлює шаблон лише тоді, коли змінюється реактивний стан - той, за яким система реактивності може стежити. Звичайна змінна такою не є.

<script setup>
let count = 0;                 // звичайна змінна
const increment = () => count++;
</script>

<template>
  <button @click="increment">{{ count }}</button>   <!-- завжди 0 -->
</template>

count справді збільшується в пам'яті, але Vue про це не знає й не перемальовує компонент.

Як правильно:

<script setup>
import { ref } from 'vue';

const count = ref(0);
const increment = () => count.value++;
</script>

Чому Vue не може стежити за звичайною змінною: JavaScript не дає способу перехопити присвоєння локальній змінній. Реактивність працює через перехоплення доступу до властивостей: ref - це об'єкт з властивістю value, reactive - проксі над об'єктом. Читання реєструє залежність, запис - сповіщає про зміну.

Коли звичайна змінна доречна:

  • константи й дані, які не змінюються (const MAX_ITEMS = 50);
  • функції-обробники;
  • значення, що потрібні лише в логіці й не показуються в шаблоні (ідентифікатор таймера, екземпляр сторонньої бібліотеки).

Схожі пастки:

  • заміна reactive-об'єкта цілком: let state = reactive({...}); state = reactive({...}) - шаблон стежить за старим проксі;
  • деструктуризація reactive чи props - отримані змінні не реактивні;
  • дані, отримані з fetch, записані в звичайну змінну замість ref - перший рендер порожній, і після завантаження нічого не змінюється.

Підказка: якщо значення має з'являтися в шаблоні й змінюватися - воно має бути ref, reactive чи computed.

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

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

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

Основа - 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