Представь: у тебя есть компонент корзины и компонент уведомлений. Когда товар добавлен в корзину, нужно показать toast. Как их связать без прямых зависимостей? Кастомные события — это встроенный в браузер механизм слабосвязанной коммуникации между компонентами.
Без кастомных событий компоненты вынуждены знать друг о друге напрямую. Корзина импортирует ToastManager, Analytics, Header — и при изменении одного падают все. Кастомные события позволяют компоненту просто объявить: «произошло событие» — а все заинтересованные подписчики реагируют самостоятельно.
// Создать и отправить стандартное событие
const event = new Event('my-event', {
bubbles: true, // событие всплывает вверх по DOM
cancelable: true, // можно отменить через event.preventDefault()
})
element.dispatchEvent(event)event.preventDefault()CustomEvent расширяет Event и позволяет передавать произвольные данные через свойство detail:
// Отправитель — создаёт событие с данными
const event = new CustomEvent('user:login', {
bubbles: true,
cancelable: false,
detail: {
userId: 42,
username: 'aleksey_petrov',
role: 'editor',
}
})
document.dispatchEvent(event)
// Получатель — читает данные из event.detail
document.addEventListener('user:login', (event) => {
console.log('Вошёл пользователь:', event.detail.username)
console.log('Роль:', event.detail.role)
})Принято называть кастомные события через двоеточие: компонент:действие:
// Компонент корзины
cart.dispatchEvent(new CustomEvent('cart:item-added', { detail: { productId: 101, qty: 2 } }))
cart.dispatchEvent(new CustomEvent('cart:cleared', { bubbles: true }))
// Компонент уведомлений (toast)
document.dispatchEvent(new CustomEvent('toast:show', {
detail: { message: 'Товар добавлен в корзину', type: 'success', duration: 3000 }
}))
// Аккордеон
panel.dispatchEvent(new CustomEvent('accordion:open', {
bubbles: true,
detail: { panelId: 'faq-1' }
}))// toast-component.js — отправляет событие
class ToastManager {
show(message, type = 'info') {
document.dispatchEvent(new CustomEvent('toast:show', {
detail: { message, type, id: Date.now() }
}))
}
hide(id) {
document.dispatchEvent(new CustomEvent('toast:hide', {
detail: { id }
}))
}
}
// app.js — слушает событие (слабая связанность!)
document.addEventListener('toast:show', ({ detail }) => {
console.log(`[${detail.type.toUpperCase()}] ${detail.message}`)
})Компонент, генерирующий событие, не знает ничего о компоненте, который его обрабатывает. Это и есть слабая связанность.
class Accordion {
constructor(element) {
this._el = element
this._openPanel = null
}
open(panelId) {
if (this._openPanel === panelId) return
this._openPanel = panelId
// Генерируем событие — другие компоненты могут среагировать
this._el.dispatchEvent(new CustomEvent('accordion:open', {
bubbles: true,
detail: { panelId, previousPanel: this._openPanel }
}))
}
close() {
const panelId = this._openPanel
this._openPanel = null
this._el.dispatchEvent(new CustomEvent('accordion:close', {
bubbles: true,
detail: { panelId }
}))
}
}dispatchEvent — синхронная операция и предназначена для межкомпонентного общения. Внутри компонента лучше просто вызывать методы:
// ПЛОХО — лишняя сложность для внутренней логики
class Cart {
addItem(item) {
this._items.push(item)
// зачем генерировать событие, если это просто внутренний вызов?
this._el.dispatchEvent(new CustomEvent('cart:_item-pushed', { detail: item }))
}
}
// ХОРОШО — события только для внешних подписчиков
class Cart {
addItem(item) {
this._items.push(item) // внутренняя логика — просто вызов
this._updateUI() // внутренняя логика — просто вызов
this._el.dispatchEvent(new CustomEvent('cart:item-added', { // событие для внешних
detail: { item, total: this._total }
}))
}
}В Node.js или sandbox-среде (без реального DOM) CustomEvent недоступен. Используется паттерн EventEmitter:
class EventEmitter {
constructor() {
this._handlers = new Map() // Map<eventName, Set<handler>>
}
on(eventName, handler) {
if (!this._handlers.has(eventName)) {
this._handlers.set(eventName, new Set())
}
this._handlers.get(eventName).add(handler)
return () => this.off(eventName, handler) // функция отписки
}
off(eventName, handler) {
this._handlers.get(eventName)?.delete(handler)
}
emit(eventName, detail) {
// Имитируем CustomEvent: передаём объект { type, detail }
this._handlers.get(eventName)?.forEach(h => h({ type: eventName, detail }))
}
}1. Забыть bubbles: true — событие не всплывает до document
// ПЛОХО — без bubbles событие ловится только на самом элементе
button.dispatchEvent(new CustomEvent('cart:add', {
detail: { productId: 42 }
// bubbles не указан — по умолчанию false
}))
document.addEventListener('cart:add', handler) // не сработает!
// ХОРОШО — с bubbles можно слушать на document
button.dispatchEvent(new CustomEvent('cart:add', {
bubbles: true,
detail: { productId: 42 }
}))
document.addEventListener('cart:add', handler) // работает2. Использовать обычные строки вместо соглашения component:action
// ПЛОХО — нет namespace, легко столкнуться с другим событием
element.dispatchEvent(new CustomEvent('update', { detail: data }))
element.dispatchEvent(new CustomEvent('click', { detail: data })) // конфликт со встроенным!
// ХОРОШО — namespace через двоеточие
element.dispatchEvent(new CustomEvent('cart:item-added', { detail: data }))
element.dispatchEvent(new CustomEvent('product:updated', { detail: data }))3. Мутировать event.detail напрямую
// ПЛОХО — получатель меняет переданные данные
document.addEventListener('user:login', (event) => {
event.detail.token = null // мутируем shared объект — опасно!
})
// ХОРОШО — делать копию в обработчике
document.addEventListener('user:login', (event) => {
const { token } = event.detail // деструктурируем нужное
processToken(token)
})dispatchEvent — это часть официального стандартаСистема уведомлений на основе CustomEvent-паттерна через EventEmitter (без DOM)
// EventEmitter — симуляция CustomEvent API без DOM
class EventEmitter {
constructor() {
this._handlers = new Map()
}
on(eventName, handler) {
if (!this._handlers.has(eventName)) {
this._handlers.set(eventName, new Set())
}
this._handlers.get(eventName).add(handler)
// Возвращаем функцию отписки (как removeEventListener)
return () => this._handlers.get(eventName)?.delete(handler)
}
off(eventName, handler) {
this._handlers.get(eventName)?.delete(handler)
}
emit(eventName, detail = null) {
// Имитируем объект CustomEvent: { type, detail }
const event = { type: eventName, detail }
this._handlers.get(eventName)?.forEach(h => h(event))
}
once(eventName, handler) {
const off = this.on(eventName, (event) => {
handler(event)
off() // автоматически отписываемся после первого вызова
})
}
}
// Глобальная шина событий — аналог document в браузере
const eventBus = new EventEmitter()
// ===== Компонент: ToastManager =====
class ToastManager {
constructor(bus) {
this._bus = bus
this._toasts = []
}
show(message, type = 'info', duration = 3000) {
const id = Date.now() + Math.random()
this._toasts.push({ id, message, type })
// Аналог: document.dispatchEvent(new CustomEvent('toast:show', { detail: ... }))
this._bus.emit('toast:show', { id, message, type, duration })
return id
}
hide(id) {
this._toasts = this._toasts.filter(t => t.id !== id)
this._bus.emit('toast:hide', { id })
}
get count() { return this._toasts.length }
}
// ===== Компонент: аналитика (слушает toast:show) =====
class Analytics {
constructor(bus) {
this._log = []
bus.on('toast:show', ({ detail }) => {
this._log.push({ event: 'toast_shown', type: detail.type, ts: Date.now() })
})
bus.on('user:login', ({ detail }) => {
this._log.push({ event: 'user_logged_in', username: detail.username, ts: Date.now() })
})
}
getLog() { return this._log }
}
// ===== Компонент: Logger =====
class Logger {
constructor(bus) {
bus.on('toast:show', ({ type, detail }) => {
console.log(`[TOAST:${detail.type.toUpperCase()}] ${detail.message}`)
})
bus.on('toast:hide', ({ detail }) => {
console.log(`[TOAST:HIDE] id=${detail.id}`)
})
bus.on('user:login', ({ detail }) => {
console.log(`[AUTH] Вошёл: ${detail.username} (роль: ${detail.role})`)
})
bus.on('user:logout', ({ detail }) => {
console.log(`[AUTH] Вышел: ${detail.username}`)
})
}
}
// ===== Инициализация =====
const logger = new Logger(eventBus) // подписываются на шину
const analytics = new Analytics(eventBus)
const toasts = new ToastManager(eventBus)
console.log('=== Демонстрация Event Bus ===')
// Симулируем вход пользователя
eventBus.emit('user:login', { username: 'мария_иванова', role: 'admin' })
// Показываем уведомления
const id1 = toasts.show('Добро пожаловать, Мария!', 'success')
const id2 = toasts.show('У вас 3 новых сообщения', 'info')
toasts.show('Сессия истекает через 5 минут', 'warning', 5000)
console.log('Активных уведомлений:', toasts.count) // 3
// Скрываем первое уведомление
toasts.hide(id1)
console.log('После скрытия:', toasts.count) // 2
// Выход
eventBus.emit('user:logout', { username: 'мария_иванова' })
// Один раз (once)
console.log('\n=== Демонстрация once() ===')
eventBus.once('cart:checkout', ({ detail }) => {
console.log('Оформлен заказ на сумму:', detail.total, 'руб.')
})
eventBus.emit('cart:checkout', { total: 4590, items: 3 }) // сработает
eventBus.emit('cart:checkout', { total: 800, items: 1 }) // НЕ сработает (once)
// Лог аналитики
console.log('\n=== Лог аналитики ===')
analytics.getLog().forEach(entry => {
console.log(`[${entry.event}]`, entry.type || entry.username || '')
})Представь: у тебя есть компонент корзины и компонент уведомлений. Когда товар добавлен в корзину, нужно показать toast. Как их связать без прямых зависимостей? Кастомные события — это встроенный в браузер механизм слабосвязанной коммуникации между компонентами.
Без кастомных событий компоненты вынуждены знать друг о друге напрямую. Корзина импортирует ToastManager, Analytics, Header — и при изменении одного падают все. Кастомные события позволяют компоненту просто объявить: «произошло событие» — а все заинтересованные подписчики реагируют самостоятельно.
// Создать и отправить стандартное событие
const event = new Event('my-event', {
bubbles: true, // событие всплывает вверх по DOM
cancelable: true, // можно отменить через event.preventDefault()
})
element.dispatchEvent(event)event.preventDefault()CustomEvent расширяет Event и позволяет передавать произвольные данные через свойство detail:
// Отправитель — создаёт событие с данными
const event = new CustomEvent('user:login', {
bubbles: true,
cancelable: false,
detail: {
userId: 42,
username: 'aleksey_petrov',
role: 'editor',
}
})
document.dispatchEvent(event)
// Получатель — читает данные из event.detail
document.addEventListener('user:login', (event) => {
console.log('Вошёл пользователь:', event.detail.username)
console.log('Роль:', event.detail.role)
})Принято называть кастомные события через двоеточие: компонент:действие:
// Компонент корзины
cart.dispatchEvent(new CustomEvent('cart:item-added', { detail: { productId: 101, qty: 2 } }))
cart.dispatchEvent(new CustomEvent('cart:cleared', { bubbles: true }))
// Компонент уведомлений (toast)
document.dispatchEvent(new CustomEvent('toast:show', {
detail: { message: 'Товар добавлен в корзину', type: 'success', duration: 3000 }
}))
// Аккордеон
panel.dispatchEvent(new CustomEvent('accordion:open', {
bubbles: true,
detail: { panelId: 'faq-1' }
}))// toast-component.js — отправляет событие
class ToastManager {
show(message, type = 'info') {
document.dispatchEvent(new CustomEvent('toast:show', {
detail: { message, type, id: Date.now() }
}))
}
hide(id) {
document.dispatchEvent(new CustomEvent('toast:hide', {
detail: { id }
}))
}
}
// app.js — слушает событие (слабая связанность!)
document.addEventListener('toast:show', ({ detail }) => {
console.log(`[${detail.type.toUpperCase()}] ${detail.message}`)
})Компонент, генерирующий событие, не знает ничего о компоненте, который его обрабатывает. Это и есть слабая связанность.
class Accordion {
constructor(element) {
this._el = element
this._openPanel = null
}
open(panelId) {
if (this._openPanel === panelId) return
this._openPanel = panelId
// Генерируем событие — другие компоненты могут среагировать
this._el.dispatchEvent(new CustomEvent('accordion:open', {
bubbles: true,
detail: { panelId, previousPanel: this._openPanel }
}))
}
close() {
const panelId = this._openPanel
this._openPanel = null
this._el.dispatchEvent(new CustomEvent('accordion:close', {
bubbles: true,
detail: { panelId }
}))
}
}dispatchEvent — синхронная операция и предназначена для межкомпонентного общения. Внутри компонента лучше просто вызывать методы:
// ПЛОХО — лишняя сложность для внутренней логики
class Cart {
addItem(item) {
this._items.push(item)
// зачем генерировать событие, если это просто внутренний вызов?
this._el.dispatchEvent(new CustomEvent('cart:_item-pushed', { detail: item }))
}
}
// ХОРОШО — события только для внешних подписчиков
class Cart {
addItem(item) {
this._items.push(item) // внутренняя логика — просто вызов
this._updateUI() // внутренняя логика — просто вызов
this._el.dispatchEvent(new CustomEvent('cart:item-added', { // событие для внешних
detail: { item, total: this._total }
}))
}
}В Node.js или sandbox-среде (без реального DOM) CustomEvent недоступен. Используется паттерн EventEmitter:
class EventEmitter {
constructor() {
this._handlers = new Map() // Map<eventName, Set<handler>>
}
on(eventName, handler) {
if (!this._handlers.has(eventName)) {
this._handlers.set(eventName, new Set())
}
this._handlers.get(eventName).add(handler)
return () => this.off(eventName, handler) // функция отписки
}
off(eventName, handler) {
this._handlers.get(eventName)?.delete(handler)
}
emit(eventName, detail) {
// Имитируем CustomEvent: передаём объект { type, detail }
this._handlers.get(eventName)?.forEach(h => h({ type: eventName, detail }))
}
}1. Забыть bubbles: true — событие не всплывает до document
// ПЛОХО — без bubbles событие ловится только на самом элементе
button.dispatchEvent(new CustomEvent('cart:add', {
detail: { productId: 42 }
// bubbles не указан — по умолчанию false
}))
document.addEventListener('cart:add', handler) // не сработает!
// ХОРОШО — с bubbles можно слушать на document
button.dispatchEvent(new CustomEvent('cart:add', {
bubbles: true,
detail: { productId: 42 }
}))
document.addEventListener('cart:add', handler) // работает2. Использовать обычные строки вместо соглашения component:action
// ПЛОХО — нет namespace, легко столкнуться с другим событием
element.dispatchEvent(new CustomEvent('update', { detail: data }))
element.dispatchEvent(new CustomEvent('click', { detail: data })) // конфликт со встроенным!
// ХОРОШО — namespace через двоеточие
element.dispatchEvent(new CustomEvent('cart:item-added', { detail: data }))
element.dispatchEvent(new CustomEvent('product:updated', { detail: data }))3. Мутировать event.detail напрямую
// ПЛОХО — получатель меняет переданные данные
document.addEventListener('user:login', (event) => {
event.detail.token = null // мутируем shared объект — опасно!
})
// ХОРОШО — делать копию в обработчике
document.addEventListener('user:login', (event) => {
const { token } = event.detail // деструктурируем нужное
processToken(token)
})dispatchEvent — это часть официального стандартаСистема уведомлений на основе CustomEvent-паттерна через EventEmitter (без DOM)
// EventEmitter — симуляция CustomEvent API без DOM
class EventEmitter {
constructor() {
this._handlers = new Map()
}
on(eventName, handler) {
if (!this._handlers.has(eventName)) {
this._handlers.set(eventName, new Set())
}
this._handlers.get(eventName).add(handler)
// Возвращаем функцию отписки (как removeEventListener)
return () => this._handlers.get(eventName)?.delete(handler)
}
off(eventName, handler) {
this._handlers.get(eventName)?.delete(handler)
}
emit(eventName, detail = null) {
// Имитируем объект CustomEvent: { type, detail }
const event = { type: eventName, detail }
this._handlers.get(eventName)?.forEach(h => h(event))
}
once(eventName, handler) {
const off = this.on(eventName, (event) => {
handler(event)
off() // автоматически отписываемся после первого вызова
})
}
}
// Глобальная шина событий — аналог document в браузере
const eventBus = new EventEmitter()
// ===== Компонент: ToastManager =====
class ToastManager {
constructor(bus) {
this._bus = bus
this._toasts = []
}
show(message, type = 'info', duration = 3000) {
const id = Date.now() + Math.random()
this._toasts.push({ id, message, type })
// Аналог: document.dispatchEvent(new CustomEvent('toast:show', { detail: ... }))
this._bus.emit('toast:show', { id, message, type, duration })
return id
}
hide(id) {
this._toasts = this._toasts.filter(t => t.id !== id)
this._bus.emit('toast:hide', { id })
}
get count() { return this._toasts.length }
}
// ===== Компонент: аналитика (слушает toast:show) =====
class Analytics {
constructor(bus) {
this._log = []
bus.on('toast:show', ({ detail }) => {
this._log.push({ event: 'toast_shown', type: detail.type, ts: Date.now() })
})
bus.on('user:login', ({ detail }) => {
this._log.push({ event: 'user_logged_in', username: detail.username, ts: Date.now() })
})
}
getLog() { return this._log }
}
// ===== Компонент: Logger =====
class Logger {
constructor(bus) {
bus.on('toast:show', ({ type, detail }) => {
console.log(`[TOAST:${detail.type.toUpperCase()}] ${detail.message}`)
})
bus.on('toast:hide', ({ detail }) => {
console.log(`[TOAST:HIDE] id=${detail.id}`)
})
bus.on('user:login', ({ detail }) => {
console.log(`[AUTH] Вошёл: ${detail.username} (роль: ${detail.role})`)
})
bus.on('user:logout', ({ detail }) => {
console.log(`[AUTH] Вышел: ${detail.username}`)
})
}
}
// ===== Инициализация =====
const logger = new Logger(eventBus) // подписываются на шину
const analytics = new Analytics(eventBus)
const toasts = new ToastManager(eventBus)
console.log('=== Демонстрация Event Bus ===')
// Симулируем вход пользователя
eventBus.emit('user:login', { username: 'мария_иванова', role: 'admin' })
// Показываем уведомления
const id1 = toasts.show('Добро пожаловать, Мария!', 'success')
const id2 = toasts.show('У вас 3 новых сообщения', 'info')
toasts.show('Сессия истекает через 5 минут', 'warning', 5000)
console.log('Активных уведомлений:', toasts.count) // 3
// Скрываем первое уведомление
toasts.hide(id1)
console.log('После скрытия:', toasts.count) // 2
// Выход
eventBus.emit('user:logout', { username: 'мария_иванова' })
// Один раз (once)
console.log('\n=== Демонстрация once() ===')
eventBus.once('cart:checkout', ({ detail }) => {
console.log('Оформлен заказ на сумму:', detail.total, 'руб.')
})
eventBus.emit('cart:checkout', { total: 4590, items: 3 }) // сработает
eventBus.emit('cart:checkout', { total: 800, items: 1 }) // НЕ сработает (once)
// Лог аналитики
console.log('\n=== Лог аналитики ===')
analytics.getLog().forEach(entry => {
console.log(`[${entry.event}]`, entry.type || entry.username || '')
})Реализуй класс EventBus с методами emit(eventName, detail), on(eventName, handler) и off(eventName, handler). Метод on должен возвращать функцию отписки. Затем создай ToastQueue поверх EventBus: метод add(message, type) генерирует событие "toast:show", метод clear() генерирует "toast:clear". Подпишись на оба события и выводи их в консоль.
on: this._handlers.get(eventName).add(handler). emit: this._handlers.get(eventName)?.forEach(h => h({ type: eventName, detail })). ToastQueue.add: this._bus.emit("toast:show", toast)