Как работает реактивность: Proxy и отслеживание зависимостей
Урок объясняет, как Vue 3 ловит чтения и записи свойств через Proxy и связывает их с эффектами механизмами track и trigger.
Реактивность — это система, которая запоминает, какой код прочитал какие данные, и автоматически перезапускает этот код, когда данные меняются.
Вы уже знаете базовую реактивность: меняешь поле в data или ref — шаблон сам перерисовывается. Но за этой магией стоит вполне конкретный механизм. В Vue 3 он построен на Proxy — стандартном объекте JavaScript, который перехватывает операции с другим объектом. Когда вы читаете свойство реактивного объекта, Vue запоминает: «вот этот эффект (например, рендер-функция компонента) зависит от этого свойства». Когда вы свойство меняете — Vue находит все запомненные эффекты и перезапускает их. Эти две операции называются track (отследить зависимость при чтении) и trigger (запустить зависимости при записи).
Понимать это нужно не ради теории. Именно отсюда растут все «ловушки» реактивности: почему деструктуризация теряет связь, почему добавление нового ключа в Vue 2 не работало, почему watch иногда не срабатывает. Разобравшись с track/trigger, вы перестаёте гадать и начинаете точно предсказывать поведение.
Зачем это на практике
Представьте компонент, который показывает total = price * qty. Когда Vue впервые вычисляет этот шаблон, он читает price и qty. В этот момент срабатывает track: Vue записывает, что рендер этого компонента зависит от обоих полей. Теперь стоит изменить qty — сработает trigger, Vue найдёт рендер в списке зависимостей и перерисует только его. Соседний компонент, который qty не читал, не тронут.
Эта точечность — причина, по которой Vue не перерисовывает всё подряд. Эффект подписывается ровно на те свойства, которые реально прочитал. Если вы понимаете, в какой момент происходит чтение, вы понимаете, на что подпишется эффект, — и можете осознанно управлять перерисовками.
Proxy: перехват чтения и записи
Proxy оборачивает объект и даёт перехватчики (ловушки) на операции: get при чтении свойства, set при записи, has при in, deleteProperty при delete. Vue вешает на get вызов track, на set — вызов trigger. Внутри ловушек он использует Reflect, чтобы выполнить саму операцию «как обычно».
// Упрощённая копия ядра реактивности Vue 3 на чистом JS.
let activeEffect = null; // эффект, который сейчас исполняется
const targetMap = new WeakMap(); // объект -> (ключ -> набор эффектов)
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
dep.add(activeEffect); // запомнили: эффект зависит от target.key
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) dep.forEach((eff) => eff()); // перезапускаем зависимости
}
function reactive(obj) {
return new Proxy(obj, {
get(t, key, r) { track(t, key); return Reflect.get(t, key, r); },
set(t, key, val, r) { const ok = Reflect.set(t, key, val, r); trigger(t, key); return ok; }
});
}
function watchEffect(fn) { activeEffect = fn; fn(); activeEffect = null; }
const state = reactive({ price: 100, qty: 2 });
watchEffect(() => console.log('total =', state.price * state.qty));
state.qty = 5; // trigger по 'qty'
state.price = 200; // trigger по 'price'
Вывод:
total = 200 total = 500 total = 1000
Запустите этот блок: первый total = 200 — это первый прогон эффекта (он прочитал price и qty и подписался на оба). Дальше каждое присваивание вызывает trigger и перезапускает эффект. Это и есть Vue в миниатюре: get подписывает, set уведомляет.
Как это работает под капотом
Настоящая структура зависимостей в Vue — трёхуровневая, ровно как в примере: WeakMap от объекта к Map ключей, а от ключа — к Set эффектов. WeakMap выбран намеренно: ключ-объект не удерживается в памяти, и когда реактивный объект больше никому не нужен, его карта зависимостей собирается сборщиком мусора. Set исключает дубли — один эффект подписан на ключ ровно один раз, сколько бы раз он его ни прочитал.
activeEffect — глобальная «текущая» переменная. Перед запуском эффекта Vue ставит его в activeEffect, выполняет тело, и любое чтение реактивного свойства внутри попадает на этот эффект через track. По завершении activeEffect сбрасывается. Поэтому track «знает», кого подписывать: того, кто прямо сейчас исполняется. Если свойство читается вне эффекта (просто в коде), activeEffect равен null — подписки не происходит, и это правильно.
Реальный Vue делает это умнее: эффекты пересобирают свои зависимости на каждом прогоне (cleanup перед повторным запуском), есть планировщик (scheduler), чтобы не перезапускать эффект синхронно на каждое изменение, а батчить. Но суть — track при чтении, trigger при записи — неизменна.
Чем это отличается от Vue 2
Vue 2 не имел Proxy (он не поддерживался в IE) и использовал Object.defineProperty. Этот метод заменяет каждое свойство объекта на пару геттер/сеттер — по одному свойству за раз, рекурсивно при инициализации. Отсюда знаменитые ограничения Vue 2.
| Аспект | Vue 2 (defineProperty) | Vue 3 (Proxy) |
| Новый ключ объекта | не реактивен, нужен Vue.set | реактивен сразу — ловушка set ловит любой ключ |
| Удаление ключа | не отслеживается, нужен Vue.delete | ловит deleteProperty |
| Индексы массива | arr[0] = x не реактивно | реактивно |
Изменение length | не отслеживается | отслеживается |
| Стоимость инициализации | обход всего объекта сразу | ленивая — прокси оборачивает вложенное при первом доступе |
Proxy перехватывает операцию с объектом целиком, а не с заранее известными свойствами. Поэтому добавление нового ключа state.newField = 1 в Vue 3 просто работает: ловушка set срабатывает на любой ключ, даже которого не было. В Vue 2 это было невозможно — геттер/сеттер ставился только на существовавшие при создании поля.
Эффекты — единица реактивности
Эффект (reactive effect) — это функция, чьи зависимости отслеживаются. Рендер компонента — частный случай эффекта. computed — эффект, кэширующий результат. watchEffect — эффект, который вы пишете сами. Все они подписываются на прочитанные свойства одинаково. Когда говорят «свойство реактивно», подразумевают: его чтение трекается, а запись триггерит подписанные эффекты.
Частые ошибки
Думать, что реактивно само значение. Реактивен не объект сам по себе, а пара «чтение через прокси + эффект, который читает». Достанете значение из прокси в обычную переменную — track больше не сработает, связь потеряна (об этом подробно в уроке про ловушки).
Ждать срабатывания эффекта на свойство, которое он не прочитал. Эффект подписывается только на то, что реально читал в последнем прогоне. Если ветка if не выполнилась и свойство не прочиталось — подписки на него нет.
Переносить ожидания Vue 2 на Vue 3. Vue.set/Vue.delete в Vue 3 не нужны и не существуют в Composition API — новые ключи и удаление работают сами.
Мутировать raw-объект в обход прокси. Если сохранить исходный объект и менять его напрямую (не через реактивную обёртку), ловушки не сработают и эффекты не узнают об изменении.
Итоги
- Vue 3 строит реактивность на
Proxy: ловушкаgetвызывает track, ловушкаset— trigger. - track запоминает, что текущий эффект (
activeEffect) зависит от прочитанного свойства; trigger перезапускает подписанные эффекты. - Зависимости хранятся как
WeakMap->Mapключей ->Setэффектов;WeakMapпозволяет собрать мусор. - В отличие от
Object.defineProperty(Vue 2),Proxyловит новые ключи, удаление и операции с массивами безVue.set. - Эффект подписывается ровно на свойства, прочитанные в последнем прогоне, — отсюда точечность перерисовок.