← JavaScript/Генерация пользовательских событий#145 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

Генерация пользовательских событий

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

Что решает этот механизм

Без кастомных событий компоненты вынуждены знать друг о друге напрямую. Корзина импортирует ToastManager, Analytics, Header — и при изменении одного падают все. Кастомные события позволяют компоненту просто объявить: «произошло событие» — а все заинтересованные подписчики реагируют самостоятельно.

На основе предыдущих уроков

  • addEventListener, removeEventListener — кастомные события используют тот же API подписки
  • классы — паттерн EventEmitter реализуется через класс
  • Event — базовый класс

    // Создать и отправить стандартное событие
    const event = new Event('my-event', {
      bubbles: true,      // событие всплывает вверх по DOM
      cancelable: true,   // можно отменить через event.preventDefault()
    })
    
    element.dispatchEvent(event)
  • bubbles: true — событие пройдёт вверх по DOM-дереву
  • cancelable: true — обработчики могут вызвать event.preventDefault()
  • CustomEvent — событие с данными

    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-уведомлений

    // 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 для внутренней логики?

    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 }
        }))
      }
    }

    EventEmitter — паттерн без DOM

    В 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)
    })

    В реальных проектах

  • Micro-frontend архитектура: разные команды разрабатывают независимые части страницы; кастомные события — стандартный канал связи между ними
  • Web Components: кастомные элементы общаются с окружением через dispatchEvent — это часть официального стандарта
  • CMS плагины (WordPress, Drupal): расширения подписываются на события ядра через аналоги EventEmitter
  • Аналитика: компонент не знает о системе аналитики, просто генерирует события; аналитика слушает их и отправляет в GA/Яндекс.Метрику
  • Примеры

    Система уведомлений на основе 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 — и при изменении одного падают все. Кастомные события позволяют компоненту просто объявить: «произошло событие» — а все заинтересованные подписчики реагируют самостоятельно.

    На основе предыдущих уроков

  • addEventListener, removeEventListener — кастомные события используют тот же API подписки
  • классы — паттерн EventEmitter реализуется через класс
  • Event — базовый класс

    // Создать и отправить стандартное событие
    const event = new Event('my-event', {
      bubbles: true,      // событие всплывает вверх по DOM
      cancelable: true,   // можно отменить через event.preventDefault()
    })
    
    element.dispatchEvent(event)
  • bubbles: true — событие пройдёт вверх по DOM-дереву
  • cancelable: true — обработчики могут вызвать event.preventDefault()
  • CustomEvent — событие с данными

    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-уведомлений

    // 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 для внутренней логики?

    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 }
        }))
      }
    }

    EventEmitter — паттерн без DOM

    В 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)
    })

    В реальных проектах

  • Micro-frontend архитектура: разные команды разрабатывают независимые части страницы; кастомные события — стандартный канал связи между ними
  • Web Components: кастомные элементы общаются с окружением через dispatchEvent — это часть официального стандарта
  • CMS плагины (WordPress, Drupal): расширения подписываются на события ядра через аналоги EventEmitter
  • Аналитика: компонент не знает о системе аналитики, просто генерирует события; аналитика слушает их и отправляет в GA/Яндекс.Метрику
  • Примеры

    Система уведомлений на основе 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)

    Загружаем среду выполнения...
    Загружаем AI-помощника...