Ловушки реактивности

Урок собирает главные ловушки реактивности Vue 3: потеря связи при деструктуризации, спасение через toRefs, особенности массивов и Map/Set, замена reactive-объекта целиком.

Потеря реактивности почти всегда означает одно: значение вынули из-под прокси в обычную переменную, и track при чтении больше не срабатывает.

Реактивность Vue 3 кажется «просто работающей», пока вы не наступаете на одну из типовых ловушек. Все они — следствия того, как устроены track и trigger: реактивна не величина сама по себе, а чтение через прокси (или через .value ref). Стоит разорвать эту цепочку — и обновления перестают приходить, причём без всякой ошибки, что особенно коварно. Разберём четыре самые частые ловушки и точные способы их обойти.

Ловушка 1: деструктуризация reactive теряет связь

Когда вы пишете const { count } = state для реактивного объекта, JavaScript копирует значение поля в новую переменную. Если это примитив (число, строка) — он живёт сам по себе, никакой связи с прокси у него нет. Дальнейшие изменения state.count переменную count не затронут, и наоборот.

// Демонстрация потери связи при деструктуризации (упрощённый reactive).
let activeEffect = null;
function effect(fn){ activeEffect = fn; fn(); activeEffect = null; }
function reactive(o){ const subs = new Map();
  return new Proxy(o, {
    get(t, k, r){ if(activeEffect){ if(!subs.has(k)) subs.set(k, new Set()); subs.get(k).add(activeEffect);} return Reflect.get(t, k, r); },
    set(t, k, v, r){ const ok = Reflect.set(t, k, v, r); if(subs.has(k)) subs.get(k).forEach(f => f()); return ok; }
  });
}
function toRefs(obj){ const res = {}; for(const key in obj){ res[key] = { get value(){ return obj[key]; }, set value(v){ obj[key] = v; } }; } return res; }

const state = reactive({ count: 1 });

let { count } = state;        // скопировали примитив — связь потеряна
count = 99;                   // меняем локальную переменную
console.log('state.count после деструктуризации =', state.count);  // 1, не 99

const refs = toRefs(state);   // toRefs сохраняет связь через геттер/сеттер
refs.count.value = 42;        // пишем через ref — уходит в прокси
console.log('state.count после refs.count.value=42 =', state.count); // 42

Вывод:

state.count после деструктуризации = 1
state.count после refs.count.value=42 = 42

Видно: запись в деструктурированную count не дошла до объекта (осталось 1), а запись через toRefs — дошла (стало 42). Это в точности поведение настоящего Vue.

Спасение: toRefs и toRef

toRefs(state) превращает каждое свойство реактивного объекта в отдельный ref, сохраняющий связь с источником. Теперь деструктуризация безопасна: const { count } = toRefs(state) даёт ref, и count.value читает/пишет прокси. toRef(state, 'count') делает то же для одного свойства. Именно поэтому composables, использующие reactive внутри, возвращают toRefs(state) — чтобы вызывающий код мог удобно распаковать поля без потери реактивности.

Ловушка 2: реактивность массивов

Хорошая новость: в Vue 3 массивы полностью реактивны через Proxy. Работают и индексное присваивание arr[0] = x, и изменение arr.length, и все методы-мутаторы (push, pop, splice, sort, reverse). Это снимает главную боль Vue 2, где arr[i] = x и arr.length = n не отслеживались.

// В Vue 3 push меняет и индекс, и length — Proxy ловит обе записи.
let activeEffect = null;
const targetMap = new WeakMap();
function track(t,k){ if(!activeEffect) return; let m=targetMap.get(t); if(!m)targetMap.set(t,m=new Map()); let d=m.get(k); if(!d)m.set(k,d=new Set()); d.add(activeEffect); }
function trigger(t,k){ const m=targetMap.get(t); if(!m)return; const d=m.get(k); if(d)[...d].forEach(f=>f()); }
function reactive(o){ return new Proxy(o,{ get(t,k,r){ track(t,k); return Reflect.get(t,k,r);}, set(t,k,v,r){ const ok=Reflect.set(t,k,v,r); trigger(t,k); return ok;} }); }
function effect(fn){ activeEffect=fn; fn(); activeEffect=null; }

const list = reactive(['a', 'b']);
effect(() => console.log('len =', list.length));
list.push('c');   // присваивает индекс 2 И обновляет length -> эффект сработает

Вывод:

len = 2
len = 3

Тонкость остаётся с заменой массива целиком, если он лежит в reactive-объекте: state.list = newArray реактивно (сработает ловушка set на ключе list). А вот если массив у вас был получен деструктуризацией и вы переприсвоили локальную переменную — это та же ловушка 1, связь рвётся. Внутри ref массив заменяют через listRef.value = newArray — тоже реактивно.

Ловушка 3: Map и Set

Коллекции Map и Set реактивны, но через свои методы, а не через индексаторы. Vue предоставляет специальные обработчики для коллекций: реактивны get/set/has/delete/add/clear и итерация (forEach, for...of, size). Это значит: добавляйте и читайте только через методы коллекции — тогда эффекты сработают.

import { reactive } from 'vue'

const users = reactive(new Map())

// Правильно: через методы Map — реактивно
users.set('alice', { online: true })
const u = users.get('alice')
console.log(users.has('alice'))   // эффект, читающий has, перезапустится при set/delete

const tags = reactive(new Set())
tags.add('vue')                   // реактивно
tags.delete('vue')                // реактивно
console.log(tags.size)            // size трекается

Этот фрагмент использует import из 'vue', поэтому помечен language-text и не запускается в браузере. Главное правило: не пытайтесь читать поля коллекции «в обход» (у Map и нет индексного доступа) — пользуйтесь её методами, и реактивность будет корректной.

Ловушка 4: замена reactive-объекта целиком

Это самая коварная ловушка. У вас есть let state = reactive({ ... }), и вы хотите заменить всё состояние новым объектом: state = reactive({ ...newData }). После этого присваивания переменная state указывает на новый прокси, но все эффекты (рендер, наблюдатели) подписаны на старый объект. Новый прокси они не видят — обновления перестают приходить.

import { reactive, ref } from 'vue'

let state = reactive({ count: 0 })
// ... шаблон/наблюдатели подписались на ЭТОТ прокси

state = reactive({ count: 5 })   // ЛОВУШКА: новый прокси, старые подписки осиротели

// Решение А: не заменять, а мутировать поля существующего объекта
Object.assign(state, { count: 5 })   // эффекты на старом прокси сработают

// Решение Б: держать состояние в ref и менять .value целиком
const data = ref({ count: 0 })
data.value = { count: 5 }            // реактивно: .value — отслеживаемая ссылка

Запомните два рабочих приёма. Первый: не переприсваивайте переменную — мутируйте существующий объект (Object.assign(state, newData) или присваивание по полям). Прокси тот же, подписки целы. Второй: если состояние нужно именно заменять целиком, держите его в ref и меняйте state.value = newObj.value отслеживается, поэтому замена ссылки реактивна. Для объектов, которые часто заменяются целиком, ref почти всегда удобнее reactive.

Как это работает под капотом

Все четыре ловушки — одно и то же явление под разными масками: эффект подписывается на конкретный объект-прокси (или конкретный ref) в момент чтения. track связывает эффект с парой «этот объект, этот ключ». Если вы достали примитив (деструктуризация), читаете не через прокси (raw-объект) или подменили сам прокси (замена целиком) — track при чтении адресует уже не туда, и trigger при изменении перезапускать некого. toRefs чинит это, оборачивая каждое поле в ref-геттер, который продолжает читать исходный прокси; ref чинит замену целиком, потому что отслеживается стабильный .value, а не сама переменная.

Частые ошибки

Деструктурировать reactive без toRefs. Поля-примитивы скопируются и потеряют связь. Распаковывайте через toRefs(state) или toRef(state, key).

Переприсваивать reactive-переменную. state = reactive(new) осиротит старые подписки. Мутируйте через Object.assign или держите состояние в ref.

Лезть в Map/Set в обход методов. Реактивны только методы коллекции (set, get, add, delete, has, size). Работайте через них.

Думать, что массивы в Vue 3 имеют ограничения Vue 2. Индексное присваивание и length здесь реактивны; Vue.set для массивов не нужен.

Итоги

  • Реактивность теряется, когда значение вынуто из-под прокси: деструктуризация примитива, чтение raw-объекта, подмена самого прокси.
  • toRefs/toRef сохраняют связь при распаковке полей reactive — обязательны для возврата из composables.
  • Массивы в Vue 3 полностью реактивны: индексы, length и методы-мутаторы — Vue.set не нужен.
  • Map и Set реактивны через свои методы (set/get/add/delete/has/size), а не через индексаторы.
  • Нельзя заменять reactive-объект переприсваиванием переменной — мутируйте через Object.assign или держите состояние в ref и меняйте .value.
Проверьте себя
1. Почему const { count } = reactive({ count: 1 }) теряет реактивность и как это исправить?
AНе теряет — деструктуризация reactive полностью реактивна
BДеструктуризация копирует примитивное значение поля, разрывая связь с прокси; исправляется через toRefs(state) или toRef(state, 'count')
CНужно обернуть объект в markRaw
DНужно использовать flush: 'post'
2. Что верно про реактивность массивов в Vue 3?
Aarr[0] = x не отслеживается, как и в Vue 2 — нужен Vue.set
BИзменение arr.length не реактивно
CИндексное присваивание, length и методы-мутаторы (push, splice) полностью реактивны через Proxy
DМассивы вообще нельзя делать реактивными
3. Вы хотите заменить всё состояние новым объектом. Почему let state = reactive(...); state = reactive(newData) ломает реактивность и что делать?
AНичего не ломает, это рекомендованный способ
BПеременная начинает указывать на новый прокси, а эффекты подписаны на старый объект; нужно мутировать через Object.assign(state, newData) или хранить состояние в ref и менять state.value
Creactive нельзя вызывать дважды в приложении
DНужно вызвать nextTick после замены