Олег Марков
События компонента - events в современных интерфейсах
Введение
События компонента (events) — это основной способ общения между частями интерфейса. Компонент меняет свое состояние, пользователь нажимает кнопку, сервер присылает данные — все это удобнее всего обрабатывать через события.
Смотрите, я покажу вам, как думать о событиях не просто как о "кликах" и "change", а как о полноценном контракте между компонентом и внешним миром. Мы разберем, как:
- описывать и документировать события компонента;
- проектировать предсказуемое и удобное API событий;
- подписываться на события и отписываться от них;
- передавать полезные данные в обработчики;
- связывать события с состоянием и побочными эффектами;
- избегать утечек памяти и сложных цепочек зависимостей.
Чтобы объяснение было универсальным, я буду опираться на общие принципы (DOM, паттерн "наблюдатель", событийная шина), а примеры показывать на нескольких привычных подходах: "голый" JavaScript с DOM, а также типичный компонентный стиль с кастомными событиями.
Что такое событие компонента
Событие как контракт
Событие компонента — это сигнал, который компонент отправляет наружу, когда с ним произошло что-то важное. Важный момент: событие — это не внутренняя деталь реализации, а именно часть публичного интерфейса.
Компонент:
- инициирует событие — в определенный момент "говорит" внешнему миру, что что-то произошло;
- определяет нагрузку события — какие данные передаются вместе с событием;
- задает семантику — как трактовать это событие: "пользователь подтвердил заказ", "фильтр применен", "форма валидна/невалидна".
Внешний код:
- подписывается на событие;
- реагирует — обновляет состояние, вызывает API, изменяет другой компонент;
- при необходимости отписывается.
Базовая модель: кто кому что отправляет
Удобно думать о событиях как о реализации паттерна "наблюдатель":
- есть издатель (компонент-источник события);
- есть подписчики (другие части приложения, которые хотят знать о произошедшем);
- издатель не знает, кто именно подписан — он просто генерирует событие;
- подписчики не знают, как именно реализован компонент — они реагируют только на его события.
Это развязывает зависимости и делает код более модульным.
Виды событий компонента
Нативные события DOM и пользовательские события
События можно условно разделить на две группы:
- Нативные DOM-события: click, input, change, submit, keydown и т.п.
- Пользовательские (кастомные) события: "cart:itemAdded", "user:loggedIn", "modal:closed" и т.д.
Нативные события предоставляет браузер, а пользовательские вы придумываете сами для описания логики приложения.
Давайте разберем, как компонент может "перепаковывать" нативные события в свои более осмысленные пользовательские события.
Оборачивание нативных событий в события компонента
Представьте компонент "Счётчик". Внутри — кнопки и отображение числа. Но внешнему коду не важно, на какую именно кнопку кликнули, важно, что "значение изменилось".
Здесь я размещаю пример, чтобы вам было проще понять:
<div id="counter">
<button data-action="dec">-</button>
<span id="value">0</span>
<button data-action="inc">+</button>
</div>
// Создаем "компонент" счетчика как функцию-инициализатор
function createCounter(element) {
let value = 0
const valueElement = element.querySelector('#value')
function render() {
// Обновляем текст в DOM
valueElement.textContent = String(value)
}
// Обработчик кликов по кнопкам
function onClick(event) {
// Определяем, по какой кнопке кликнули
const action = event.target.getAttribute('data-action')
if (action === 'inc') {
value++
} else if (action === 'dec') {
value--
} else {
return
}
render()
// Генерируем пользовательское событие "change"
const customEvent = new CustomEvent('change', {
detail: { value } // Передаем новое значение
})
// Диспатчим событие от корневого элемента компонента
element.dispatchEvent(customEvent)
}
element.addEventListener('click', onClick)
render()
return {
// Позволяем снаружи подписываться на события компонента
onChange(handler) {
// Оборачиваем пользовательский обработчик для удобства
const listener = (event) => handler(event.detail.value)
element.addEventListener('change', listener)
return () => element.removeEventListener('change', listener)
}
}
}
Комментарии к примеру:
- компонент перехватывает нативное событие click;
- обновляет внутреннее состояние
value; - генерирует кастомное событие "change" и передает в него
detail.value; - наружу отдаёт метод
onChange, который прячет деталиCustomEventиdetail.
Так компонент превращает набор низкоуровневых кликов в одно осмысленное событие "значение счетчика изменилось".
Объявление и документирование событий компонента
Зачем описывать события явно
Если вы не фиксируете явно, какие события компонент генерирует, через пару месяцев и вы, и ваши коллеги будете разбираться в них только через чтение кода. Лучше сразу относиться к событиям как к части публичного API и документировать их.
Для каждого события стоит явно указать:
- имя события;
- когда оно возникает;
- какие данные передаются;
- гарантии и инварианты (например, что значение всегда число, что событие генерируется после обновления DOM и т.д.).
Пример описания событий
Представим компонент DatePicker. Вот как можно описать его события:
dateChange- Когда: пользователь выбрал дату в календаре.
- Payload: объект
{ date: Date, formatted: string }. - Гарантии:
dateвсегда валиден, форматformattedстабилен (например "YYYY-MM-DD").
open- Когда: календарь открыт пользователем или программно.
- Payload:
{ source: 'user' | 'api' }.
close- Когда: календарь закрыт.
- Payload:
{ reason: 'escape' | 'outside-click' | 'select' | 'api' }.
Такая документация помогает тем, кто будет использовать компонент, не заглядывая внутрь кода.
Явное описание в коде (поддерживаемый контракт)
В JavaScript без типов вы можете хотя бы явно задокументировать структуру:
/**
* Событие "dateChange"
* detail: {
* date: Date, // выбранная дата
* formatted: string // дата в формате YYYY-MM-DD
* }
*/
function emitDateChange(rootElement, date, formatted) {
const event = new CustomEvent('dateChange', {
detail: { date, formatted }
})
rootElement.dispatchEvent(event)
}
Здесь явная функция emitDateChange задает "шаблон" события. Все, кто вызывают ее, не должны придумывать структуру payload заново.
Подписка на события компонента
Базовый пример подписки в DOM
Сначала посмотрим, как подписаться на пользовательское событие, которое компонент выбрасывает через CustomEvent.
// Предположим, компонент размещен в DOM
const counterElement = document.getElementById('counter')
// Подписываемся на кастомное событие "change"
function onCounterChange(event) {
// В detail лежат данные, которые передал компонент
const newValue = event.detail.value
// Здесь вы можете обновить другой компонент или состояние приложения
console.log('Новое значение счетчика', newValue)
}
counterElement.addEventListener('change', onCounterChange)
// Позже, если обработчик больше не нужен:
counterElement.removeEventListener('change', onCounterChange)
Обратите внимание:
- обработчик принимает объект события, а не только значение;
- данные берутся из
event.detail, если вы используетеCustomEvent; - нужно помнить оригинальную ссылку на обработчик, чтобы правильно отписаться.
Подписка с оберткой (контракт посильнее)
Иногда удобно скрыть детали CustomEvent и работать только с чистыми payload-значениями:
function subscribeCounterChange(element, handler) {
// Оборачиваем стандартный обработчик в наш
const listener = (event) => {
// Передаем только полезное значение, без "лишних" данных
handler(event.detail.value)
}
element.addEventListener('change', listener)
// Возвращаем функцию отписки
return () => {
element.removeEventListener('change', listener)
}
}
// Использование:
const unsubscribe = subscribeCounterChange(counterElement, (value) => {
console.log('Значение изменилось на', value)
})
// Когда подписка больше не нужна
unsubscribe()
Такой подход уменьшает вероятность ошибок и дублирования логики обработки event.detail во многих местах.
Генерация (эмит) событий в компоненте
Простой эмит события
Теперь давайте посмотрим, как сам компонент генерирует событие.
function emitEvent(element, name, detail) {
// Создаем пользовательское событие с указанным именем и данными
const event = new CustomEvent(name, {
detail, // Полезная нагрузка, доступна как event.detail
bubbles: true, // Разрешаем всплытие по дереву DOM
cancelable: true // Позволяем подписчикам отменять действие
})
// Диспатчим событие от элемента компонента
element.dispatchEvent(event)
// Возвращаем флаг, было ли событие отменено
return !event.defaultPrevented
}
Комментарии:
bubbles: true— событие поднимается по DOM вверх, что удобно для "делегирования" и глобальной обработки;cancelable: true— подписчик может вызватьevent.preventDefault(), чтобы отменить действие компонента (например, "не закрывай модалку").
Управляемое поведение через отмену события
Давайте разберемся на примере, как подписчик может влиять на поведение компонента.
function createModal(element) {
function close() {
// Пытаемся "согласовать" закрытие с внешним кодом
const canClose = emitEvent(element, 'beforeClose', {
reason: 'user' // Причина закрытия
})
// Если событие не отменили, закрываем модалку
if (!canClose) {
// Внешний код вызвал preventDefault
return
}
element.classList.remove('is-open')
emitEvent(element, 'afterClose', { reason: 'user' })
}
// Пример: закрытие по клику на оверлей
element.querySelector('[data-close]').addEventListener('click', close)
return { close }
}
Внешний код может перехватить beforeClose:
modalElement.addEventListener('beforeClose', (event) => {
// Например, проверяем, сохранены ли изменения
const hasUnsavedChanges = true
if (hasUnsavedChanges) {
// Отменяем стандартное действие (закрытие)
event.preventDefault()
// Показываем предупреждение
console.log('Сначала сохраните изменения')
}
})
Как видите, этот код выполняет "диалог" между компонентом и внешним кодом: компонент предлагает действие, внешний код может его запретить.
Именование событий компонента
Почему имя события важно
Имя события — это ключ к пониманию логики. Плохо выбранные имена вроде "changed" или "ok" быстро приводят к путанице.
Хорошее имя события:
- описательное — понятно, что произошло;
- стабильное — меняется редко и осмысленно;
- однозначное — минимизирует двусмысленность.
Практические рекомендации по именованию
Событие как факт, а не команда
Используйте существительные или "прошедшие" формы:- лучше:
valueChange,dialogOpened,itemSelected; - хуже:
setValue,openDialog,doAction(звучит как команда к компоненту, а не факт, который он сообщает).
- лучше:
Явно указывайте сущность и действие
cart:itemAdded,cart:itemRemoved,user:authenticated,form:validated.
Такой формат с префиксом (
cart:,user:) особенно удобен в общей событийной шине.Согласуйте формат с командой
Внутри проекта вам будет проще, если все компоненты используют похожий стиль имен: camelCase, kebab-case или
namespace:eventName.
Передача данных в событиях
Что передавать, а что нет
В detail стоит передавать только те данные, которые действительно нужны внешнему коду:
- новые значения (например,
value,selectedItems); - контекст (например,
reason,source); - идентификаторы (например,
id,index).
Не стоит тащить туда:
- огромные структуры состояния;
- DOM-элементы, если это не строго необходимо;
- функции (это уже почти RPC, а не событие).
Пример полезного payload
Теперь вы увидите, как это выглядит в коде.
function emitSelectionChange(element, selectedIds) {
// selectedIds — массив идентификаторов выбранных элементов
const event = new CustomEvent('selectionChange', {
detail: {
selectedIds,
count: selectedIds.length
}
})
element.dispatchEvent(event)
}
// Подписка
listElement.addEventListener('selectionChange', (event) => {
const { selectedIds, count } = event.detail
console.log('Выбрано элементов', count, 'идентификаторы', selectedIds)
})
Здесь внешний код получает ровно ту информацию, которая ему нужна, без лишних деталей.
Жизненный цикл компонента и события
Моменты, когда стоит генерировать события
Условно, события компонента можно разделить на несколько групп по моменту:
События инициализации
ready,mounted,initialized.- Сообщают, что компонент готов к работе (DOM отрисован, подписки повешены).
События изменения состояния
valueChange,opened,collapsed,itemAdded.- Главное, чтобы эти события генерировались после обновления внутреннего состояния.
События жизненного цикла
beforeDestroy,destroyed.- Помогают внешнему коду освободить ресурсы и отписаться от своих внутренних подписок.
Важность порядка: сначала состояние, потом событие
Правило: компонент должен сначала изменить свое состояние, затем генерировать событие. Это снижает риск "рваных" состояний, когда обработчик события сразу же спрашивает у компонента его текущие данные.
Пример правильного порядка:
function toggle(element) {
const isOpen = element.classList.toggle('is-open')
// Сначала меняем DOM (и внутреннее состояние), затем сообщаем о факте
const event = new CustomEvent('toggle', {
detail: { isOpen }
})
element.dispatchEvent(event)
}
Отписка от событий и утечки памяти
Почему важно отписываться
Если компоненты создаются и уничтожаются динамически (например, список карточек, модальные окна), подписки на события, которые не были сняты, могут привести к:
- утечкам памяти (старые обработчики висят в памяти, хотя компонент исчез);
- "призрачным" вызовам (обработчик реагирует на событие, хотя логически уже не должен).
Паттерн "подписался — верни функцию отписки"
Давайте посмотрим, как сделать удобный API для этого.
function onEvent(element, name, handler, options) {
// Вешаем обработчик
element.addEventListener(name, handler, options)
// Возвращаем функцию отписки
return () => {
element.removeEventListener(name, handler, options)
}
}
// Использование:
const unsubscribe = onEvent(button, 'click', (event) => {
console.log('Нажали кнопку')
})
// Позже:
unsubscribe()
Компонент, который сам создает подписки, должен внутри своего "destroy" или "dispose" вызывать все функции отписки, которые он сохранил.
function createComponent(element) {
const unsubscribes = []
// Сохраняем функции отписки
unsubscribes.push(
onEvent(element, 'click', () => console.log('click'))
)
return {
destroy() {
// Отписываемся от всех событий
unsubscribes.forEach((fn) => fn())
}
}
}
Так вы напрямую связываете жизненный цикл компонента и жизненный цикл событий.
Всплытие и делегирование событий
Как работает всплытие
В DOM есть механизм всплытия: событие поднимается от конкретного элемента вверх по дереву до document. Для событий компонента это тоже можно использовать.
Если вы создаете компонент, который диспатчит событие с bubbles: true, то это событие "дойдет" до родительских контейнеров, и они могут реагировать на него централизованно.
const childElement = document.querySelector('.child')
const event = new CustomEvent('childAction', {
detail: { id: 123 },
bubbles: true
})
childElement.dispatchEvent(event)
// Родитель может подписаться один раз
document.querySelector('.parent').addEventListener('childAction', (event) => {
console.log('Сработало действие дочернего компонента', event.detail.id)
})
Делегирование для динамических компонент
Если список компонентов динамически меняется (карточки товаров, элементы формы), гораздо удобнее подписаться на событие один раз на контейнер и обрабатывать все вложенные компоненты.
const list = document.querySelector('.list')
// Один обработчик на контейнер
list.addEventListener('itemSelected', (event) => {
// Находим корневой элемент компонента, который инициировал событие
const itemRoot = event.target.closest('.item')
console.log('Сработало событие в элементе', itemRoot)
})
Так вы не привязываетесь к количеству компонентов в списке, и вам не нужно перестраивать подписки, когда список меняется.
Локальные события vs глобальная шина
Локальные события между родителем и дочерним компонентом
Когда событие интересует только "соседей" по иерархии (родитель — дети), имеет смысл оставаться в рамках DOM-событий компонента.
Примеры:
- дочерний компонент формы сообщает родителю о валидации;
- элемент списка сообщает родителю, что его выбрали;
- кнопка в модалке сообщает модалке, что ее пора закрыть.
Глобальная событийная шина
Иногда событие важно для всего приложения, а не только родителей по DOM. Тогда имеет смысл использовать глобальную шину событий (event bus).
Покажу вам, как это реализовано на практике в самом простом виде:
function createEventBus() {
const listeners = new Map()
return {
on(name, handler) {
if (!listeners.has(name)) {
listeners.set(name, new Set())
}
listeners.get(name).add(handler)
return () => {
listeners.get(name).delete(handler)
}
},
emit(name, payload) {
const handlers = listeners.get(name)
if (!handlers) return
// Вызываем всех подписчиков с переданным payload
handlers.forEach((handler) => handler(payload))
}
}
}
const bus = createEventBus()
// Подписка
const unsubscribe = bus.on('user:loggedIn', (user) => {
console.log('Пользователь вошел', user.id)
})
// Генерация события
bus.emit('user:loggedIn', { id: 1, name: 'Alice' })
Это уже не привязано к DOM и удобно для обмена событиями между независимыми компонентами.
Связь событий компонента с состоянием и побочными эффектами
События как триггеры побочных эффектов
События часто запускают:
- запросы к API;
- навигацию (смену маршрута);
- записи в localStorage;
- логи или метрики.
Важно отделять генерацию события от обработки эффекта, чтобы компонент не "знал" обо всех возможных реакциях.
// Компонент модалки
function createConfirmDialog(element) {
function confirm() {
// Сообщаем внешнему коду, что пользователь подтвердил
const event = new CustomEvent('confirm')
element.dispatchEvent(event)
}
element.querySelector('[data-confirm]').addEventListener('click', confirm)
}
// Внешний код
dialogElement.addEventListener('confirm', () => {
// Только здесь вызываем API или навигацию
console.log('Вызываем API подтверждения')
})
Так компонент остается переиспользуемым, а конкретные эффекты привязываются снаружи.
События и однонаправленный поток данных
Если вы используете архитектуру с однонаправленным потоком данных (например, паттерн Flux, Redux-подобные решения), полезно воспринимать события компонента как "источник действий" (actions).
Схема:
- Компонент эмитит событие с фактами: "пользователь выбрал фильтр".
- Обработчик переводит это событие в action:
{ type: 'FILTER_SET', payload: ... }. - Action меняет глобальное состояние.
- Это состояние снова "спускается" в компоненты через пропсы или контекст.
Таким образом компонент:
- не знает о глобальном состоянии;
- не вызывает напрямую глобальные методы;
- сообщает только о том, что произошло.
Обработка ошибок и устойчивость событийной системы
Ошибка в обработчике не должна ломать компонент
Если один из подписчиков некорректно обрабатывает событие (например, бросает исключение), это не должно "ронять" сам компонент или другие подписчики.
Если вы реализуете свою шину событий, стоит оборачивать вызовы обработчиков в try/catch.
function safeEmit(listeners, payload) {
listeners.forEach((handler) => {
try {
handler(payload)
} catch (error) {
// Локализуем ошибку внутри одного обработчика
console.error('Ошибка в обработчике события', error)
}
})
}
В случае DOM-событий браузер сам обрабатывает ошибки, но хорошая практика — не полагаться на это и писать обработчики так, чтобы они были устойчивы к частичным ошибкам.
Не завязывайтесь на порядок подписчиков
События должны быть по возможности коммутативными: порядок вызова подписчиков не должен менять результат.
Если ваш код зависит от того, кто именно отреагирует первым, это признак слишком тесной связности. Лучше явно разделить шаги (например, предобработку и финальное действие) в разные события (beforeX, afterX), а не надеяться на порядок подписки.
Заключение
События компонента — это фундаментальный механизм связи между частями интерфейса. Если относиться к ним как к первоклассному элементу архитектуры, а не как к "побочному эффекту" кликов, вы получаете:
- более понятные и предсказуемые компоненты;
- четкий контракт между компонентами и внешним кодом;
- удобную точку подключения побочных эффектов;
- лучшую управляемость и меньше скрытых зависимостей.
Мы разобрали:
- модель событий как реализации паттерна "наблюдатель";
- разницу между нативными и пользовательскими событиями;
- практику объявления и документирования событий;
- генерацию, подписку и отписку;
- работу с payload, всплытие и делегирование;
- локальные события и глобальную событийную шину;
- связь событий с состоянием и побочными эффектами, а также устойчивость к ошибкам.
Используя эти приемы, вы сможете проектировать компоненты с ясным событийным API, которые будет проще использовать, сопровождать и развивать.
Частозадаваемые технические вопросы по теме и ответы
Как правильно тестировать события компонента
В юнит-тестах удобно проверять не сам факт клика, а то, что компонент сгенерировал нужное событие с правильным payload.
- Создайте элемент и инициализируйте компонент.
- Подпишитесь на ожидаемое событие и сохраните
event.detailво внешнюю переменную. - Смоделируйте действие (клик, ввод).
- Проверьте, что событие было вызвано и payload соответствует ожиданию.
let payload = null
element.addEventListener('change', (event) => {
payload = event.detail
})
// Здесь имитируем действие
button.click()
// В тесте проверяем содержимое payload
expect(payload.value).toBe(1)
Как передать несколько разных значений в событии без "мешанины"
Сформируйте четкий объект payload и придерживайтесь его структуры.
const event = new CustomEvent('submit', {
detail: {
formData, // объект с данными формы
meta: {
source: 'user',
timestamp: Date.now()
}
}
})
Старайтесь избегать "плоской каши" с множеством несвязанных полей, группируйте их по смыслу.
Как связать события нескольких независимых компонент
Используйте общую событийную шину (event bus):
- Компонент A при наступлении локального события эмитит глобальное событие:
bus.emit('filter:changed', payload). - Компонент B подписывается:
bus.on('filter:changed', handler).
Так компоненты не зависят друг от друга напрямую и могут находиться в разных частях дерева DOM.
Как временно отключить реакцию на события без удаления подписки
Сделайте обертку над обработчиком с флагом активности.
let enabled = true
function handler(event) {
if (!enabled) return
// Основная логика
}
element.addEventListener('change', handler)
// Включение/выключение
enabled = false // обработчик перестанет реагировать
enabled = true // снова начнет
Это удобно, если нужно временно "заглушить" реакции, не трогая структуру подписок.
Как избежать конфликта имен событий в большом приложении
Используйте неймспейсы в именах событий:
cart:itemAddeduser:profileUpdatedmodal:opened
А также договоритесь в команде о едином формате. Для DOM-событий компонентов это уменьшит риск, что два разных компонента используют одно и то же имя для разных вещей.
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

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