ДокументацияHTMLWeb Components: Custom Elements, Shadow DOM, Templates
Средний 9 мин чтения

Web Components: Custom Elements, Shadow DOM, Templates

Web Components — нативные компоненты браузера. Custom Elements, Shadow DOM для инкапсуляции, HTML Templates и Slots, подключение стилей и атрибуты.

Web ComponentsCustom ElementsShadow DOMtemplatesslotsвеб-компонентыHTML

Что такое Web Components

Web Components — нативная технология браузера для создания переиспользуемых компонентов. Не нужен React, Vue или Angular — компоненты работают в любом проекте, с любым фреймворком или без него.

Три технологии:

  • Custom Elements — свои HTML-теги
  • Shadow DOM — изолированные стили и разметка
  • HTML Templates — шаблоны, которые не рендерятся до использования

Custom Elements

Создание элемента

class MyButton extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<button>Нажми</button>`
  }
}

customElements.define('my-button', MyButton)

Использование:

<my-button></my-button>

Имя тега обязательно с дефисом (my-button, user-card, app-header). Без дефиса — ошибка, чтобы не конфликтовать со стандартными тегами.

Жизненный цикл

class UserCard extends HTMLElement {
  constructor() {
    super()
    console.log('Создан')
  }

  connectedCallback() {
    console.log('Добавлен в DOM')
    this.render()
  }

  disconnectedCallback() {
    console.log('Удалён из DOM')
  }

  attributeChangedCallback(name, oldVal, newVal) {
    console.log(`Атрибут ${name} изменён: ${oldVal}${newVal}`)
    this.render()
  }

  static get observedAttributes() {
    return ['name', 'role']
  }

  render() {
    this.innerHTML = `
      <div class="card">
        <h3>${this.getAttribute('name') || 'Без имени'}</h3>
        <p>${this.getAttribute('role') || ''}</p>
      </div>
    `
  }
}

customElements.define('user-card', UserCard)
<user-card name="Анна" role="Разработчик"></user-card>

observedAttributes

Чтобы реагировать на изменения атрибутов — укажите их в observedAttributes. Тогда attributeChangedCallback вызовется при каждом изменении:

document.querySelector('user-card').setAttribute('name', 'Иван')

Shadow DOM

Shadow DOM создаёт изолированное дерево DOM внутри элемента. Стили внутри не утекают наружу, внешние стили не ломают компонент.

Подключение Shadow DOM

class MyTooltip extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        .tooltip {
          position: relative;
          display: inline-block;
          cursor: help;
        }
        .tooltip-text {
          visibility: hidden;
          position: absolute;
          bottom: 100%;
          left: 50%;
          transform: translateX(-50%);
          background: #1f2937;
          color: white;
          padding: 4px 8px;
          border-radius: 4px;
          font-size: 12px;
          white-space: nowrap;
        }
        .tooltip:hover .tooltip-text {
          visibility: visible;
        }
      </style>
      <div class="tooltip">
        <slot></slot>
        <span class="tooltip-text">${this.getAttribute('text')}</span>
      </div>
    `
  }
}

customElements.define('my-tooltip', MyTooltip)
<my-tooltip text="Подсказка">Наведи курсор</my-tooltip>

open и closed

  • mode: 'open'element.shadowRoot доступен из JavaScript
  • mode: 'closed'element.shadowRoot возвращает null, доступ только внутри класса

Используйте openclosed почти не нужен на практике.

Инкапсуляция стилей

Стили внутри Shadow DOM не влияют на внешнюю страницу, и наоборот:

<style>
  p { color: red; }
</style>

<my-component></my-component>

<script>
  class MyComponent extends HTMLElement {
    constructor() {
      super()
      this.attachShadow({ mode: 'open' })
    }
    connectedCallback() {
      this.shadowRoot.innerHTML = `
        <style>
          p { color: blue; }
        </style>
        <p>Этот текст синий, не красный</p>
      `
    }
  }
  customElements.define('my-component', MyComponent)
</script>

Templates и Slots

template

<template> — HTML, который не рендерится, но доступен через JS:

<template id="card-template">
  <style>
    .card {
      border: 1px solid #e5e7eb;
      border-radius: 8px;
      padding: 16px;
    }
    .card h3 { margin: 0 0 8px; }
  </style>
  <div class="card">
    <h3><slot name="title">Заголовок</slot></h3>
    <div><slot>Контент по умолчанию</slot></div>
  </div>
</template>
class CardTemplate extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
  }

  connectedCallback() {
    const template = document.getElementById('card-template')
    this.shadowRoot.appendChild(template.content.cloneNode(true))
  }
}

customElements.define('card-tpl', CardTemplate)

slot

<slot> — место, куда вставляется дочерний контент:

<card-tpl>
  <span slot="title">Моя карточка</span>
  <p>Содержимое карточки</p>
</card-tpl>

Именованные слоты — slot="title" попадает в <slot name="title">. Контент без slot — в <slot> без имени (default slot).

Взаимодействие снаружи

CSS Custom Properties — «прокол» Shadow DOM

CSS-переменные проходят через Shadow DOM:

this.shadowRoot.innerHTML = `
  <style>
    .btn {
      background: var(--btn-bg, #6366f1);
      color: var(--btn-color, white);
      border: none;
      padding: 8px 16px;
      border-radius: 8px;
      cursor: pointer;
    }
  </style>
  <button class="btn"><slot></slot></button>
`
<style>
  my-button {
    --btn-bg: #ef4444;
  }
</style>

<my-button>Красная кнопка</my-button>

::part() — стилизация изнутри снаружи

this.shadowRoot.innerHTML = `
  <style>.header { padding: 16px; }</style>
  <div class="header" part="header"><slot></slot></div>
`
my-card::part(header) {
  background: #f9fafb;
}

События

События из Shadow DOM по умолчанию retarget'ятся — event.target указывает на host-элемент, не на внутренний:

this.shadowRoot.querySelector('button').addEventListener('click', () => {
  this.dispatchEvent(new CustomEvent('my-click', {
    bubbles: true,
    composed: true,
    detail: { value: 42 }
  }))
})

composed: true — событие проходит через Shadow DOM-границу.

document.querySelector('my-button').addEventListener('my-click', (e) => {
  console.log(e.detail.value)
})

Практический пример — Modal

class ModalDialog extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.close = this.close.bind(this)
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host([open]) .backdrop { display: grid; }
        .backdrop {
          display: none;
          position: fixed;
          inset: 0;
          background: rgba(0, 0, 0, 0.5);
          place-items: center;
          z-index: 1000;
        }
        .dialog {
          background: white;
          border-radius: 12px;
          padding: 24px;
          max-width: 500px;
          width: 90%;
        }
        .close {
          float: right;
          background: none;
          border: none;
          font-size: 20px;
          cursor: pointer;
        }
      </style>
      <div class="backdrop">
        <div class="dialog">
          <button class="close" aria-label="Закрыть">&times;</button>
          <slot></slot>
        </div>
      </div>
    `

    this.shadowRoot.querySelector('.close').addEventListener('click', this.close)
    this.shadowRoot.querySelector('.backdrop').addEventListener('click', (e) => {
      if (e.target.classList.contains('backdrop')) this.close()
    })
  }

  disconnectedCallback() {
    this.shadowRoot.querySelector('.close').removeEventListener('click', this.close)
  }

  close() {
    this.removeAttribute('open')
  }
}

customElements.define('modal-dialog', ModalDialog)
<modal-dialog open>
  <h2>Подтверждение</h2>
  <p>Вы уверены?</p>
</modal-dialog>

Web Components и фреймворки

  • Vueapp.config.compilerOptions.isCustomElement = (tag) => tag.includes('-')
  • React — работает, но свойства передаются как атрибуты. Пакет @lit-labs/react для удобной обёртки
  • Nuxt — в nuxt.config.ts: vue: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('my-') } }

Когда использовать

Подходит:

  • Дизайн-системы, используемые в разных проектах и фреймворках
  • Виджеты для встраивания (виджет комментариев, оплаты)
  • Изолированные компоненты (расширения браузера, email)

Не подходит:

  • Как замена фреймворку для целого приложения
  • Нужна реактивность данных (Web Components её не дают — DIY)

Итог

  • customElements.define('my-tag', class) — создание своего HTML-тега
  • Имя тега с дефисом обязательно
  • attachShadow({ mode: 'open' }) — изолированные стили
  • <template> + <slot> — шаблоны с контентом извне
  • CSS-переменные и ::part() — стилизация Shadow DOM снаружи
  • CustomEvent с composed: true — проброс событий