⚡ Реактивность Vue 3 (Proxy + track/trigger + effect)

💡 Ключевая идея

Vue не “следит” за данными. Vue связывает (объект + свойство) → (эффект-функции) и перезапускает их при изменении значения.


🧭 Обзор

state.count++

Proxy.set()

trigger()

поиск эффектов (Set)

effect() перезапускается

render / computed / watch

🧠 1. Proxy — перехват доступа

Proxy перехватывает:

  • get() → чтение
  • set() → запись
  • deleteProperty() → удаление
  • has() → оператор in
  • ownKeys() → перечисление (Object.keys, for...in)
state.count // get()
state.count++ // set()

👉 Proxy = точка входа в реактивность

Vue 2 использовал Object.defineProperty на каждом свойстве, и у этого были реальные ограничения: он не мог отследить добавление новых свойств к объекту или изменение индекса/length массива без вспомогательных методов (Vue.set, this.$set). Proxy перехватывает весь объект целиком, поэтому в Vue 3 всё это “просто работает”.


🔥 2. effect — реактивная функция

effect(() => {
  console.log(state.count)
})

👉 Любая реактивная логика в Vue на самом деле — это effect:

  • render (render-функция компонента)
  • computed
  • watch / watchEffect

Внутри effect() оборачивает вашу функцию в экземпляр ReactiveEffect. Именно этот экземпляр на самом деле сохраняется как подписчик и перезапускается — а не голая функция.


🧩 3. activeEffect — “кто сейчас выполняется”

let activeEffect = null

Во время выполнения:

effect стартует

activeEffect = этот effect

поля state читаются внутри него

Эффекты могут быть вложенными (например, computed читается внутри render), поэтому на практике Vue хранит не одну глобальную переменную, а стек эффектов, проталкивая/выталкивая activeEffect по мере старта и завершения эффектов.


🔗 4. track() — подписка

Вызывается при get():

state.count // Proxy.get → track()
track():
activeEffect записывается как подписчик

👉 значение:

“этот эффект зависит от этого свойства”


⚡ 5. trigger() — обновление

Вызывается при set():

state.count = 1
trigger():
найти Set(effect)
→ выполнить их все

Для массивов и коллекций (Map/Set) trigger() также нужно знать, какого рода произошло изменение (SET, ADD, DELETE, CLEAR) — добавление нового ключа или изменение length может повлиять на эффекты, основанные на итерации (например, for...in или Array.map), даже если этот конкретный ключ никогда не читался напрямую.


🧠 6. Структура зависимостей

💥 Vue хранит:

WeakMap

объект (state)

Map

свойство (count)

Set(effect)

📦 Полная структура

WeakMap
 └─ state
     └─ Map
         ├─ count → Set(effects)
         └─ name  → Set(effects)

🧩 Почему именно такие структуры

WeakMap

  • ключ = сам реактивный объект
  • автоматически удаляется сборщиком мусора, когда на объект больше нет ссылок извне 👉 никаких утечек памяти

Map

  • хранит свойства объекта → их наборы зависимостей

Set

  • хранит эффекты без дублей (эффект может присутствовать не более одного раза на свойство, даже если это свойство читается внутри него несколько раз)

🔁 Полный цикл реактивности

1. Подписка

effect()
→ activeEffect = fn
→ читается state.count
→ track()
→ fn добавляется в Set

2. Изменение

state.count++
→ Proxy.set()
→ trigger()
→ ищется Set
→ вызываются эффекты

🧠 Итоговая диаграмма

ЧТЕНИЕ:
state.count

Proxy.get

track()

WeakMap → Map → Set → activeEffect
 
ЗАПИСЬ:
state.count++

Proxy.set

trigger()

Set(effects)

effect() перезапускается

💥 Реактивность Vue в одной формуле

Proxy + effect + track + trigger
= автоматическая подписка и обновление UI

🧠 Суть в одном предложении

Vue не “следит за данными” — он строит граф зависимостей между свойствами и функциями и перезапускает только те части, на которые повлияло изменение.


📎 Дополнительно: что осталось за кадром диаграммы выше

reactive() vs ref()

  • reactive(obj) напрямую оборачивает объект в Proxy — по умолчанию глубоко (вложенные объекты оборачиваются лениво, при обращении к ним).
  • ref(value) оборачивает одно значение (включая примитивы, которые Proxy перехватить не может) в объект с геттером/сеттером .value, который сам вызывает track()/trigger(). Именно поэтому refs требуют .value в обычном JS, но автоматически разворачиваются в шаблонах и внутри объектов reactive().
  • shallowReactive / shallowRef пропускают глубокое оборачивание — отслеживается только доступ верхнего уровня, что полезно для больших объектов или интеграции с нереактивным внешним состоянием.

computed()

  • computed сам по себе является ReactiveEffect, но ленивым: он не перезапускается сразу при trigger(). Вместо этого он просто выставляет флаг dirty в true.
  • Чтение .value проверяет dirty: если true — геттер перезапускается и результат кэшируется; если false — возвращается закэшированное значение без пересчёта.
  • Именно поэтому computed-свойства дешевле методов при частом чтении между изменениями зависимостей.

watch / watchEffect

  • watchEffect — это по сути effect() с автоматическим отслеживанием зависимостей, запускаемый немедленно.
  • watch отслеживает только явно переданный источник(и) и по умолчанию ленивый (не запускается при setup, только при изменении), и не отслеживает автоматически то, что читается внутри тела самого коллбэка — только внутри геттера источника.
  • Оба поддерживают опцию таймингa flush (pre / post / sync), которая управляет тем, выполняется ли коллбэк до, после или синхронно вокруг обновлений DOM компонента.

Планирование — почему DOM не обновляется на каждую мутацию

  • Render-эффекты компонента не выполняются синхронно внутри trigger(). Вместо этого они добавляются в очередь задач и сбрасываются как микротаска (Promise.resolve().then(flushJobs)).
  • Это значит, что несколько синхронных мутаций (state.a = 1; state.b = 2) в одном тике вызывают только один ре-рендер, а не два — именно этого дожидается nextTick().

readonly / toRaw / markRaw

  • readonly(obj) оборачивает объект так, что ловушки set/delete в dev-режиме предупреждают и ничего не делают — используется для защиты состояния, передаваемого вниз как props.
  • toRaw(proxy) разворачивает реактивный Proxy обратно в исходный объект, минуя отслеживание — полезно при передаче данных в нереактивные внешние библиотеки.
  • markRaw(obj) говорит Vue вообще никогда не оборачивать этот объект в Proxy.

Очистка

  • Каждый раз при перезапуске эффекта Vue сначала удаляет его из всех наборов зависимостей, на которые он был подписан ранее (cleanupEffect), а затем позволяет track() заново выстроить подписки с нуля во время нового запуска. Именно это позволяет условным зависимостям (например, внутри веток v-if или тернарных операторов) корректно переставать отслеживаться, как только ветка перестаёт выполняться.

vue Compiler — кто генерирует реактивный код vue