Ловушки реактивности
Урок собирает главные ловушки реактивности 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.