watch против watchEffect, flush и nextTick

Урок сравнивает watch и watchEffect, объясняет тайминг flush (pre, post, sync), роль nextTick и обновление DOM, а также остановку наблюдателей.

watch следит за явно указанным источником и даёт старое и новое значение; watchEffect запускается сразу и сам определяет зависимости по тому, что прочитал.

И watch, и watchEffect — это реактивные эффекты, но с разным контрактом. watch просит вас назвать источник (ref, геттер, реактивный объект) и вызывает колбэк только при его изменении, передавая (newVal, oldVal). watchEffect не требует источника: он сразу выполняет переданную функцию, по ходу трекает все прочитанные реактивные данные и перезапускается при изменении любого из них. Выбор между ними — это выбор между «слежу за конкретным» и «реагирую на всё, что использую».

Отдельная важная тема — когда срабатывает наблюдатель относительно перерисовки DOM. Этим управляет опция flush. Без понимания тайминга вы будете читать из DOM устаревшие значения или, наоборот, ловить лишние срабатывания.

Зачем это на практике

Классический случай: пользователь меняет фильтр, и вам нужно сделать запрос на сервер. Источник один и понятен — берите watch(filter, () => fetchData()): чёткий триггер, доступ к старому значению. Другой случай: вы синхронизируете несколько полей в localStorage и не хотите перечислять их вручную — watchEffect сам подпишется на всё, что прочитает внутри. А если после изменения данных нужно измерить размер обновлённого элемента — тут критичен тайминг: читать DOM можно только после того, как Vue его перерисовал, то есть с flush: 'post' или через nextTick.

watch: явный источник, старое и новое значение

watch принимает источник и колбэк. Источник — ref, геттер-функция или реактивный объект. Колбэк получает новое и старое значения. По умолчанию watch ленив: колбэк не вызывается при создании, только при последующих изменениях (это меняется опцией immediate: true).

import { ref, watch } from 'vue'

const query = ref('')

// Источник — ref; колбэк получает (new, old)
watch(query, (val, old) => {
  console.log(`запрос изменился: "${old}" -> "${val}"`)
  fetchResults(val)
})

// Источник-геттер: следим за вычисляемым выражением
watch(
  () => props.userId,
  (id) => loadUser(id),
  { immediate: true }   // выполнить колбэк сразу при инициализации
)

Когда источников несколько, передайте массив — колбэк получит массивы новых и старых значений. Для глубокого слежения за вложенными полями объекта добавьте { deep: true } (но помните: глубокий обход стоит производительности на больших структурах).

watchEffect: зависимости определяются автоматически

watchEffect выполняет функцию немедленно и перезапускает её при изменении любой прочитанной реактивной величины. Источник указывать не нужно — он выводится из тела. Это удобно, когда «эффект» зависит сразу от нескольких величин и перечислять их вручную утомительно.

import { ref, watchEffect } from 'vue'

const a = ref(1)
const b = ref(2)

// Сразу выполнится и подпишется и на a, и на b
watchEffect(() => {
  console.log('сумма =', a.value + b.value)
})

a.value = 10   // перезапуск: прочитали a.value -> зависимость есть

Разница в одной фразе: watch ленив и даёт старое значение; watchEffect энергичен (запускается сразу) и старого значения не даёт. Если вам нужно сравнить «было/стало» — это watch. Если нужно «просто держать в актуальном состоянии» — watchEffect.

flush: когда срабатывает наблюдатель

Оба наблюдателя по умолчанию работают в режиме flush: 'pre' — колбэк выполняется перед перерисовкой компонента. Это значит: на момент колбэка DOM ещё хранит старое состояние. Опция flush управляет таймингом:

flushКогда срабатываетСостояние DOM в колбэке
'pre' (по умолчанию)перед обновлением DOM, в той же «пачке»ещё старое
'post'после обновления DOMуже новое — можно читать/мерить
'sync'синхронно, сразу на каждое изменениене батчится, может срабатывать часто

Правило: если в колбэке нужно работать с обновлённым DOM (измерить размер, прокрутить к элементу, обратиться к ребёнку), используйте { flush: 'post' }. 'sync' почти никогда не нужен — он отключает батчинг и легко приводит к лавине срабатываний, если за один тик меняется несколько зависимостей.

nextTick и обновление DOM

Vue обновляет DOM асинхронно. Когда вы меняете реактивные данные, Vue не перерисовывает компонент тут же — он ставит обновление в очередь и применяет его одним батчем в конце текущего «тика». Поэтому сразу после изменения данных DOM ещё старый.

import { ref, nextTick } from 'vue'

const count = ref(0)

async function increment() {
  count.value++
  // DOM ещё НЕ обновлён — здесь старое значение в разметке
  console.log('сразу после изменения, DOM старый')

  await nextTick()
  // Теперь DOM перерисован — можно читать актуальную разметку
  console.log('после nextTick, DOM обновлён')
}

nextTick() возвращает Promise, который резолвится после того, как Vue применил все ожидающие обновления DOM. Это безопасный способ «дождаться перерисовки» в обработчике. Та же идея, что и flush: 'post' у наблюдателя, только локально в любом месте кода.

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

Все эти эффекты используют общий планировщик Vue. Когда trigger перезапускает эффект, эффект не вызывается синхронно — он ставится в очередь планировщика. Очередь делится на буферы: pre-наблюдатели и рендеры выполняются в одной фазе (микрозадача в конце тика), post-наблюдатели — в отдельном буфере, который опустошается уже после применения DOM-патчей. nextTick просто добавляет ваш колбэк в хвост той же микрозадачной очереди, поэтому он гарантированно выполняется после рендеров.

Батчинг — ключевая оптимизация: если за один синхронный блок вы измените count десять раз, рендер выполнится один раз, а не десять. Планировщик дедуплицирует эффекты в очереди (через Set) — повторно добавленный эффект не продублируется. flush: 'sync' ломает этот батчинг намеренно, выполняя колбэк прямо в trigger.

Остановка наблюдателей

И watch, и watchEffect возвращают функцию остановки. Вызовите её — наблюдатель перестанет следить и отпишется от зависимостей. Наблюдатели, созданные в setup (синхронно), Vue останавливает автоматически при размонтировании компонента — заботиться не нужно. Но если вы создаёте наблюдатель асинхронно (внутри await, в колбэке таймера), автоматической привязки к жизненному циклу нет — его надо останавливать вручную, иначе утечка.

import { watchEffect } from 'vue'

const stop = watchEffect(() => {
  console.log(source.value)
})

// Позже, когда слежение больше не нужно:
stop()   // эффект отписан, колбэк больше не вызовется

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

Читать DOM в flush: 'pre' и удивляться старым значениям. По умолчанию колбэк идёт до перерисовки. Для работы с обновлённым DOM нужен flush: 'post' или nextTick.

Ждать синхронного обновления DOM после изменения данных. Vue обновляет DOM асинхронно (батч в конце тика). Сразу после count.value++ разметка ещё старая — нужен await nextTick().

Использовать watchEffect там, где нужно старое значение. watchEffect не передаёт oldVal. Если важно «было/стало» — берите watch.

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

Итоги

  • watch ленив, требует явного источника и даёт (newVal, oldVal); watchEffect запускается сразу и сам выводит зависимости, но без старого значения.
  • flush управляет таймингом: 'pre' (DOM ещё старый), 'post' (DOM обновлён — можно мерить), 'sync' (синхронно, без батчинга).
  • Vue обновляет DOM асинхронно; nextTick() возвращает Promise, резолвящийся после применения обновлений.
  • Под капотом — общий планировщик с дедупликацией и батчингом эффектов в очереди.
  • Наблюдатели возвращают функцию остановки; синхронные в setup Vue чистит сам, асинхронные надо останавливать вручную.
Проверьте себя
1. Чем watch отличается от watchEffect?
Awatch работает только с примитивами, watchEffect — только с объектами
Bwatch ленив, требует явного источника и даёт (newVal, oldVal); watchEffect запускается сразу, сам выводит зависимости и не даёт старого значения
CwatchEffect нельзя остановить, а watch можно
DОни полностью идентичны, это псевдонимы
2. Вам нужно в колбэке наблюдателя измерить размер только что обновлённого элемента DOM. Какой flush выбрать?
A'pre' — он по умолчанию и самый быстрый
B'sync' — чтобы сработать немедленно
C'post' — он выполняется после применения обновлений DOM, поэтому разметка уже актуальна
Dflush здесь не важен
3. Что возвращает nextTick() и зачем он нужен?
AСтарое значение реактивной переменной
BPromise, который резолвится после того, как Vue применил все ожидающие обновления DOM — чтобы дождаться перерисовки
CФункцию остановки наблюдателя
DСинхронно перерисовывает компонент прямо сейчас