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.
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.
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 радить тримати стабільну межу: або завжди проксі, або завжди сирий.