Как работает реактивность: 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.
  • Эффект подписывается ровно на свойства, прочитанные в последнем прогоне, — отсюда точечность перерисовок.
Проверьте себя
1. Что делает операция track в системе реактивности Vue 3?
AПерезапускает все эффекты при изменении данных
BЗапоминает, что текущий исполняемый эффект зависит от прочитанного свойства
CСоздаёт глубокую копию реактивного объекта
DУдаляет свойство из реактивного объекта
2. Почему в Vue 3 добавление нового ключа state.newField = 1 реактивно, а в Vue 2 требовало Vue.set?
AVue 3 заранее обходит все возможные ключи объекта
BProxy перехватывает операцию записи для любого ключа, а defineProperty ставит геттер/сеттер только на существующие при создании поля
CВ Vue 3 объекты автоматически замораживаются
DVue 3 хранит данные в массиве, а не в объекте
3. Зачем карта зависимостей верхнего уровня в Vue реализована через WeakMap?
AWeakMap работает быстрее обычного Map при чтении
BЧтобы реактивный объект и его карта зависимостей могли быть собраны сборщиком мусора, когда объект больше не нужен
CWeakMap позволяет хранить дубликаты ключей
DЭто требование стандарта ECMAScript для Proxy