Олег Марков
Наблюдатели watchers - от паттерна до практических реализаций
Введение
Наблюдатели (watchers) — это общий подход к отслеживанию изменений: в данных, событиях, файлах, конфигурации, состоянии приложения. Вы наверняка сталкивались с ними, даже если так их не называли: подписка на события, реактивные переменные, вотчеры файлов при разработке фронтенда или перезапуске сервера при изменении кода.
Здесь важно разделять два уровня:
- архитектурный паттерн Наблюдатель (Observer pattern);
- конкретные реализации watchers в инструментах и фреймворках.
В статье я последовательно разберу обе стороны: сначала концепцию, затем практические реализации в разных контекстах — от фронтенда до DevOps. По пути мы посмотрим примеры кода и типичные проблемы, с которыми вы можете столкнуться.
Что такое Наблюдатель (Observer) как паттерн
Базовая идея
Паттерн Наблюдатель описывает зависимость типа "один-ко-многим":
есть объект-издатель (subject), и есть один или несколько наблюдателей (observers).
Когда состояние издателя меняется, он уведомляет всех подписанных наблюдателей.
Смотрите, базовая структура выглядит так:
- Subject — хранит состояние и список наблюдателей;
- Observer — объект, который хочет знать об изменениях субъекта;
- метод attach/subscribe — подписка на уведомления;
- метод detach/unsubscribe — отписка;
- метод notify — рассылка уведомлений всем наблюдателям.
Пример на упрощенном Go
Здесь я размещаю пример, чтобы вам было проще понять саму идею без привязки к какому-то фреймворку:
package main
import "fmt"
// Observer описывает того, кто реагирует на обновление
type Observer interface {
Update(data string) // Метод вызывается при изменении состояния
}
// Subject хранит подписчиков и рассылает им события
type Subject struct {
observers []Observer // Список всех подписчиков
state string // Некоторое состояние
}
// Attach добавляет нового наблюдателя
func (s *Subject) Attach(o Observer) {
s.observers = append(s.observers, o)
}
// Detach удаляет наблюдателя по ссылке
func (s *Subject) Detach(o Observer) {
for i, obs := range s.observers {
if obs == o {
// Удаляем элемент из среза
s.observers = append(s.observers[:i], s.observers[i+1:]...)
break
}
}
}
// Notify оповещает всех подписчиков об изменении состояния
func (s *Subject) Notify() {
for _, obs := range s.observers {
// Передаем новое состояние каждому наблюдателю
obs.Update(s.state)
}
}
// SetState меняет состояние и вызывает Notify
func (s *Subject) SetState(newState string) {
s.state = newState
s.Notify()
}
// Конкретный наблюдатель
type LoggerObserver struct {
name string
}
func (l *LoggerObserver) Update(data string) {
// Здесь наблюдатель реагирует на изменения
fmt.Printf("[%s] Получено новое состояние - %s\n", l.name, data)
}
func main() {
subject := &Subject{} // Создаем издателя
// Создаем двух наблюдателей
logger1 := &LoggerObserver{name: "Logger1"}
logger2 := &LoggerObserver{name: "Logger2"}
// Подписываем их
subject.Attach(logger1)
subject.Attach(logger2)
// Меняем состояние - оба наблюдателя получат уведомление
subject.SetState("Первое обновление")
subject.SetState("Второе обновление")
}
Как видите, этот код выполняет простую вещь: любое изменение состояния субъекта автоматически приводит к вызову методов у всех наблюдателей. Вотчеры во фреймворках обычно реализуют ту же идею, но прячут детали внутри себя.
Где используются watchers на практике
Чтобы лучше воспринимать тему, давайте сразу разложим по основным областям:
- реактивные UI-фреймворки (Vue watchers, Angular watchers, MobX, Pinia и т.п.);
- наблюдение за файловой системой (file watchers в инструментах сборки, серверах, dev-средах);
- наблюдение за конфигурацией и внешними ресурсами (Kubernetes, Consul и т.д.);
- инфраструктурный мониторинг (watch в CLI-инструментах, подписка на метрики и события).
Дальше мы последовательно пройдемся по этим сценариям и разберем, как там устроены watchers.
Watchers в реактивных фреймворках (фронтенд)
Зачем вообще нужны watchers в UI
В реактивных фреймворках данные и интерфейс связаны: вы меняете данные — UI обновляется автоматически.
В идеале достаточно декларативного биндинга (data binding), но бывают случаи, когда вам нужно:
- выполнить побочный эффект при изменении конкретного значения;
- синхронизировать данные с localStorage или URL;
- реагировать на связку значений, а не только одно поле;
- "подслушать" изменение, не меняя сам шаблон.
Здесь и появляются watchers — наблюдатели за изменениями данных.
Ниже я рассмотрю на примерах, близких к Vue, потому что там термин watchers используется напрямую, но идея похожа и в других библиотеках.
Простой watcher за одним полем
Давайте разберемся на простом синтетическом примере на базе Vue-подобного синтаксиса:
const app = createApp({
data() {
return {
query: "", // Поисковая строка
results: [] // Результаты поиска
}
},
watch: {
// Вотчер за полем query
query(newVal, oldVal) {
// Здесь мы реагируем на любые изменения query
// Например, вызываем API поиска
this.search(newVal) // Метод компонента
}
},
methods: {
async search(query) {
// Здесь мог бы быть реальный HTTP-запрос
// Для примера просто логируем
console.log("Ищем по запросу", query)
// Обновляем результаты (заглушка)
this.results = ["Результат 1", "Результат 2"]
}
}
})
app.mount("#app")
Комментарии к примеру:
- watch.query — это и есть watcher, он "подписан" на изменения свойства query;
- как только вы меняете query (например, с привязкой к input), вызывается функция watcher;
- newVal и oldVal позволяют сравнить значения и при необходимости фильтровать изменения.
Глубокий watcher для объектов
Когда вы наблюдаете за объектом, важный момент: не все фреймворки по умолчанию отслеживают вложенные изменения. Иногда нужно явно указать, что вотчер должен "погружаться" в объект.
Теперь вы увидите, как это выглядит в коде:
const app = createApp({
data() {
return {
user: {
name: "Alex",
address: {
city: "Moscow"
}
}
}
},
watch: {
// Наблюдаем за всем объектом user
user: {
handler(newVal, oldVal) {
// Обратите внимание - будет вызываться при изменении любого поля user
console.log("Пользователь изменен", newVal)
},
deep: true // Глубокое отслеживание вложенных полей
}
}
})
Если не включить deep, во многих реализациях вотчер сработает только при полной замене user целиком, а не при изменении, например, user.address.city.
Watchers vs computed свойства
Частый вопрос: "Когда использовать watcher, а когда вычисляемое (computed) свойство?"
Коротко:
- computed — для получения нового значения на основе других данных (без побочных эффектов);
- watcher — для реакции и побочных эффектов: запросов, логирования, сохранения в хранилища.
Например, если вам нужно просто отображать полное имя:
computed: {
fullName() {
// Чистая функция без побочных эффектов
return this.firstName + " " + this.lastName
}
}
А если нужно вызывать API при изменении фильтров:
watch: {
filters: {
handler(newFilters) {
// Здесь уже явно побочный эффект - запрос к API
this.fetchData(newFilters)
},
deep: true
}
}
Отписка и управление временем жизни watcher
В важных случаях вам нужно уметь:
- создавать watcher динамически;
- уметь его отключить, когда он больше не нужен.
Покажу вам, как это реализовано на практике в стиле Composition API (идея понятна и без знания деталей):
import { ref, watch, onMounted, onUnmounted } from "vue"
export default {
setup() {
const count = ref(0) // Реактивная переменная
let stopWatcher // Здесь будем хранить функцию для остановки
onMounted(() => {
// Создаем watcher программно
stopWatcher = watch(count, (newVal, oldVal) => {
console.log("count изменился", newVal)
})
})
onUnmounted(() => {
// Очень важно - отписаться, когда компонент уничтожается
if (stopWatcher) {
stopWatcher() // Эта функция приходит от watch
}
})
return { count }
}
}
Это логика, которая часто скрыта внутри фреймворка, но вам важно понимать: любой watcher — это подписка, и к ней нужно относиться как к ресурсу, который нужно освобождать.
Watchers для файловой системы
Зачем нужны файловые watchers
Вы постоянно пользуетесь ими в фоновом режиме:
- webpack / Vite / Parcel пересобирают проект при изменении файлов;
- nodemon или подобные инструменты перезапускают сервер, когда вы меняете код;
- тестовые раннеры запускают тесты при изменениях.
Под капотом эти инструменты используют watcher за файловой системой.
Общая идея
Вместо того чтобы:
- периодически опрашивать файловую систему (polling);
- постоянно читать каталоги;
watcher регистрирует "подписку" на изменения (создание, изменение, удаление файлов) у ОС или/и запускает эффективный цикл опроса с кэшированием.
Пример watchers в Node.js
Давайте посмотрим, что происходит в следующем примере на Node.js:
const fs = require("fs")
// Наблюдаем за изменениями конкретного файла
const filePath = "./config.json"
const watcher = fs.watch(filePath, (eventType, filename) => {
// eventType может быть "change" или "rename"
if (eventType === "change") {
console.log("Файл изменен", filename)
// Здесь вы можете перечитать файл конфигурации
// и применить обновленные настройки
} else if (eventType === "rename") {
console.log("Файл был переименован или удален", filename)
}
})
// Через какое-то время можно остановить наблюдение
setTimeout(() => {
watcher.close() // Освобождаем ресурс
console.log("Наблюдение за файлом остановлено")
}, 60000) // Останавливаем через минуту
Комментарии:
- fs.watch работает по-разному на разных платформах, но общая идея одна — вы подписываетесь на изменения;
- важно не забывать вызывать watcher.close, когда наблюдение больше не нужно.
Наблюдение за каталогами и игнорирование файлов
Часто нужно следить не за одним файлом, а за целым деревом директорий с гиброй фильтрацией. В продвинутых задачах чаще используют высокоуровневые библиотеки, например chokidar.
Давайте разберемся на примере с chokidar:
const chokidar = require("chokidar")
// Настраиваем watcher на каталог src
const watcher = chokidar.watch("src", {
ignored: /(^|[\/\\])\../, // Игнорируем скрытые файлы и папки
persistent: true, // Наблюдать до явного закрытия
ignoreInitial: true // Не реагировать на уже существующие файлы
})
// Реагируем на добавление файла
watcher.on("add", path => {
console.log("Новый файл", path)
})
// Реагируем на изменение файла
watcher.on("change", path => {
console.log("Файл изменен", path)
})
// Реагируем на удаление файла
watcher.on("unlink", path => {
console.log("Файл удален", path)
})
// При необходимости можно остановить наблюдение
process.on("SIGINT", async () => {
console.log("Останавливаем watcher...")
await watcher.close()
process.exit(0)
})
Обратите внимание, как этот фрагмент кода решает задачу:
- вы можете гибко фильтровать файлы через ignored;
- игнорировать начальное состояние (иначе вы получите события для всех уже существующих файлов);
- грамотно останавливать watcher при завершении процесса.
Watchers в Kubernetes и подобных системах
Подписка на изменения объектов
В системах оркестрации вроде Kubernetes вам нужно эффективно следить за изменениями ресурсов:
- Pod;
- Deployment;
- ConfigMap;
- Secret и т.д.
Вместо того чтобы отправлять бесконечные GET-запросы, используются механизмы watch — по сути, это тот же паттерн Наблюдатель, но в контексте HTTP/stream API.
Пример команды kubectl
Давайте посмотрим на простой пример использования kubectl:
kubectl get pods --watch
Комментарии:
- эта команда не завершится после вывода списка;
- она будет "подписана" на дальнейшие изменения подов и сразу выводить новые события;
- под капотом используется механизм watch API Kubernetes.
Пример кода на Go с использованием client-go
Теперь давайте перейдем к следующему шагу и взглянем, как можно реализовать программный watcher:
package main
import (
"context"
"fmt"
"time"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
)
func main() {
// Загружаем конфигурацию kubeconfig (обычно из ~/.kube/config)
config, err := clientcmd.BuildConfigFromFlags("", "/home/user/.kube/config")
if err != nil {
panic(err)
}
// Создаем клиент Kubernetes
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
panic(err)
}
// Создаем контекст с тайм-аутом для примера
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Запускаем watcher за подами в namespace default
watcher, err := clientset.CoreV1().Pods("default").Watch(ctx, metav1.ListOptions{})
if err != nil {
panic(err)
}
defer watcher.Stop()
// Получаем канал событий
ch := watcher.ResultChan()
for event := range ch {
// Приводим объект к типу Pod
pod, ok := event.Object.(*v1.Pod)
if !ok {
continue // Пропускаем, если тип не тот
}
fmt.Printf("Событие %s - Pod %s - статус %s\n",
event.Type, // Тип события - ADDED MODIFIED DELETED
pod.Name, // Имя пода
pod.Status.Phase) // Текущая фаза - Pending Running Succeeded Failed Unknown
}
}
Что здесь важно:
- watcher.ResultChan возвращает канал, из которого вы читаете события;
- event.Type отражает тип события;
- вы должны корректно завершать watcher по контексту или другим условиям, чтобы не было утечек ресурсов.
Архитектурные тонкости и подводные камни watchers
Проблема утечек подписок
Каждый watcher — это подписка. Если не управлять временем жизни:
- вы можете продолжать получать события, когда объект уже "логически" не существует;
- собирается "мусор" в памяти, особенно при множестве временных watchers;
- возникают неожиданные побочные эффекты (дублирующиеся запросы, двойная обработка событий).
Общие рекомендации:
- всегда иметь явный способ отписки (функция stop/close/unsubscribe);
- в UI — привязывать отписку к жизненному циклу компонента;
- в CLI/серверных — к контексту, сигналам завершения или срокам действия.
Дребезг и троттлинг (debounce/throttle)
Частая проблема: watcher срабатывает слишком часто. Например, вы вешаете его на input и отправляете запрос при каждом изменении строки поиска.
Чтобы не перегружать API и не "дергать" логику, используют:
- debounce — выполнить действие только если после изменения прошло N миллисекунд без новых изменений;
- throttle — разрешить выполнение не чаще одного раза в N миллисекунд.
Пример с debounce для watcher в браузерном коде:
function debounce(fn, delay) {
let timerId
return function (...args) {
// Сбрасываем предыдущий таймер
clearTimeout(timerId)
// Назначаем новый
timerId = setTimeout(() => {
fn.apply(this, args) // Вызываем оригинальную функцию
}, delay)
}
}
// Представим, что мы внутри компонента
export default {
data() {
return {
query: ""
}
},
created() {
// Оборачиваем метод поиска в debounce
this.debouncedSearch = debounce(this.search, 300)
},
watch: {
query(newVal) {
// Вместо прямого вызова this.search
// вызываем "задебаунсенный" вариант
this.debouncedSearch(newVal)
}
},
methods: {
search(q) {
// Здесь уже можно безопасно вызывать API
console.log("Ищем по запросу", q)
}
}
}
Здесь дебаунс позволяет не вызывать search при каждом нажатии клавиши, а только после паузы длиной 300 мс.
Гранулярность наблюдения
Чем более "широкий" объект вы наблюдаете, тем больше ложных срабатываний:
- watcher за весь объект user будет вызываться и при изменении города, и при изменении телефона;
- иногда выгодно дробить состояние на части и ставить watchers точечно.
Подход:
- анализировать, на какие именно изменения вам важно реагировать;
- не наблюдать "за всем миром" без реальной необходимости;
- комбинировать watchers с чистыми вычислениями (computed) и мемоизацией.
Пошаговое проектирование собственного механизма watchers
Чтобы лучше прочувствовать тему, полезно один раз реализовать свой механизм наблюдателей "с нуля". Я покажу упрощенный пример на JavaScript, который можно потом адаптировать под свою задачу.
Реактивное значение с подпиской
Смотрите, я покажу вам, как это работает на минималистичном примере:
// Простая реализация "реактивного" значения
class ReactiveValue {
constructor(initial) {
this.value = initial // Текущее значение
this.subscribers = [] // Список подписчиков
}
get() {
// Возвращаем значение
return this.value
}
set(newVal) {
// Если значение не изменилось - ничего не делаем
if (newVal === this.value) return
const oldVal = this.value // Сохраняем старое значение
this.value = newVal // Обновляем
// Уведомляем всех подписчиков
this.subscribers.forEach(fn => fn(newVal, oldVal))
}
watch(fn) {
// Добавляем наблюдателя
this.subscribers.push(fn)
// Возвращаем функцию для отписки
return () => {
this.subscribers = this.subscribers.filter(sub => sub !== fn)
}
}
}
// Пример использования
const count = new ReactiveValue(0)
// Подписываемся на изменения
const stop = count.watch((newVal, oldVal) => {
console.log("count изменился с", oldVal, "на", newVal)
})
// Меняем значение - будет вызван watcher
count.set(1)
count.set(2)
// Отписываемся
stop()
// После отписки watcher вызываться не будет
count.set(3)
Этот код иллюстрирует суть:
- есть хранилище значения;
- есть список "подписчиков";
- каждый раз при set мы сравниваем значения и вызываем все подписки при изменении;
- watch возвращает функцию отписки.
На практике фреймворки добавляют:
- автоматическую привязку задач к текущему контексту;
- оптимизации (батчинг, планировщики и т.п.);
- поддержку сложных структур (объекты, массивы, деревья).
Заключение
Наблюдатели (watchers) — это общий подход, который проявляется в разных слоях разработки:
- на архитектурном уровне — паттерн Наблюдатель, реализующий зависимость "один-ко-многим" между объектами;
- в UI — реактивные watchers, которые позволяют выполнять побочные эффекты при изменении данных;
- в инфраструктуре — подписки на изменения файлов, конфигураций, Kubernetes-ресурсов и т.д.
Ключевые моменты работы с watchers:
- любой watcher — это подписка, ей нужно управлять (отписываться, ограничивать время жизни);
- нужно следить за частотой срабатываний, использовать debounce/throttle там, где есть частые изменения;
- важно выбирать нужную "гранулярность" наблюдения, не вешать watcher "на все" без необходимости;
- правильная комбинация watchers, чистых вычислений и явной логики значительно упрощает код.
Если держать в голове базовый паттерн Наблюдатель и понимать общие принципы подписки и отписки, вы будете легче ориентироваться в любых конкретных реализациях watchers — от фронтенд-фреймворков до DevOps-инструментов.
Частозадаваемые технические вопросы и ответы
Как отлаживать цепочку watchers, если непонятно, кто именно реагирует на изменение
Добавляйте точечное логирование в каждый важный watcher с уникальным префиксом. Например, в UI-фреймворке:
watch: {
value(newVal) {
console.log("[Watcher A] value", newVal)
}
}
Аналогично в Node.js или Go. При сложных связях имеет смысл завести единый логгер и включать режим "trace" только при отладке.
Как избежать циклических обновлений между watchers
Типичный сценарий — watcher A изменяет значение, которое отслеживает watcher B, а он в свою очередь меняет обратно исходное. Решения:
- сохраняйте флаг "внутреннего" обновления и пропускайте реакцию на него;
- разделяйте "источник истины" и производные значения, не смешивайте их в двусторонних связях;
- в UI избегайте схем, где два поля "взаимно" синхронизируют друг друга без общего центра.
Как реализовать watcher для асинхронных операций так, чтобы избегать гонок
Если watcher делает асинхронный запрос, предыдущий запрос может вернуться позже следующего. Используйте:
- токены/идентификаторы запросов и проверяйте, что ответ принадлежит последнему запросу;
- отмену предыдущих запросов (AbortController в браузере, context в Go);
- храните "номер версии" данных и игнорируйте ответы со старой версией.
Как сделать watcher, который реагирует только на "значимые" изменения объекта
Сравнивайте не весь объект целиком, а только нужные поля. Один из подходов:
- в watcher считайте "ключ" (hash/строку) из нужных полей;
- сравнивайте новый ключ со старым;
- выполняйте логику только при изменении ключа.
Это снижает количество ложных срабатываний при изменении несущественных полей.
Как масштабировать большое количество watchers без потери производительности
Подходы:
- группировать watchers — вместо сотен подписок на мелкие флаги использовать один watcher на "модель состояния" и внутри уже распределять логику;
- использовать батчинг — собирать события за короткий промежуток времени и обрабатывать их пачкой;
- выносить "горячие" участки логики из watchers в отдельные оптимизированные слои (например, предрасчет, работа с индексами или кэшами).
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев