Перейти к содержимому

5. Реактивность: ref и reactive

Реактивность — это сердце Vue. Когда данные меняются, интерфейс обновляется автоматически. Не нужно вручную обновлять DOM — Vue делает это за тебя. Но чтобы Vue «видел» изменения, данные нужно обернуть в ref() или reactive(). Давай разберёмся, когда что использовать! 🎯


ref() — универсальный способ сделать любое значение реактивным:

import { ref } from 'vue'
// Примитивы
const count = ref(0)
const message = ref('Привет')
const isVisible = ref(true)
// Объекты (тоже работает!)
const user = ref({ name: 'Яша', age: 25 })
// Массивы
const items = ref<string[]>([])
// null (для async данных)
const data = ref<User | null>(null)
// В скрипте — всегда через .value
console.log(count.value) // 0
count.value = 42 // изменяем
count.value++ // инкремент
// Объекты через .value
user.value.name = 'Иван' // изменяем свойство
user.value = { name: 'Пётр', age: 30 } // заменяем весь объект
<!-- В шаблоне — .value НЕ нужен! Vue распаковывает автоматически -->
<template>
<p>{{ count }}</p> <!-- не count.value! -->
<p>{{ user.name }}</p> <!-- не user.value.name! -->
<input v-model="message" /> <!-- не message.value! -->
</template>

Почему .value в скрипте, но не в шаблоне?

В шаблоне Vue автоматически распаковывает (unwrap) ref. В скрипте — JavaScript не знает про Vue, поэтому нужен явный .value.


reactive() делает объект (или массив) реактивным без .value:

import { reactive } from 'vue'
const user = reactive({
name: 'Яша',
age: 25,
address: {
city: 'Москва',
street: 'Пушкина'
}
})
// Работа как с обычным объектом!
user.name = 'Иван' // ✅ реактивно
user.age++ // ✅ реактивно
user.address.city = 'СПб' // ✅ реактивно (глубокая реактивность!)
<template>
<p>{{ user.name }}, {{ user.age }} лет</p>
<p>Город: {{ user.address.city }}</p>
</template>

⚠️ Деструктуризация ломает реактивность!

Заголовок раздела «⚠️ Деструктуризация ломает реактивность!»
const user = reactive({ name: 'Яша', age: 25 })
// ❌ ПЛОХО — деструктуризация теряет реактивность!
const { name, age } = user
// name и age — обычные примитивы, не реактивны
// Изменение user.name НЕ обновит name
// ✅ ХОРОШО — toRefs сохраняет реактивность
import { toRefs } from 'vue'
const { name, age } = toRefs(user)
// name.value и age.value — реактивны!
// Изменение user.name ОБНОВИТ name.value ✅
let state = reactive({ count: 0 })
// ❌ Заменяем весь объект — реактивность потеряна!
state = reactive({ count: 1 }) // переменная теперь новый объект
// старые ссылки никуда не делись
// ✅ Правильно — обновляем свойства
state.count = 1
// ✅ Или используй ref для замены объекта целиком
const state = ref({ count: 0 })
state.value = { count: 1 } // ✅ работает!

✅ Используй ref() для:
├── Примитивов (number, string, boolean)
├── Когда нужно передавать реактивное значение в функции
├── Когда нужно заменять объект целиком
└── Composables (возвращать из функций)
✅ Используй reactive() для:
├── Объектов с несколькими связанными свойствами
├── Когда не нужна деструктуризация
└── Состояния формы (group of related fields)
💡 Правило Эвана Ю (создатель Vue):
Используй ref() везде, где не уверен — это безопаснее!
// reactive — хорошо для форм
const form = reactive({
email: '',
password: '',
rememberMe: false
})
// ref — хорошо для отдельных значений и composables
const count = ref(0)
const isLoading = ref(false)
const user = ref<User | null>(null)

readonly() — неизменяемый реактивный объект 🔒

Заголовок раздела «readonly() — неизменяемый реактивный объект 🔒»
import { ref, reactive, readonly } from 'vue'
const original = reactive({ count: 0 })
const readonlyOriginal = readonly(original)
readonlyOriginal.count = 1 // ⚠️ Warning в dev, ничего не происходит
// Если original меняется, readonlyOriginal тоже обновляется
// Но через readonlyOriginal изменить нельзя!
original.count = 5
console.log(readonlyOriginal.count) // 5 — синхронизируется!
// Типичное применение: expose readonly внутри composable
const useCounter = () => {
const count = ref(0)
const increment = () => count.value++
return {
count: readonly(count), // нельзя изменить снаружи
increment // можно только через increment
}
}

shallowRef() и shallowReactive() — поверхностная реактивность 🏔️

Заголовок раздела «shallowRef() и shallowReactive() — поверхностная реактивность 🏔️»

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

import { shallowRef, shallowReactive } from 'vue'
// shallowRef — реактивна только замена .value целиком
const bigList = shallowRef([{ id: 1, name: 'Яша' }])
// ❌ Не реактивно — вложенное изменение
bigList.value[0].name = 'Иван' // шаблон НЕ обновится
// ✅ Реактивно — замена .value
bigList.value = [...bigList.value, { id: 2, name: 'Иван' }]
// shallowReactive — реактивны только свойства верхнего уровня
const state = shallowReactive({
user: { name: 'Яша' }, // ← только эта ссылка отслеживается
count: 0 // ← отслеживается
})
state.count++ // ✅ реактивно
state.user = { name: 'Иван' } // ✅ реактивно (замена ссылки)
state.user.name = 'Пётр' // ❌ НЕ реактивно (вложенное)

Когда использовать shallow?

// Большие массивы с immutable данными (производительность!)
const hugeList = shallowRef<HugeDataItem[]>([])
// Данные обновляются только заменой всего массива
const refresh = async () => {
hugeList.value = await fetchHugeList() // ← только это реактивно
}

import { reactive, toRef, toRefs } from 'vue'
const user = reactive({
name: 'Яша',
age: 25,
})
// toRef() — создаёт ref на конкретное свойство reactive
const nameRef = toRef(user, 'name')
nameRef.value = 'Иван' // изменяет user.name!
console.log(user.name) // 'Иван' — синхронизировано!
// toRefs() — создаёт объект с refs на все свойства
const { name, age, email } = toRefs(user)
// Теперь можно деструктурировать с сохранением реактивности!
name.value = 'Пётр' // изменяет user.name!

Главный use-case toRefs — в composables:

composables/useUser.ts
export function useUser() {
const user = reactive({
name: 'Яша',
role: 'admin',
isActive: true
})
// Возвращаем через toRefs — потребитель может деструктурировать!
return {
...toRefs(user),
// Методы возвращаем как есть
updateUser: (data: Partial<User>) => Object.assign(user, data)
}
}
// В компоненте
const { name, role, isActive, updateUser } = useUser()
// name, role, isActive — реактивные refs! ✅

import { ref, reactive, isRef, isReactive, unref } from 'vue'
const count = ref(0)
const user = reactive({ name: 'Яша' })
// isRef() — проверка, является ли значение ref
isRef(count) // true
isRef(0) // false
isRef(user) // false
// isReactive() — проверка реактивного объекта
isReactive(user) // true
isReactive(count) // false (ref, не reactive)
// unref() — безопасно распаковывает ref (или возвращает значение как есть)
unref(count) // 0 (как count.value)
unref(42) // 42 (обычное число, не ref)
unref('hello') // 'hello'
// Полезно в composables, которые принимают ref ИЛИ значение
function useDouble(value: number | Ref<number>) {
return computed(() => unref(value) * 2)
}
useDouble(5) // работает!
useDouble(ref(5)) // тоже работает!

import { ref, onMounted } from 'vue'
interface User { id: number; name: string }
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const loadUser = async (id: number) => {
loading.value = true
error.value = null
try {
const response = await fetch(`/api/users/${id}`)
user.value = await response.json()
} catch (e) {
error.value = 'Не удалось загрузить пользователя'
} finally {
loading.value = false
}
}
onMounted(() => loadUser(1))
import { reactive } from 'vue'
const form = reactive({
email: '',
password: '',
confirmPassword: '',
acceptTerms: false
})
const errors = reactive<Record<string, string>>({})
const validate = () => {
if (!form.email) errors.email = 'Email обязателен'
if (form.password.length < 8) errors.password = 'Минимум 8 символов'
if (form.password !== form.confirmPassword) {
errors.confirmPassword = 'Пароли не совпадают'
}
}
const resetForm = () => {
Object.assign(form, { email: '', password: '', confirmPassword: '', acceptTerms: false })
Object.keys(errors).forEach(key => delete errors[key])
}
import { ref, computed } from 'vue'
interface Todo {
id: number
text: string
done: boolean
}
const todos = ref<Todo[]>([])
const filter = ref<'all' | 'active' | 'done'>('all')
const filteredTodos = computed(() => {
if (filter.value === 'active') return todos.value.filter(t => !t.done)
if (filter.value === 'done') return todos.value.filter(t => t.done)
return todos.value
})
const addTodo = (text: string) => {
todos.value.push({ id: Date.now(), text, done: false })
}
const toggleTodo = (id: number) => {
const todo = todos.value.find(t => t.id === id)
if (todo) todo.done = !todo.done
}
const removeTodo = (id: number) => {
todos.value = todos.value.filter(t => t.id !== id)
}

Vue автоматически распаковывает refs в нескольких контекстах:

const count = ref(0)
const state = reactive({ count }) // ref в reactive!
// Внутри reactive — .value не нужен!
state.count // 0 (не state.count.value!)
state.count = 5 // изменяет count.value!
// Но только для верхнего уровня reactive
const nested = reactive({ inner: { count } })
nested.inner.count.value // здесь .value нужен (не верхний уровень!)

В шаблоне все refs распаковываются:

<template>
<!-- Всё это работает без .value в шаблоне -->
{{ count }} <!-- ref<number> -->
{{ user.name }} <!-- ref<User> — Vue распакует user, потом .name -->
{{ title }} <!-- ref<string> -->
</template>

Система реактивности Vue 3:

  • ref() — для примитивов и замены целых объектов, доступ через .value
  • reactive() — для объектов, доступ напрямую, но нельзя деструктурировать
  • readonly() — неизменяемая обёртка, синхронизируется с источником
  • shallowRef/shallowReactive — для производительности с большими данными
  • toRef/toRefs — мосты между reactive и ref, для composables
  • isRef/unref — утилиты для работы в composables

Следующий шаг — computed и watch! 🔄