Перейти к содержанию

Наблюдатели

Пример использования

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

С помощью Options API мы можем использовать свойство watch для запуска функции при изменении реактивного свойства:

js
export default {
  data() {
    return {
      question: '',
      answer: 'Вопросы обычно содержат вопросительный знак. ;-)',
      loading: false
    }
  },
  watch: {
    // при каждом изменении вопроса будет запускаться эта функция
    question(newQuestion, oldQuestion) {
      if (newQuestion.includes('?')) {
        this.getAnswer()
      }
    }
  },
  methods: {
    async getAnswer() {
      this.loading = true
      this.answer = 'Думаю...'
      try {
        const res = await fetch('https://yesno.wtf/api')
        this.answer = (await res.json()).answer
      } catch (error) {
        this.answer = 'Ошибка! Не удалось связаться с API. ' + error
      } finally {
        this.loading = false
      }
    }
  }
}
template
<p>
  Задайте вопрос «да/нет»:
  <input v-model="question" :disabled="loading" />
</p>
<p>{{ answer }}</p>

Попробовать в Песочнице

Свойство watch также поддерживает путь, разделённый точками, в качестве ключа:

js
export default {
  watch: {
    // Примечание: только простые пути. Выражения не поддерживаются.
    'some.nested.key'(newValue) {
      // ...
    }
  }
}

С помощью Composition API мы можем использовать функцию watch для запуска обратного вызова при каждом изменении части реактивного состояния:

vue
<script setup>
import { ref, watch } from 'vue'

const question = ref('')
const answer = ref('Вопросы обычно содержат вопросительный знак. ;-)')
const loading = ref(false)

// watch работает напрямую с ref
watch(question, async (newQuestion, oldQuestion) => {
  if (newQuestion.includes('?')) {
    loading.value = true
    answer.value = 'Думаю...'
    try {
      const res = await fetch('https://yesno.wtf/api')
      answer.value = (await res.json()).answer
    } catch (error) {
      answer.value = 'Ошибка! Не удалось связаться с API. ' + error
    } finally {
      loading.value = false
    }
  }
})
</script>

<template>
  <p>
    Задайте вопрос «да/нет»:
    <input v-model="question" :disabled="loading" />
  </p>
  <p>{{ answer }}</p>
</template>

Попробовать в Песочнице

Типы источников Watch

Первым аргументом watch могут быть различные типы реактивных «источников»: Это может быть ссылка (включая вычисляемые ссылки), реактивный объект, геттер-функция или массив из нескольких источников:

js
const x = ref(0)
const y = ref(0)

// одиночная ссылка
watch(x, (newX) => {
  console.log(`x — ${newX}`)
})

// геттер
watch(
  () => x.value + y.value,
  (sum) => {
    console.log(`сумма x + y: ${sum}`)
  }
)

// массив из нескольких источников
watch([x, () => y.value], ([newX, newY]) => {
  console.log(`x — ${newX}, y — ${newY}`)
})

Обратите внимание, что вы не можете наблюдать за свойством реактивного объекта таким образом:

js
const obj = reactive({ count: 0 })

// Это не сработает, потому что мы передаем в watch() число
watch(obj.count, (count) => {
  console.log(`Счётчик: ${count}`)
})

Вместо этого используйте геттер:

js
// используем геттер:
watch(
  () => obj.count,
  (count) => {
    console.log(`Счётчик: ${count}`)
  }
)

Глубокие наблюдатели

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

js
export default {
  watch: {
    someObject: {
      handler(newValue, oldValue) {
        // Примечание: Здесь `newValue` будет равно `oldValue`
        // при вложенных мутациях до тех пор, пока сам объект
        // не будет заменен
      },
      deep: true
    }
  }
}

Когда вы вызываете watch() непосредственно на реактивном объекте, он неявно создаст глубокий наблюдатель — обратный вызов будет срабатывать на все вложенные мутации:

js
const obj = reactive({ count: 0 })

watch(obj, (newValue, oldValue) => {
  // срабатывает при мутациях вложенных свойств
  // Примечание: Здесь `newValue` будет равно `oldValue`,
  // потому что они оба указывают на один и тот же объект!
})

obj.count++

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

js
watch(
  () => state.someObject,
  () => {
    // срабатывает только при замене state.someObject
  }
)

Однако вы можете заставить второй вариант превратиться в глубокий наблюдатель, явно используя опцию deep:

js
watch(
  () => state.someObject,
  (newValue, oldValue) => {
    // Примечание: Здесь `newValue` будет равно `oldValue`,
    // *если* state.someObject не был заменен
  },
  { deep: true }
)

В Vue 3.5+ опция deep также может быть числом, указывающим на максимальную глубину обхода — т. е. на сколько уровней Vue должен обойти вложенные свойства объекта.

Используйте с осторожностью

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

Нетерпеливые наблюдатели

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

Мы можем заставить обратный вызов наблюдателя выполняться немедленно, объявив его с помощью объекта с функцией handler и параметром immediate: true опция:

js
export default {
  // ...
  watch: {
    question: {
      handler(newQuestion) {
        // будет выполняться непосредственно при создании компонента.
      },
      // принудительное выполнение обратного вызова
      immediate: true
    }
  }
  // ...
}

Начальное выполнение функции-обработчика произойдет непосредственно перед хуком created. Vue уже обработает свойства data, computed и methods, поэтому эти свойства будут доступны при первом вызове.

Мы можем заставить обратный вызов наблюдателя выполняться немедленно, передав ему параметр immediate: true:

js
watch(
  source,
  (newValue, oldValue) => {
    // выполняется сразу, затем снова, когда изменяется `source`.
  },
  { immediate: true }
)

Однократные наблюдатели

  • Поддерживается только в 3.4+

Обратный вызов наблюдателя будет выполняться при каждом изменении наблюдаемого источника. Если вы хотите, чтобы обратный вызов срабатывал только один раз при изменении источника, используйте опцию once: true.

js
export default {
  watch: {
    source: {
      handler(newValue, oldValue) {
        //при изменении `source` срабатывает только один раз
      },
      once: true
    }
  }
}
js
watch(
  source,
  (newValue, oldValue) => {
    // при изменении `source` срабатывает только один раз
  },
  { once: true }
)

watchEffect()

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

js
const todoId = ref(1)
const data = ref(null)

watch(
  todoId,
  async () => {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/todos/${todoId.value}`
    )
    data.value = await response.json()
  },
  { immediate: true }
)

В частности, обратите внимание, что наблюдатель использует todoId дважды: один раз в качестве источника, а затем ещё раз внутри обратного вызова.

Это можно упростить с помощью watchEffect(). watchEffect() позволяет нам автоматически отслеживать реактивные зависимости обратного вызова. Приведённый выше наблюдатель можно переписать в виде:

js
watchEffect(async () => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/todos/${todoId.value}`
  )
  data.value = await response.json()
})

Здесь обратный вызов будет выполнен немедленно, нет необходимости указывать immediate: true. Во время выполнения он будет автоматически отслеживать todoId.value как зависимость (аналогично вычисляемым свойствам). Когда todoId.value изменится, обратный вызов будет запущен снова. С watchEffect() нам больше не нужно явно передавать todoId в качестве исходного значения.

Вы можете посмотреть этот пример watchEffect() и реактивной выборки данных в действии.

Для таких примеров, как этот, с одной зависимостью, польза от watchEffect() относительно невелика. Но для наблюдателей, у которых есть несколько зависимостей, использование watchEffect() снимает бремя необходимости вести список зависимостей вручную. Кроме того, если вам нужно следить за несколькими свойствами во вложенной структуре данных, watchEffect() может оказаться более эффективным, чем глубокий наблюдатель, поскольку он будет отслеживать только те свойства, которые используются в обратном вызове, а не рекурсивно отслеживать их все.

Примечание

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

watch в сравнении с watchEffect

watch и watchEffect позволяют нам реактивно выполнять побочные эффекты. Их основное отличие заключается в способе отслеживания реактивных зависимостей:

  • watch отслеживает только явно наблюдаемый источник. Он не будет отслеживать ничего, к чему обращаются внутри обратного вызова. Кроме того, обратный вызов срабатывает только тогда, когда источник действительно изменился. watch отделяет отслеживание зависимостей от побочного эффекта, предоставляя нам более точный контроль над тем, когда должен сработать обратный вызов.

  • С другой стороны, watchEffect объединяет отслеживание зависимостей и побочных эффектов в одну фазу. Он автоматически отслеживает каждое реактивное свойство, к которому обращаются во время его синхронного выполнения. Это удобнее и обычно приводит к более короткому коду, но делает реактивные зависимости менее явными.

Очистка от побочных эффектов

Иногда мы можем выполнять побочные действия, например асинхронные запросы в наблюдателе:

js
watch(id, (newId) => {
  fetch(`/api/${newId}`).then(() => {
    // логика обратного вызова
  })
})
js
export default {
  watch: {
    id(newId) {
      fetch(`/api/${newId}`).then(() => {
        // логика обратного вызова
      })
    }
  }
}

Но что, если id изменится до завершения запроса? Когда предыдущий запрос завершится, он всё равно вызовет обратный вызов со значением ID, которое уже устарело. В идеале, мы хотим иметь возможность отменить устаревший запрос, когда id изменится на новое значение.

Можно использовать API onWatcherCleanup() для регистрации функции очистки, которая будет вызвана, когда наблюдатель станет недействительным и будет запущен снова:

js
import { watch, onWatcherCleanup } from 'vue'

watch(id, (newId) => {
  const controller = new AbortController()

  fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
    // логика обратного вызова
  })

  onWatcherCleanup(() => {
    // прерываем устаревший запрос
    controller.abort()
  })
})
js
import { onWatcherCleanup } from 'vue'

export default {
  watch: {
    id(newId) {
      const controller = new AbortController()

      fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
        // логика обратного вызова
      })

      onWatcherCleanup(() => {
        // прерываем устаревший запрос
        controller.abort()
      })
    }
  }
}

Обратите внимание, что onWatcherCleanup поддерживается только в Vue 3.5+ и должен вызываться во время синхронного выполнения функции эффекта watchEffect или функции обратного вызова watch: нельзя вызывать после оператора await в async-функции.

Кроме того, функция onCleanup также передается в обратные вызовы наблюдателя в качестве 3-го аргумента, а в функцию эффекта watchEffect — в качестве первого аргумента:

js
watch(id, (newId, oldId, onCleanup) => {
  // ...
  onCleanup(() => {
    // логика обратного вызова
  })
})

watchEffect((onCleanup) => {
  // ...
  onCleanup(() => {
    // логика обратного вызова
  })
})
js
export default {
  watch: {
    id(newId, oldId, onCleanup) {
      // ...
      onCleanup(() => {
        // логика обратного вызова
      })
    }
  }
}

Это работает в версиях до 3.5. Кроме того, onCleanup, переданный через аргумент функции, привязан к экземпляру наблюдателя, поэтому на него не распространяется ограничение синхронности onWatcherCleanup.

Время сброса обратного вызова

Когда вы изменяете реактивное состояние, это может вызвать как обновления компонентов Vue, так и обратные вызовы наблюдателей, созданные вами.

По умолчанию созданные пользователем обратные вызовы наблюдателя вызываются перед обновлениями компонентов Vue. Это означает, что если вы попытаетесь получить доступ к DOM внутри обратного вызова наблюдателя, DOM будет находиться в состоянии до того, как Vue применит какие-либо обновления.

Пост-наблюдатели

Если вы хотите получить доступ к DOM в обратном вызове наблюдателя после обновления Vue, вам нужно указать опцию flush: 'post':

js
export default {
  // ...
  watch: {
    key: {
      handler() {},
      flush: 'post'
    }
  }
}
js
watch(source, callback, {
  flush: 'post'
})

watchEffect(callback, {
  flush: 'post'
})

watchEffect() с опцией flush: 'post' также имеет удобный псевдоним — watchPostEffect():

js
import { watchPostEffect } from 'vue'

watchPostEffect(() => {
  /* выполняется после обновлений Vue */
})

Наблюдатели синхронизации

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

js
export default {
  // ...
  watch: {
    key: {
      handler() {},
      flush: 'sync'
    }
  }
}
js
watch(source, callback, {
  flush: 'sync'
})

watchEffect(callback, {
  flush: 'sync'
})

watchEffect() с опцией flush: 'sync' также имеет удобный псевдоним — watchSyncEffect():

js
import { watchSyncEffect } from 'vue'

watchSyncEffect(() => {
  /* выполняется синхронно при реактивном изменении данных */
})

Используйте с осторожностью

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

this.$watch()

Также можно принудительно создавать наблюдатели с помощью метода экземпляра $watch():

js
export default {
  created() {
    this.$watch('question', (newQuestion) => {
      // ...
    })
  }
}

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

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

Наблюдатели, объявленные с помощью свойства watch или метода экземпляра $watch(), автоматически останавливаются, когда компонент-владелец размонтирован, поэтому в большинстве случаев вам не нужно беспокоиться о том, чтобы останавливать наблюдателя самостоятельно.

В редких случаях, когда вам нужно остановить наблюдателя до того, как компонент-владелец размонтируется, API $watch() возвращает функцию для этого:

js
const unwatch = this.$watch('foo', callback)

// ...когда наблюдатель больше не нужен:
unwatch()

Наблюдатели, объявленные синхронно внутри setup() или <script setup>, привязываются к экземпляру компонента-владельца и будут автоматически остановлены, когда компонент-владелец будет размонтирован. В большинстве случаев вам не нужно беспокоиться о том, чтобы самостоятельно остановить наблюдателя.

Ключевым моментом здесь является то, что наблюдатель должен быть создан синхронно: Если наблюдатель создается в асинхронном обратном вызове, он не будет привязан к компоненту-владельцу и должен быть остановлен вручную, чтобы избежать утечки памяти. Вот пример:

vue
<script setup>
import { watchEffect } from 'vue'

// этот будет автоматически остановлен
watchEffect(() => {})

// ...этот - нет!
setTimeout(() => {
  watchEffect(() => {})
}, 100)
</script>

Чтобы вручную остановить наблюдателя, воспользуйтесь возвращаемым обработчиком. Это работает как для watch, так и для watchEffect:

js
const unwatch = watchEffect(() => {})

// ...позже, когда необходимость в них отпадет
unwatch()

Обратите внимание, что случаев, когда вам нужно создавать наблюдатели асинхронно, должно быть очень мало, и по возможности следует предпочесть синхронное создание. Если вам нужно дождаться каких-то асинхронных данных, вы можете сделать логику watch условной:

js
// данные для асинхронной загрузки
const data = ref(null)

watchEffect(() => {
  if (data.value) {
    // делать что-то при загрузке данных
  }
})
Наблюдатели