Обчислені, методи та спостерігачі у Vue.js
Computed, methods і watchers - три інструменти Vue для роботи з реактивними даними: computed кешує похідні значення і перераховується тільки при зміні залежностей, methods виконуються при кожному виклику без кешу, watchers запускають side effects при зміні конкретного джерела.
Теорія
TL;DR
- Computed = формула в Excel: перераховується тільки коли змінюються вхідні дані
- Methods = кнопка калькулятора: виконується заново при кожному виклику, без пам'яті
- Watchers = спостерігач: викликає callback коли конкретний ref чи reactive змінюється
- Потрібне відображення значення? Computed. Обробка події з параметрами? Method. Запит до API при зміні? Watcher.
- Computed тільки синхронний; watchers підтримують async (API-запити, localStorage, debounce)
Швидкий приклад
<template>
<button @click="count++">Кліки: {{ count }}</button>
<p>Подвоєне (computed): {{ doubleCount }}</p>
<p>Подвоєне (method): {{ doubleCountMethod() }}</p>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const count = ref(0)
const doubleCount = computed(() => {
console.log('Computed запустився') // Один раз на зміну count
return count.value * 2
})
const doubleCountMethod = () => {
console.log('Method запустився') // При кожному рендері
return count.value * 2
}
watch(count, (newVal) => {
console.log('Watcher спрацював:', newVal) // При кожній зміні count
})
</script>Клікни кнопку і подивись в консоль: "Computed запустився" з'являється один раз на клік, "Method запустився" при кожному циклі рендеру, watcher спрацьовує при кожній зміні count.
Головна різниця
Computed відстежує реактивні залежності під час першого запуску і кешує результат. Система реактивності Vue (Proxy у Vue 3) запам'ятовує до яких refs звертався геттер, і скидає кеш тільки коли вони змінюються. Methods - це звичайні функції без будь-якого відстеження. Watchers використовують явне джерело і запускають callbacks асинхронно через планувальник Vue, тобто виконуються після того як Vue завершить поточний цикл оновлення DOM. Саме тому watchers підходять для side effects: API-запитів, запису в localStorage, роботи з DOM.
Коли що використовувати
- Відображення похідних даних (fullName з firstName + lastName): computed
- Обробка подій користувача з параметрами (submit форми, клік на кнопку): method
- Реакція на зміну даних з API-запитом або записом у localStorage: watcher
- Читаєш одне й те саме значення кілька разів за один рендер: computed, не method
- Потрібно стежити за кількома реактивними джерелами в одному callback: watcher з масивом джерел
- Одноразове обчислення з параметром: method
Таблиця порівняння
| Властивість | Computed | Methods | Watchers |
|---|---|---|---|
| Кешування | Так, залежності | Ні | Ні |
| Повертає значення | Завжди | Опційно | Ні |
| Тригер | Читання + зміна залежностей | Кожен виклик | Зміна джерела |
| Async | Ні (тільки синхронно) | Так | Так |
| Side effects | Не рекомендується | Допустимо | Для цього й призначений |
| Найкраще для | Відображення в шаблоні | Обробники подій, параметризовані виклики | API-запити, debounce, localStorage |
Як Vue обробляє це всередині
Vue 3 огортає refs і reactive-об'єкти у Proxy. Коли геттер computed запускається вперше, Vue активує Effect і записує кожне реактивне значення до якого звертається код, через track(). При зміні залежності Effect позначається застарілим, а геттер перезапускається при наступному читанні - не одразу. Тому computed ледачий і кешований. Methods обходять цей механізм повністю. Watchers використовують той самий Effect, але з явним джерелом і асинхронним flush через queueScheduler.
Типові помилки
1. Method для значень які повторюються в шаблоні
<!-- getFullName() виконується при кожному рендері -->
<template>
<p>{{ getFullName() }}</p>
<p>{{ getFullName() }}</p>
</template>
<!-- computed виконується один раз при зміні залежностей -->
<template>
<p>{{ fullName }}</p>
<p>{{ fullName }}</p>
</template>
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('Олена')
const lastName = ref('Коваль')
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
</script>На списку з 1000 елементів різниця відчутна. Computed потрібен коли одне похідне значення з'являється в шаблоні кілька разів.
2. Async у computed
<script setup>
import { ref, computed, watchEffect } from 'vue'
const userId = ref(1)
const user = ref(null)
// Неправильно: computed має бути синхронним
const userName = computed(async () => {
user.value = await fetchUser(userId.value) // Повертає Promise, не ім'я
return user.value.name
})
// Правильно: watchEffect для async
watchEffect(async () => {
if (userId.value) {
user.value = await fetchUser(userId.value)
}
})
</script>Computed поверне Promise-об'єкт, а не resolved значення. Vue не чекає на нього. Ця помилка часто зустрічається в Nuxt.js auth-модулях.
3. Side effects у computed
<script setup>
// Неправильно: side effect при кожному читанні геттера
const bad = computed(() => {
apiCall() // Якщо шаблон читає bad 5 разів, apiCall спрацює 5 разів
return count.value * 2
})
// Правильно
watch(count, () => {
apiCall()
})
</script>4. Watcher на нереактивне значення
<script setup>
let plainVar = 'hello'
// Неправильно: plainVar не реактивний, watcher ніколи не спрацює
watch(plainVar, (newVal) => console.log(newVal))
// Правильно: загорни в ref або передай функцію-геттер
const reactiveVar = ref('hello')
watch(reactiveVar, (newVal) => console.log(newVal))
</script>5. Deep watcher на великих об'єктах
// Неправильно: рекурсивно сканує весь об'єкт при кожній мутації
watch(items, cb, { deep: true })
// Краще: стежи тільки за тим що потрібно
watch(() => items.value.length, cb){ deep: true } на масиві з 500 елементів запускає повний рекурсивний обхід при кожній вкладеній зміні. Звужуй до конкретного поля де можливо.
Де зустрічається в реальних проектах
- Pinia/Vuex store: getters - це computed властивості (відфільтровані задачі, підсумок кошика)
- Vue Storefront:
totalобчислюється з масиву елементів кошика, кешується між рендерами - Nuxt.js: watchers на параметри роуту для запуску data fetch при навігації
- Element Plus: computed для динамічних повідомлень валідації форм
- Vuetify: methods для параметризованих обробників кнопок
Питання для поглиблення
Q: Чому computed не може бути асинхронним?
A: Computed вимагає синхронного значення на виході. Повернення Promise дає Vue Promise-об'єкт, а не дані. Для async логіки використовуй watchEffect або watch з async callback.
Q: Як Vue визначає залежності computed властивості?
A: Під час першого запуску геттера Vue активує Effect і викликає track() для кожного Proxy до якого звертається код. Вони стають залежностями. Коли будь-яка з них змінюється, trigger() позначає computed застарілим.
Q: Яка різниця між watch і watchEffect?
A: watchEffect запускається одразу, автоматично збирає залежності зі свого тіла і перезапускається при їх зміні. watch явний: ти вказуєш джерело, він ледачий за замовчуванням, і callback отримує старе і нове значення.
Q: Коли computed скидає кеш для об'єктних залежностей?
A: За замовчуванням Vue відстежує тільки поверхневий рівень: саме посилання на об'єкт. Мутація вкладеної властивості без заміни ref не скидає computed. Використовуй reactive або перебудуй так, щоб змінювалося саме посилання.
Q: (Senior) Якщо реалізувати мемоїзовану функцію через замикання (closure), чого їй бракує порівняно з Vue computed?
A: Планувальника. Computed Vue групує анулювання і відкладає перезапуск до наступного тіку, уникаючи зайвих обчислень коли кілька залежностей змінюються в одній мікрозадачі. Звичайне замикання перезапускається одразу при кожній зміні без групування.
Приклади
Підсумок кошика з computed
<script setup>
import { ref, computed } from 'vue'
const items = ref([
{ name: 'Футболка', price: 300, qty: 2 },
{ name: 'Штани', price: 600, qty: 1 }
])
const total = computed(() => {
// Перераховується тільки коли змінюється масив items або його елементи
return items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
})
</script>
<template>
<ul>
<li v-for="item in items" :key="item.name">
{{ item.name }} x{{ item.qty }} = {{ item.price * item.qty }} грн
</li>
</ul>
<p>Разом: {{ total }} грн</p>
</template>total читається один раз за цикл рендеру незалежно від кількості звернень у шаблоні. Змінюєш qty - Vue перераховує, але тільки тоді. Саме цей паттерн використовується у Vue Storefront.
Пошук з debounced watcher
<script setup>
import { ref, watch } from 'vue'
const query = ref('')
const results = ref([])
let timer = null
watch(query, (newVal) => {
clearTimeout(timer)
timer = setTimeout(async () => {
if (newVal.trim()) {
results.value = await searchAPI(newVal)
} else {
results.value = []
}
}, 300)
})
async function searchAPI(q) {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
return res.json()
}
</script>
<template>
<input v-model="query" placeholder="Введіть для пошуку..." />
<ul>
<li v-for="r in results" :key="r.id">{{ r.title }}</li>
</ul>
</template>Це саме той паттерн для якого призначені watchers: async side effect з debounce, який не блокує рендер і не повертає значення в шаблон. Computed тут не підійде.
Writable computed для двостороннього зв'язку
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('Олена')
const lastName = ref('Коваль')
const fullName = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(newValue) {
const parts = newValue.split(' ')
firstName.value = parts[0] ?? ''
lastName.value = parts[1] ?? ''
}
})
</script>
<template>
<input v-model="fullName" />
<p>Ім'я: {{ firstName }}, Прізвище: {{ lastName }}</p>
</template>Writable computed зустрічається рідше, але корисний коли батьківський компонент працює з об'єднаним значенням, а стан зберігається окремо. Бачив такий підхід у бібліотеках форм де адреса зберігається по частинах, але v-model прив'язаний до одного поля.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.