⚡ Реактивность 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()→ операторinownKeys()→ перечисление (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 = 1trigger():
найти 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 добавляется в Set2. Изменение
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или тернарных операторов) корректно переставать отслеживаться, как только ветка перестаёт выполняться.