Всплытие событий через Shadow DOM

Shadow DOM создаёт изолированную область внутри веб-компонента, предоставляя локальный DOM, который защищён от внешних стилей и скриптов. Это создаёт особенности при работе с событиями, особенно когда используется библиотека Haunted для построения компонентов на основе хуков. Понимание механизма всплытия событий в Shadow DOM является критическим для корректной архитектуры компонентов.

Основные принципы всплытия

В обычном DOM событие, инициированное на элементе, проходит через три стадии:

  1. Capturing phase (фаза перехвата) — событие идёт сверху вниз от document к целевому элементу.
  2. Target phase (фаза цели) — событие достигает элемента, на котором оно было вызвано.
  3. Bubbling phase (фаза всплытия) — событие возвращается вверх по дереву DOM, проходя через всех предков.

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

Создание событий с composed: true

В Haunted для передачи пользовательских событий из внутреннего состояния компонента наружу используется CustomEvent. Пример:

import { html, component, useState } from 'haunted';

function Counter() {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(count + 1);
    const event = new CustomEvent('counter-incremented', {
      detail: { count: count + 1 },
      bubbles: true,
      composed: true // позволяет событию выйти из Shadow DOM
    });
    this.dispatchEvent(event);
  };

  return html`
    <div>
      <p>Счётчик: ${count}</p>
      <button @click=${increment}>Увеличить</button>
    </div>
  `;
}

customElements.define('x-counter', component(Counter));

Ключевой момент: без composed: true событие останется внутри Shadow DOM, и внешний код не сможет его отловить через addEventListener на компоненте.

Подписка на события из Shadow DOM

После того как событие становится composed, его можно слушать так же, как обычное событие на пользовательском компоненте:

const counter = document.querySelector('x-counter');
counter.addEventListener('counter-incremented', (e) => {
  console.log('Новое значение счётчика:', e.detail.count);
});

Haunted не изменяет стандартное поведение событий, поэтому все методы addEventListener работают корректно. Отличие только в том, что события внутри компонента нужно создавать с bubbles: true и composed: true.

Всплытие стандартных событий

Многие стандартные события, например click, input, уже настроены как composed, поэтому они автоматически всплывают за пределы Shadow DOM. Пример с Haunted:

function TextInput() {
  return html`<input @input=${e => console.log(e.target.value)} />`;
}

customElements.define('x-text-input', component(TextInput));

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

Особенности при композиции компонентов

Когда компонент Haunted содержит внутри другие компоненты с Shadow DOM, важно помнить:

  • События должны быть composed, если требуется передать их через несколько теневых корней.
  • Если событие не composed, его можно перехватить внутри локального компонента и заново сгенерировать наружное событие.

Пример повторной генерации события:

function ChildComponent() {
  const notifyParent = (e) => {
    const event = new CustomEvent('child-event', {
      detail: e.detail,
      bubbles: true,
      composed: true
    });
    this.dispatchEvent(event);
  };

  return html`<button @click=${(e) => notifyParent(e)}>Нажать</button>`;
}

customElements.define('x-child', component(ChildComponent));

Такой подход гарантирует корректную коммуникацию между компонентами даже через несколько уровней Shadow DOM.

Практические рекомендации

  • Использовать composed: true для всех событий, которые должны достигать родительских компонентов.
  • Следить за bubbles: true, если требуется всплытие через дерево компонентов.
  • Не создавать лишние события, когда можно использовать стандартные, уже composed события (click, input, change).
  • Документировать пользовательские события в компоненте, чтобы сторонний код понимал, какие события доступны для подписки.

Связь с хуками Haunted

Использование хуков, таких как useState и useEffect, не меняет правила всплытия. Однако хуки позволяют создавать события реактивно при изменении состояния:

function Toggle() {
  const [on, setOn] = useState(false);

  useEffect(() => {
    const event = new CustomEvent('toggle-changed', {
      detail: { on },
      bubbles: true,
      composed: true
    });
    this.dispatchEvent(event);
  }, [on]);

  return html`<button @click=${() => setOn(!on)}>${on ? 'Вкл' : 'Выкл'}</button>`;
}

customElements.define('x-toggle', component(Toggle));

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

В совокупности эти механизмы создают мощную модель взаимодействия компонентов внутри Shadow DOM, сохраняя изоляцию стилей и поведения, но позволяя гибко передавать события наружу.