Статья подготовлена в преддверии курса «Vue.js разработчик»

Компонент получает объект из хранилища, раскладывает его по переменным для удобства, меняет одно поле — и шаблон остаётся прежним. Никаких ошибок в консоли, никаких предупреждений в сборке. Значение в памяти новое, на экране старое.

Разработчик добавляет watch, тот не срабатывает. Добавляет nextTick, ничего не меняется.

В итоге появляется key на корневом теге и ручной инкремент счётчика, чтобы перерисовать всё принудительно — костыль, который работает и который потом живёт в коде годами.

А объяснение простое: реактивность живёт не в значении, а в объекте, из которого его достали. Как только вы вынули поле наружу, связь оборвалась, и никакой watch её не восстановит.

Разберём, где именно это происходит, почему так устроено, и какие места в системе реактивности стоит понимать до того, как начнёте городить обходные пути.

Прокси знает про доступ к свойству, а не про само значение

Вся система построена на перехвате обращений. Когда вы пишете reactive({ count: 0 }), наружу отдаётся не объект, а прокси‑обёртка над ним.

  • Читаете state.count — прокси записывает, какой эффект сейчас выполняется, и запоминает зависимость.

  • Меняете state.count — прокси находит записанные эффекты и запускает их заново.

Отсюда следует главное ограничение, из которого растут все остальные. Отслеживается пара «объект плюс имя свойства», а не значение. Число 0, вынутое из прокси, — обычное число, ничем не отличающееся от литерала.

const state = reactive({ count: 0 })

let { count } = state    // count — это просто 0
count++                  // изменили локальную переменную, прокси не в курсе

Шаблон при этом покажет начальное значение и больше никогда не обновится, потому что при первом рендере он прочитал count — обычную переменную, за которой никто не следит.

Правильных выходов два, и выбор между ними определяется тем, нужна вам развязка или нет:

const { count } = toRefs(state)    // count.value связан с state.count в обе стороны
state.count++                      // либо просто не разбирать объект

toRefs превращает каждое свойство в ref, сохраняющий обратную ссылку на источник. Меняете count.value — меняется state.count, и наоборот. Для одного свойства есть toRef, для всего объекта toRefs.

Ref, который перестал быть ref

Вторая половина той же истории живёт со стороны ref, и там путаницы больше, потому что .value появляется и исчезает в разных контекстах.

В шаблоне Vue разворачивает ref автоматически — писать .value не нужно. В скрипте нужно всегда. Это раздражает до тех пор, пока не поймёшь механику, ведь разворачивание в шаблоне делает компилятор, а обычный JS ничего сам не разворачивает.

Проблемс вылезает при передаче ref внутрь функции:

function useCounter(initial) {
  const count = ref(initial)
  return { count, double: computed(() => count.value * 2) }
}

const { count } = useCounter(0)    // всё в порядке: вернули объект с ref внутри

Здесь всё работает, потому что из функции вернулся объект, а разобрали мы его на ref, а не на числа. Сравните с обратным случаем:

const state = reactive({ user: ref('Аня') })
const { user } = state    // reactive развернул вложенный ref, и user — строка

reactive разворачивает ref, лежащие внутри него, при чтении. Вы можете подумать, что достаёте ref, а достаёте примитив.

Поэтому возвращайте объект с ref, а не reactive. Тогда вызывающий код может спокойно разбирать результат, и ничего не сломается.

Глубокая реактивность стоит денег

И ref, и reactive по умолчанию рекурсивны: прокси оборачивает не только сам объект, но и каждый вложенный при первом обращении к нему. Для формы на десять полей это бесплатно. Для таблицы на 50 000 строк, где каждая строка объект из 15 полей уже нет.

Прокси создаются лениво, при первом чтении, поэтому загрузка данных проходит быстро, а тормоза появляются при рендере: обход списка трогает каждый элемент, и на каждый создаётся обёртка. Вкладка подвисает на пару секунд, профайлер показывает время в служебных функциях фреймворка, и это выглядит так, будто виноват сам Vue.

Фиксится тем, чтобы не отслеживать то, что не меняется:

const rows = shallowRef([])           // отслеживается только замена .value целиком

async function load() {
  rows.value = await fetchRows()      // сработает
}

function patch(i, field, v) {
  rows.value[i][field] = v            // не сработает, элементы не реактивны
  triggerRef(rows)                    // придётся дёрнуть вручную
}

shallowRef следит только за присваиванием в .value. Элементы внутри остаются обычными объектами, прокси на них не создаются, и обход списка перестаёт быть дорогим.

Для случая, когда большой неизменяемый кусок надо положить внутрь реактивного состояния, есть markRaw:

const state = reactive({
  filters: { query: '' },
  dictionary: markRaw(hugeStaticDictionary),   // никогда не станет прокси
})

Пометка необратима, объект, помеченный сырым, не обернётся, даже если положить его в другое реактивное состояние. Это и хорошо, и не очень, если позже понадобится реактивность, придётся заводить копию.

Массив меняется, а v‑for показывает старое

Во второй версии реактивность строилась на переопределении геттеров и сеттеров, и добавление нового свойства объекта отследить было нельзя. Отсюда Vue.set, this.$set, и рекомендация менять массив только через специально пропатченные методы.

// Vue 2, обязательные обходные пути
this.$set(this.user, 'phone', '+7...')
this.items.splice(index, 1, newValue)   // прямое присваивание по индексу не работало

Прокси сняли это ограничение. Добавление свойств, удаление через delete, присваивание по индексу, изменение length — всё отслеживается штатно:

const user = reactive({ name: 'Аня' })
user.phone = '+7...'       // работает
delete user.name           // тоже работает

const items = reactive([1, 2, 3])
items[0] = 99              // работает
items.length = 1           // и это тоже

Что осталось непокрытым — коллекции, которые вы заменяете целиком через shallowRef, и всё, что помечено сырым. Плюс редкий случай: прокси и исходный объект — разные ссылки, и сравнение через === между ними даёт ложь.

const raw = { id: 1 }
const proxy = reactive(raw)
console.log(proxy === raw)        // false
console.log(toRaw(proxy) === raw) // true

Всплывает это при работе со сторонними библиотеками, которые держат свои ссылки на объекты: вы кладёте объект в реактивное состояние, библиотека сравнивает его со своей копией и не узнаёт. ФикситсяtoRaw на границе или markRaw при укладке.

Что видит computed и чего не видит watch

Вычисляемые свойства кэшируются по зависимостям, и зависимости определяются тем, что реально прочиталось при выполнении. Не тем, что упомянуто в коде, а тем, до чего дошло исполнение.

const filtered = computed(() => {
  if (!enabled.value) return []       // при выключенном флаге items не читается
  return items.value.filter(fn)
})

Пока enabled ложно, items в зависимости не попадает вовсе — и его изменения не вызовут пересчёт. Как только флаг станет истинным, зависимость появится. Ведёт себя логично, а выглядит как пропущенное обновление, если не знать про это.

С watch другая частая история — он по умолчанию не следит за вложенными изменениями:

watch(state, () => {})                    // reactive: следит глубоко
watch(() => state.user, () => {})         // следит за заменой объекта целиком
watch(() => state.user, () => {}, { deep: true })   // следит за полями внутри

Правило работает так: передали реактивный объект напрямую — получили глубокое отслеживание автоматически. Передали геттер — следите только за тем, что он возвращает, и глубину надо просить явно.

К тому же при глубоком отслеживании старое и новое значение в колбэке — один и тот же объект. Vue не делает копию, поэтому сравнить «было» и «стало» внутри watch не получится, если менялось поле, а не сама ссылка.

watch(state, (val, oldVal) => {
  console.log(val === oldVal)     // true при мутации поля
}, { deep: true })

Нужна разница — храните снимок сами, например через структурное клонирование в начале.

Где реактивность стоит выключить

Разговор про потери создаёт впечатление, что реактивности должно быть больше. На практике в зрелом проекте чаще приходится решать обратную задачу — какие данные из неё вынуть.

Кандидаты эти набираются быстро.

Экземпляры сторонних библиотек: карта, редактор, график, соединение по веб‑сокету. Это объекты с внутренним состоянием и своими ссылками, оборачивать их прокси незачем и вредно — обход при первом обращении может занять заметное время, а поведение библиотеки станет непредсказуемым.

const map = shallowRef(null)

onMounted(() => {
  map.value = markRaw(new maplibregl.Map({ container: el.value }))
})

Дальше — справочники и словари, которые загружаются один раз и не меняются. Страны, валюты, коды регионов, схема формы. Реактивность им не нужна, а размер бывает приличным.

И третье — данные, которые вы отдаёте наружу и не хотите, чтобы их правили мимо вас. Для этого есть отдельная обёртка:

const state = reactive({ items: [] })

export function useStore() {
  return { state: readonly(state), addItem }
}

Попытка записи в такой объект даст предупреждение в разработке и молча ничего не сделает в сборке. Приём удобен для композаблов и хранилищ: чтение открыто всем, запись идёт только через явные действия.

Проверить, во что реально превратилось состояние, помогают три функции — isRef, isReactive и isProxy.

Props нельзя менять

Свойства компонента реактивны, но доступны только для чтения. Попытка присвоить даёт предупреждение в консоли, а разбор объекта свойств ломает реактивность точно так же, как разбор reactive.

Долгое время это чинили через toRefs(props). Начиная с версии 3.5 компилятор научился делать это сам: разобранные свойства остаются реактивными, потому что компилятор подставляет обращения к исходному объекту прямо в код.

const { title, count = 0 } = defineProps(['title', 'count'])
// после 3.5 title и count остаются реактивными

Работает это только внутри <script setup> и только за счёт компиляции — в обычном JavaScript та же запись реактивность потеряет.

Что стоит держать в голове

Все описанные случаи сводятся к одному вопросу: остаётся ли у значения связь с прокси, который за ним следит. Достали значение наружу — связь оборвалась. Оставили доступ через объект или через ref — связь жива.

Поэтому из хранилищ и реактивных объектов доставайте свойства через toRefs и storeToRefs, а не разбором. Большие массивы держите в shallowRef и не забывайте про виртуализацию, а неизменяемые словари помечайте сырыми.

Есои что‑то не обновляется, нужно не гадать, а проверить: реактивно ли значение вообще, и попадает ли оно в зависимости при том пути исполнения, который сейчас работает. Первое проверяется вызовом isRef и isReactive, второе — хуками отслеживания из предыдущего раздела.

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

Когда понимаешь, где Vue отслеживает зависимости и где эта связь рвётся, становится проще разбираться с поведением интерфейса без watch, nextTick и принудительных перерисовок наугад.

Продолжить практику можно на бесплатных открытых уроках — от базовых механик Vue 3 до интерактивных приложений с обновлением данных в реальном времени.

  • 26 августа в 20:00 — «Vue умеет проще: пишем игру, пока React грузит стейт». Записаться

  • 9 сентября в 20:00 — «Создаём первое приложение на Vue 3 с Composition API». Записаться

  • 21 сентября в 20:00 — «От Vue‑компонентов к живому приложению: подключаем WebSocket и создаем real‑time опыт». Записаться

Весь список открытых уроков августа собрали в дайджесте.