Каждый, кто использует Vue для разработки или только его изучает, так или иначе встречается с необходимостью выполнить какое‑либо побочное действие при изменении значений, и сразу в голове возникает мысль о двух методах‑наблюдателях — Watch и WatchEffect.

Как работают эти 2 метода можно узнать из великолепной документации Vue, а в этой статье мы посмотрим на примеры самых часто используемых компонентов вместе с наблюдателями — по 2 компонента на каждый метод — а заодно вы сможете больше понять принцип их работы.

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

Watch

Поисковая строка

Начнем с более простого примера.

Первым делом давайте создадим простой компонент, который будет содержать поле для ввода (обычный инпут) и с помощью v-model привяжем к нему реактивную переменную:

<script setup>
import { ref } from 'vue';

const search = ref('');
</script>

<template>
  <input v-model="search" type="text" placeholder="Поиск по сайту">
</template>

Если вы не знакомы с работой v-model, то об этом можете почитать в документации.

Теперь самое интересное — это логика работы поиска: при изменении строки поиска будет выполняться запрос к серверу с соответствующим значением. В качестве сервера с данными будем использовать JSONPlaceholder с адресом сервера, где search — поисковой запрос:

https://jsonplaceholder.typicode.com/posts?q={search}

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

Напишем в скрипте функцию для запроса данных fetchPosts(val), которая состоит из одного параметра, в который будет попадать обновленное значение поиска:

const fetchPosts = async (val) => {
  try {
    const response = await fetch(`https://jsonplaceholder.typicode.com/posts?q=${val}`);

    if (!response.ok) {
      throw new Error('Что-то пошло не так');
    }

    const data = await response.json();

    return data;
  } catch (e) {
    console.error(e);
    return [];
  }
};

Будем вызывать эту функцию каждый раз, когда мы вводим новое значение и выводить полученные данные в консоль. Здесь нам и нужен наблюдатель за значением поиска для выполнения побочного действия. Используем метод Watch, т.к. отслеживаем всего одно значение. Первым аргументом будет отслеживаемое значение (без обращения к search.value, Vue сделает это за нас), а вторым аргументом выступает колбэк‑функция, первым параметром которой будет обновленное значение поиска:

watch(search, async (newVal) => {
  const data = await fetchPosts(newVal);
  console.log(data);
});

Поиск готов, при каждом изменении символа в строке поиска вызывается функция fetchPosts(), а затем данные выводятся в консоль.

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

Для этого используем отложенный наблюдатель WatchDebounced вместо обычного Watch, который содержит библиотека утилит VueUse. Эта библиотека используется в большинстве проектов Vue. Достаточно настроить время задержки:

watchDebounced(search, async (newVal) => {
  const data = await fetchPosts(newVal);
  console.log(data);
}, { debounce: 800, maxWait: 1600 });

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

Как можете заметить количество запросов поиска сократилось практически до 1 вместо 34. Очень заметная оптимизация. Двигаемся к следующему примеру.

Валидация форм

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

Составим шаблон простой формы для подписки на рассылку, состоящей из 3-х полей: имя, почта и чекбокс о согласии с политикой обработки ПД:

<template>
  <form>
    <label for="name">Имя</label>
    <input type="text" id="name" name="name" placeholder="Введите имя">
    <span>Текст ошибки</span>

    <label for="email">Email</label>
    <input type="email" id="email" name="email" placeholder="example@gmail.com">
    <span>Текст ошибки</span>

    <input type="checkbox" id="agreement" name="agreement">
    <label for="agreement">Согласен с политикой обрабоки <a href="#">персональных данных</a></label>
    <span>Текст ошибки</span>

    <button type="submit">Отправить</button>
  </form>
</template>

Создадим реактивный объект, содержащий значения всех полей и сообщений об ошибках:

<script setup>
import { reactive } from 'vue';

const fields = reactive({
  name: { value: '', error: '' },
  email: { value: '', error: '' },
  agreement: { value: false, error: '' }
});

</script>

Не забудем также привязать поля формы к соответствующим полям реактивного объекта с помощью v-model и распределить их ошибки:

<template>
  <form @submit.prevent="handleSubmit">
    <label for="name">Имя</label>
    <input v-model="fields.name.value" type="text" id="name" name="name" placeholder="Введите имя">
    <span v-if="fields.name.error">{{ fields.name.error }}</span>

    <label for="email">Email</label>
    <input v-model="fields.email.value" type="email" id="email" name="email" placeholder="example@gmail.com">
    <span v-if="fields.email.error">{{ fields.email.error }}</span>

    <input v-model="fields.agreement.value" type="checkbox" id="agreement" name="agreement">
    <label for="agreement">Согласен с политикой обрабоки <a href="#">персональных данных</a></label>
    <span v-if="fields.agreement.error">{{ fields.agreement.error }}</span>

    <button type="submit">Отправить</button>
  </form>
</template>

Перед тем, как перейти к разработке валидации, создадим базовую функцию проверки ошибок и отправки данных формы. С помощью computed создадим вычисляемое значение hasErrors, в котором проходит проверка всех значений объекта fields на наличие ошибок:

const hasErrors = computed(() => {
  return Object.values(fields).some(field => field.error);
});

И напишем обработчик отправки формы, который просто выводит в консоль сообщение об успешной отправке, если нет ни одной ошибки формы, а затем сбрасывает все значения формы до исходного состояния:

const handleSubmit = async () => {
  if (hasErrors.value) return;

  try {
    console.log('Форма отправлена:', {
      name: fields.name.value,
      email: fields.email.value,
      agreement: fields.agreement.value
    });

    fields.name = { value: '', error: '' };
    fields.email = { value: '', error: '' };
    fields.agreement = { value: false, error: '' }
  } catch (error) {
    console.error('Ошибка отправки формы:', error);
  }
};

Теперь самое интересное. Нужно наблюдать за каждым полем формы и при его изменении проводить валидацию. Но есть один нюанс – Watch не умеет отслеживать вложенные поля объектов, которые передаются напрямую. В этом случае следует применить функцию-getter, которая вернет необходимое значение. Посмотрим на пример валидации поля имени:

// ✅ Правильный способ – используется функция-getter
watch(() => fields.name.value, (newName) => {
  fields.name.error = newName.trim() ? '' : 'Обязательное поле';
});

// ❌ Такой способ не сработает. Vue не сможет отследить переданное значение
watch(fields.name.value, (newName) => {
  fields.name.error = newName.trim() ? '' : 'Обязательное поле';
});

Выше мы также следим за новым значением имени и у реактивного объекта fields устанавливаем соответствующее сообщение об ошибке.

Похожим образом добавим валидацию остальных полей:

// Валидация email
watch(() => fields.email.value, (newEmail) => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  fields.email.error = !newEmail.trim()
    ? 'Обязательное поле'
    : !emailRegex.test(newEmail) ? 'Некорректный email' : '';
});

// Валидация соглашения
watch(() => fields.agreement.value, (newAgreement) => {
  fields.agreement.error = newAgreement ? '' : 'Требуется согласие';
});

Последний момент, который стоит учесть, это проблема при отправке формы, как только компонент формы появился на странице. Дело в том, что Watch не вызывается при создании компонента, и форма будет отправлена даже с неправильными значениями, т.к. ошибок полей по умолчанию нет.

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

watch(() => fields.name.value, (newName) => {
  ...
}, { immediate: true });

// ----

watch(() => fields.email.value, (newEmail) => {
  ...
}, { immediate: true });

// ----

watch(() => fields.agreement.value, (newAgreement) => {
  ...
}, { immediate: true });

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

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

<script setup>
import { reactive, computed, watch } from 'vue';

const fields = reactive({
  name: { value: '', error: '' },
  email: { value: '', error: '' },
  agreement: { value: false, error: '' }
});

const hasErrors = computed(() => {
  return Object.values(fields).some(field => field.error);
});

const validateName = (field) => {
  fields.name.error = field.trim() ? '' : 'Обязательное поле';
}

const validateEmail = (field) => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  fields.email.error = !field.trim()
    ? 'Обязательное поле'
    : !emailRegex.test(field)
      ? 'Некорректный email'
      : '';
}

const validateAgreement = (field) => {
  fields.agreement.error = field ? '' : 'Требуется согласие';
}

const validateAll = () => {
  validateName(fields.name.value);
  validateEmail(fields.email.value);
  validateAgreement(fields.agreement.value);
}

const handleSubmit = async () => {
  validateAll();

  if (hasErrors.value) return;

  try {
    console.log('Форма отправлена:', {
      name: fields.name.value,
      email: fields.email.value,
      agreement: fields.agreement.value
    });

    fields.name = { value: '', error: '' };
    fields.email = { value: '', error: '' };
    fields.agreement = { value: false, error: '' }
  } catch (error) {
    console.error('Ошибка отправки формы:', error);
  }
};

// Валидация имени
watch(() => fields.name.value, (newName) => {
  validateName(newName);
});

// Валидация email
watch(() => fields.email.value, (newEmail) => {
  validateEmail(newEmail);
});

// Валидация соглашения
watch(() => fields.agreement.value, (newAgreement) => {
  validateAgreement(newAgreement);
});

</script>

<template>
  <form @submit.prevent="handleSubmit">
    <label for="name">Имя</label>
    <input v-model="fields.name.value" type="text" id="name" name="name" placeholder="Введите имя">
    <span v-if="fields.name.error">{{ fields.name.error }}</span>

    <label for="email">Email</label>
    <input v-model="fields.email.value" type="email" id="email" name="email" placeholder="example@gmail.com">
    <span v-if="fields.email.error">{{ fields.email.error }}</span>

    <input v-model="fields.agreement.value" type="checkbox" id="agreement" name="agreement">
    <label for="agreement">Согласен с политикой обрабоки <a href="#">персональных данных</a></label>
    <span v-if="fields.agreement.error">{{ fields.agreement.error }}</span>

    <button type="submit">Отправить</button>
  </form>
</template>

Таким простым образом мы можем подключить живую валидацию формы без использования сторонних библиотек.

Также существуют очень полезные библиотеки для валидации форм. Их стоит использовать для форм с более сложной логикой и большим количеством полей. С ними стоит обязательно познакомиться, т.к. в проектах они используются достаточно часто: VeeValidate и, если используете TypeScript, ZOD схемы. Обе библиотеки отлично работают в связке друг с другом.

WatchEffect

Наблюдатель WatchEffect очень похож на Watch, но с рядом отличий:

  • Не принимает аргумент с отслеживаемой переменной. Вместо этого, он отслеживает изменения всех реактивных значений, которые читаются внутри функции-колбека этого наблюдателя. То есть чаще всего этот метод используется для наблюдения за несколькими значениями одновременно.

  • В отличие от Watch, WatchEffect срабатывает сразу при создании компонента и это поведение нельзя изменить

Если попытаться привести Watch к такому же поведению, что и WatchEffect, то получится следующее:

// Не задаем параметров и не передаем отслеживаемое значение
watchEffect(() => {
  ...
});

// Передаем отслеживаемое значение первым аргументом, а также объект
// с параметрами
watch(reactiveValue, () => {
  ...
}, { deep: true, immediate: true })

Для того, чтобы наглядно показать разницу и возможности WatchEffect, посмотрим на предыдущий пример с реализацией валидации форм.

Валидация форм

В прошлом примере мы использовали три раздельных наблюдателя за каждым полем. С помощью WatchEffect мы можем легко объединить их.

Вместо такого кода:

// Валидация имени
watch(() => fields.name.value, (newName) => {
  validateName(newName);
});

// Валидация email
watch(() => fields.email.value, (newEmail) => {
  validateEmail(newEmail);
});

// Валидация соглашения
watch(() => fields.agreement.value, (newAgreement) => {
  validateAgreement(newAgreement);
});

Получаем следующий:

watchEffect(() => {
  validateName(fields.name.value);
  validateEmail(fields.email.value);
  validateAgreement(fields.agreement.value);
});

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

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

const prevValues = {
  name: '',
  email: '',
  agreement: false,
};

Затем добавим в наблюдатель проверку текущих значений формы с предыдущими. Если есть разница, значит было изменено определенное поле и именно у него выполняем валидацию. В конце не забудем поменять данные предыдущих значений:

watchEffect(() => {
  if (fields.name.value !== prevValues.name) validateName(fields.name.value);
  if (fields.email.value !== prevValues.email) validateEmail(fields.email.value);
  if (fields.agreement.value !== prevValues.agreement) validateAgreement(fields.agreement.value);

  prevValues.name = fields.name.value;
  prevValues.email = fields.email.value;
  prevValues.agreement = fields.agreement.value;
});

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

Код выше срабатывает из-за того, что мы явно прочитали реактивные значения объекта fields

Перейдем к последнему компоненту.

Фильтрация товаров

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

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

// Исходный список товаров
const items = ref([
  { id: 1, name: 'Ноутбук', category: 'Электроника', price: 80000 },
  { id: 2, name: 'Кроссовки', category: 'Одежда', price: 6000 },
  { id: 3, name: 'Смартфон', category: 'Электроника', price: 50000 },
  { id: 4, name: 'Книга', category: 'Книги', price: 800 },
  { id: 5, name: 'Футболка', category: 'Одежда', price: 1200 },
  { id: 6, name: 'Холодильник', category: 'Бытовая техника', price: 45000 },
  { id: 7, name: 'Сковорода', category: 'Кухня', price: 2500 },
  { id: 8, name: 'Чайник', category: 'Бытовая техника', price: 3000 },
  { id: 9, name: 'Пылесос', category: 'Бытовая техника', price: 15000 },
  { id: 10, name: 'Рюкзак', category: 'Аксессуары', price: 3500 }
])

// Категории для селекта
const categories = [...new Set(items.value.map(i => i.category))]

</script>


<template>
  <div>
    <h1>Список товаров</h1>

    <!-- Список товаров -->
    <div>
      <div v-for="item in items" :key="item.id">
        <h2>{{ item.name }}</h2>
        <p>Категория: {{ item.category }}</p>
        <p>Цена: {{ item.price }}₽</p>
      </div>
      <p v-if="items.length === 0">Нет товаров по заданным критериям</p>
    </div>
  </div>
</template>

Кратко, что здесь происходит:

  • Массив товаров items состоит из продуктов. Каждый объект продукта имеет название, категорию и цену.

  • С помощью цикла v-for динамически отображаются все продукты на странице.

  • Если элементов для отображения нет, то выводит сообщение об этом.

Теперь добавим шаблон формы и реактивный объект, к которому мы привяжем поля фильтров с помощью v-model:

<script setup>
// ...

const filters = reactive({
  name: '',
  category: '',
  maxPrice: null,
});
</script>

<template>
  <div>
    <input v-model="filters.name" type="text" placeholder="Поиск по названию" />
    <select v-model="filters.category">
      <option value="">Все категории</option>
      <option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
    </select>
    <input v-model.number="filters.maxPrice" type="number" placeholder="Макс. цена" />
  </div>

  // ...
</template>

Также заметьте, что в привязке цены был использован модификатор .number, который преобразовывает значение в число (по умолчанию в значение попадает строка, несмотря на тип поля number).

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

const filteredItems = ref([])

watchEffect(() => {
  filteredItems.value = items.value.filter(item => {
    const matchName = item.name.toLowerCase().includes(filters.name.toLowerCase())
    const matchCategory = filters.category ? item.category === filters.category : true
    const matchPrice = filters.maxPrice ? item.price <= filters.maxPrice : true
    return matchName && matchCategory && matchPrice
  })
})

В коде выше мы явно читаем поля реактивного объекта filters, отчего watchEffect срабатывает каждый раз, при изменении любого фильтра.

Осталось лишь подменить items на filteredItems в шаблоне:

<div>
  <div v-for="item in filteredItems" :key="item.id">
    <h2>{{ item.name }}</h2>
    <p>Категория: {{ item.category }}</p>
    <p>Цена: {{ item.price }}₽</p>
  </div>
  <p v-if="filteredItems.length === 0">Нет товаров по заданным критериям</p>
</div>

Теперь фильтрация полностью готова. Но давайте посмотрим на то, как мы можем улучшить код.

Сейчас мы внутри WatchEffect меняем значение одной реактивной переменной – filteredItems. Vue поддерживает встроенный метод computed, напоминающий watchEffect, с тем отличием, что computed обязательно должен возвращать какое-либо значение и записывать его в переменную. Получится следующее:

// Было
const filteredItems = ref([])

watchEffect(() => {
  filteredItems.value = items.value.filter(item => {
    ...
  })
})

// Cтало
const filteredItems = computed(() => items.value.filter(item => {
  ...
}))

Таким образом сделаем вывод:

Если watchEffect своей логикой изменяет значение одной реактивной переменной, то его можно заменить с помощью computed, сразу вернув значение в нужную переменную, которая при этом останется реактивной.

Теперь вы знаете

  1. В каких случаях применять watch и watchEffect.

  2. Способы реализации компонентов поиска, валидации форм и фильтрации товаров по множественным фильтрам.

  3. Несколько полезных библиотек, облегчающих процесс разработки.

К комментариях смело указывайте, как бы Вы улучшили представленные в статье компоненты.

Если статья была Вам полезна, можете также посетить мой Телеграм канал, в котором найдете другую полезную для себя информацию – https://t.me/sanwed_it

Комментарии (1)


  1. Notactic
    07.06.2025 11:05

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

    Ваши примеры тому подтверждение, вы сделали их через слежение, просто потому, что можете. Всё это делается через событие элементов формы, что в дальнейшем проще дебажить.