Web Components: свои элементы без фреймворков

Серия дошла до того слоя, который раньше всегда отдавали фреймворку. «Свои теги с собственной логикой и стилями» — то, ради чего шли в React/Vue/Svelte, теперь часть платформы браузера: Web Components — это три независимых стандарта (Custom Elements, Shadow DOM, Templates/Slots), которые вместе дают переиспользуемый инкапсулированный элемент, работающий в любом HTML без сборки, без рантайма-библиотеки и без «угадай, какая у клиента версия фреймворка». К октябрю 2026 всё разобранное ниже есть в актуальных Chromium, Firefox и Safari (точные версии внедрения отдельных API, где это важно, сверяйте на caniuse — там, где я не привожу номера релизов, это сделано сознательно).

Эта статья — про то, как собрать компонент правильно: почему нельзя делать DOM-работу в конструкторе, чем attribute отличается от property, как пробить изоляцию Shadow DOM намеренно (через ::part и кастомные свойства), как заставить свой элемент работать внутри <form> через ElementInternals, и когда Web Components — плохой выбор.


Часть I. Из чего это состоит и как регистрируется

1. Три стандарта, один результат

ТехнологияЧто даётКлючевой API
Custom Elements v1Свои теги <user-card> с логикой и жизненным цикломcustomElements.define, конструктор + callbacks
Shadow DOM v1Изолированное поддерево: свой CSS не течёт наружу и чужой — внутрьattachShadow, :host, ::part, <slot>
Templates & SlotsЗаготовка разметки + «дырки» для содержимого родителя<template>, content, <slot>

Ни один из них не тянет за собой «компонентную модель с virtual DOM». Это низкоуровневые строительные блоки: вы пишете обычный класс и обычный DOM-код, а платформа даёт инкапсуляцию и переиспользование. Компонент, собранный так, работает и в React, и в Vue, и в голыом HTML — фреймворки давно умеют монтировать произвольные кастомные элементы.

2. Регистрация: customElements.define

class UserCard extends HTMLElement {
  connectedCallback() {
    if (this._rendered) return;       // защита от двойного рендера при upgrade + reconnect
    this._rendered = true;
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML = `
      <style>
        :host { display: inline-flex; gap: .5rem; align-items: center;
                font: 14px/1.4 system-ui; }
        img { border-radius: 50%; width: 40px; height: 40px; object-fit: cover; }
        .name { font-weight: 600; }
      </style>
      <img src="${escapeAttr(this.getAttribute('avatar') || '')}" alt="">
      <span class="name">${escapeText(this.getAttribute('name') || '—')}</span>
    `;
  }
}
customElements.define('user-card', UserCard);   // имя обязано содержать дефис

Правило дефиса — не косметика: div, span уже заняты стандартом, а дефис гарантирует, что ваше имя никогда не конфликтует с будущим HTML-тегом и что парсер корректно распознаёт «неизвестный пока» элемент.

3. Анонимный upgrade: «тег раньше класса»

Браузер встречает <user-card> до того, как загрузился скрипт. Он создаёт базовый HTMLElement, а после customElements.define апгрейдит существующие экземпляры: вызывает конструктор, потом connectedCallback. Следствие для конструктора (§4): он обязан работать «в вакууме», потому что в момент upgrade атрибуты могут читаться, но потом — тоже, и DOM-манипуляции в конструкторе строго запрещены спецификацией.


Часть II. Custom Elements: конструктор, цикл жизни, attributes vs properties

4. Конструктор: только «бесплатная» подготовка

Спецификация накладывает на конструктор ограничения — иначе ломается upgrade и парсер:

  • нельзя читать/ставить атрибуты (getAttribute, setAttribute);
  • нельзя добавлять детей или делать querySelector;
  • нельзя полагаться на то, что <slot> уже наполнен;
  • можно: вызвать super(), создать shadow-корень (attachShadow), объявить поля, зарегистрировать listeners на самом себе.
class RatingWidget extends HTMLElement {
  static formAssociated = true;        // объявляем «форменность» статиком (Часть VI)
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });   // разрешено в конструкторе
    this._internals = this.attachInternals(); // тоже в конструкторе
  }
  connectedCallback() {
    this.render();                     // вся DOM-работа — здесь
  }
}

Зачем shadow в конструкторе, а render — в connectedCallback: корень можно создать один раз и безопасно; а наполнять его надо, когда элемент «в документе» (есть доступ к атрибутам, слотам, окружению).

5. Четыре жизненных callback-а

CallbackКогдаТипичное назначение
connectedCallbackэлемент добавлен в DOM (в т.ч. после upgrade и при reconnect)рендер, подписки, старт观察 observers
disconnectedCallbackубран из DOMснять listeners/таймеры/observer-ы, abort() fetch (см. статью про Fetch)
adoptedCallbackперенесён в другой документ (document.adoptNode)перепривязка к новому ownerDocument
attributeChangedCallbackизменился отслеживаемый атрибутреакция на attr="value" из HTML
class ClockEl extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<time part="time"></time>`;
    this._tick = () => {
      this.shadowRoot.querySelector('time').textContent =
        new Date().toLocaleTimeString();
    };
    this._timer = setInterval(this._tick, 1000);
    this._tick();
  }
  disconnectedCallback() {
    clearInterval(this._timer);          // иначе утечка таймеров при удалении
    this._timer = null;
  }
}
customElements.define('clock-el', ClockEl);

Классика бага: компонент вставили-удалили-вставили, а connectedCallback навесил глобальный window.addEventListener повторно. Отписывайтесь в disconnectedCallback ровно там, где подписались в connectedCallback.

6. Атрибуты: observedAttributes + attributeChangedCallback

HTML живёт атрибутами, поэтому свойство, которым управляют из разметки, надо отразить в элементе:

class BadgeEl extends HTMLElement {
  static observedAttributes = ['variant'];    // за каким списком следить

  connectedCallback() {
    if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
    this.render();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'variant') this.render();    // перерисовать на изменение атрибута
  }

  render() {
    const variant = this.getAttribute('variant') || 'default';
    this.shadowRoot.innerHTML = `
      <style>:host { --bg: #eee; }
        .b { background: var(--bg); padding: 2px 8px; border-radius: 999px; }
      </style>
      <span class="b" part="badge">${badgeColor(variant)}</span>`;
  }
}
customElements.define('badge-el', BadgeEl);

Менеджмент атрибутов из JS (setAttribute('variant','ok')) попадёт в attributeChangedCallback; «мгновенный» же сеттер-property — нет, если вы сами не реализуете reflection (§8).

7. Порядок: connectedCallback не гарантирует «всё готово»

Элемент может быть в DOM, но ещё без детей (парсер дошёл до открывающего тега). Если логика зависит от содержимого, используйте MutationObserver на детей или слот-событие slotchange (Часть III/IV) вместо допущения «детей уже видно в connectedCallback». Для «дождись, пока DOM полностью загружен» — html import уже не вариант; нормальный паттерн — вешать регистрацию элемента так, чтобы тег ещё не парсился (module defer по умолчанию).

8. Property ↔ attribute: reflection как API-контракт

Хороший компонент объявляет «пару» для каждого публичного значения:

class ToggleEl extends HTMLElement {
  static observedAttributes = ['on'];

  get on()       { return this.hasAttribute('on'); }
  set on(value)  {
    value ? this.setAttribute('on', '') : this.removeAttribute('on');
  }

  attributeChangedCallback() { this.syncUi(); }
  connectedCallback() { this.syncUi(); }
  syncUi() { /* перекрасить из this.on */ }
}
customElements.define('toggle-el', ToggleEl);
<toggle-el on></toggle-el>          <!-- разметкой -->
<script>document.querySelector('toggle-el').on = true;</script>  <!-- и из кода -->

Правило: атрибут — это то, что можно написать в HTML (строка, «присутствие/отсутствие»); property — то, с чем удобно работать в JS (число, объект, boolean). Reflection держит их в согласии. Атрибут — всегда строка: this.on === false и this.hasAttribute('on') дают «включено» только по факту наличия атрибута, а on="false" в разметке — это «значение-строка ‘false’», что по логике boolean-атрибута всё равно «истина». Не завязывайтесь на строковое значение boolean-атрибута.

9. whenDefined: дождаться регистрации перед использованием

customElements.whenDefined('user-card').then(() => {
  const el = document.querySelector('user-card');
  el.refresh?.();          // теперь гарантированно апгрейднутый экземпляр
});
customElements.get('user-card');  // класс или undefined, синхронная проверка

Часть III. Shadow DOM: изоляция, которую нужно пробивать намеренно

10. Что даёт attachShadow

this.attachShadow({ mode: 'open' }) превращает детей элемента в shadow-поддерево, отделённое от document-дерева границей (shadow boundary). Следствия:

  • стили не протекают внутрь: правила страницы с .name { } не достанут до <span class="name"> внутри shadow (и наоборот — ваш :host не красит чужой контент).
  • структура скрыта: document.querySelector('.name') не найдёт то, что под shadow (если не зайти через el.shadowRoot).
  • innerHTML элемента больше не «его» разметка: дети уходят во fragment.
const el = document.querySelector('user-card');
el.shadowRoot        // DocumentFragment, если mode:'open'
el.shadowRoot?.querySelectorAll?.('*');  // только у open

11. mode: 'open' vs 'closed'

open — el.shadowRoot доступен, удобно отлаживать и тестировать. closed — наружу корень «не виден» (ссылку можно где-то спрятать). Closed — не про безопасность (защиты от XSS не даёт), а про контракт: вы явно не хотите, чтобы потребители лезли внутрь. В 90% случаев берите open: закрытый shadow усложняет отладку и сломает некоторые тест-фреймворки.

12. Стилизация снаружи: :host, :host(), ::part, CSS-переменные

Shadow изолирует, но компонент обязан быть настраиваемым. Есть ровно три легальных канала наружу:

1. :host — стили самого элемента (внутри shadow-стиля):

:host { display: block; }
:host([variant="ok"]) { --bg: green; }   /* условие по атрибуту хоста */
:host(:hover) { --border: var(--accent); }

2. ::part — точечное открытие внутренних элементов. Помечаете внутренний узел part="…" и даёте внешний селектор:

<span part="title">…</span>
/* снаружи на странице: */
user-card::part(title) { color: rebeccapurple; font-weight: 700; }
/* без part="title" внутри — снаружи стилизовать нельзя */

::part не даёт доставать вложенные узлы произвольно — только явно опубликованные. Это «public CSS API» компонента.

3. Наследуемые кастомные свойства — главный путь темизации. CSS-переменные наследуются сквозь границу shadow: объявили на хосте — они дойдут до правил внутри.

/* компонент (внутри shadow): */
:host {
  --card-bg: #fff;            /* дефолт компонента */
  --card-pad: 1rem;
  background: var(--card-bg);
  padding: var(--card-pad);
}
/* снаружи — переопределяем «как props», без ::part: */
user-card { --card-bg: #1f2937; --card-pad: 2rem; }

Правило хорошего тона: кастомные свойства = публичный API темы, ::part = публичный API «покрасить кусок», остальное — закрытая внутренность. Никакого !important, никакого «влезь в shadowRoot и поправь» извне.

13. <slot>: composition вместо пропсов

Shadow DOM не про «данные через атрибуты» — он про разметку, которую родитель кладёт внутрь. <slot> — дырка для children:

<user-card>
  <span slot="meta">онлайн с 2020</span>   <!-- в именованный слот -->
  <div>Свободный текст пойдёт в default-слот</div>
</user-card>
this.shadowRoot.innerHTML = `
  <style>
    ::slotted(span) { color: gray; }       /* базовое оформление назначенного контента */
    .meta { font-size: 12px; }
  </style>
  <div class="head">…</div>
  <slot></slot>                            <!-- default slot -->
  <div class="meta"><slot name="meta"></slot></div>
`;

Механика: элементы не «копируются» в shadow, они назначаются (assigned) слоту. Поэтому стили назначенного узла живут в document (родительский) tree scope — их нужно задавать снаружи, а изнутри слота — только через ::slotted() и только для верхнего уровня назначенного узла (не вглубь). Управление — через slot.assignedNodes({flatten:true}) и slotchange.


Часть IV. Templates: <template> как безопасная заготовка

14. Почему <template>, а не строка в innerHTML

Обычный парситель innerHTML исполняет то, что нельзя: грузит <img>, запускает <script>, кидает события загрузки. Содержимое <template> — инертный DocumentFragment (template.content): картинки не грузятся, скрипты не работают, формы не участвуют, пока вы не склонируете и не вставите.

<template id="row-tpl">
  <tr class="row">
    <td class="row-name"></td>
    <td class="row-age"></td>
  </tr>
</template>
const tpl = document.querySelector('#row-tpl');
function addRow(name, age) {
  const node = tpl.content.cloneNode(true);   // deep clone инертного fragment
  node.querySelector('.row-name').textContent = name;  // XSS-safe (см. статью про Fetch, §XSS)
  node.querySelector('.row-age').textContent = age;
  document.querySelector('tbody').append(node);
}

15. Template как «shadow-разметка» компонента

Зрелый паттерн — держать shadow-разметку в <template> рядом с классом и клонировать её в корень:

<template id="user-card-tpl">
  <style>
    :host { display: inline-flex; gap: .5rem; align-items: center; }
    img { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; }
  </style>
  <img part="avatar">
  <span part="name"><slot></slot></span>
</template>
<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
    if (!root.firstChild) {
      root.append(document.querySelector('#user-card-tpl').content.cloneNode(true));
    }
  }
});
</script>

Сравнение «innerHTML строкой vs template»: template даёт статический разбор один раз, безопасен по умолчанию (не исполняет) и не требует экранирования интерполяции — значения ставят в textContent. Именно из-за «строковый innerHTML + интерполяция данных» компоненты на конкатенации собирают XSS.


Часть V. События сквозь границу shadow

16. Проблема: внутренние события «не выходят»

<button> внутри shadow: слушатель на самом <my-el> не увидит его click по умолчанию, потому что нативное событие не сcomposed через shadow boundary (оно вообще «закрывается» внутри вашего дерева). Поэтому наружу нельзя просто поставить el.addEventListener('click', …) и надеяться, что это сработает для внутреннего клика.

17. composed + bubbles: как выпустить событие наружу

class Stepper extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<button part="inc">+1</button>`;
    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('step', {
        bubbles: true,      // всплывать по DOM-дереву
        composed: true,     // и пересекать границу shadow
        detail: { delta: 1 },
      }));
    });
  }
}
customElements.define('my-stepper', Stepper);

// снаружи:
document.querySelector('my-stepper')
  .addEventListener('step', (e) => console.log(e.detail.delta));

Без composed: true step останется внутри shadow и снаружи не всплывёт. Без bubbles: true он вообще не покинет элемент. Нужны оба флага для «кастомного события, которое слушают снаружи».

18. composedPath(): кто на самом деле был на пути

Снаружи у события e.target ретаргетируется в shadow-хост (чтобы не «сливать» внутреннюю структуру). Если нужно понять, во что реально кликнули внутри:

el.addEventListener('click', (e) => {
  const real = e.composedPath()[0];   // истинная цель с учётом shadow-деревьев
});

Это же используют для «клик вне компонента» (закрыть dropdown): проверяют, что composedPath() не содержит корень/ссылку на закрытый элемент.


Часть VI. Формы: ElementInternals вместо костыля со скрытым <input>

19. Form-associated custom elements (FACE)

«Свой тег внутри <form>», который реально отправляет значение и участвует в валидации, делается через attachInternals():

class QuantityInput extends HTMLElement {
  static formAssociated = true;                 // без этой статики internals в форму не пустит

  constructor() {
    super();
    this._internals = this.attachInternals();   // в конструкторе — разрешено
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <input type="number" part="input" min="${this.min ?? 1}">`;
    this._input = this.shadowRoot.querySelector('input');
    this._input.addEventListener('input', () => this._sync());
    this.value = this.getAttribute('value') ?? this._input.value;
  }

  get form()     { return this._internals.form; }
  get name()     { return this.getAttribute('name'); }
  get type()     { return this.localName; }      // «тип» для FormData

  get value()    { return this._input?.value ?? ''; }
  set value(v)   {
    const s = String(v);
    if (this._input) this._input.value = s;
    this._sync();
  }

  _sync() {
    this._internals.setFormValue(this.value);    // значение попадёт в FormData формы
    // валидация через нативный input внутри:
    const err = this._input?.validationMessage ?? '';
    this._internals.setValidity(
      this._input?.validity ?? { valid: true },
      err,
      this._input,
    );
  }

  // колбэки формы, как у нативных контролов:
  formResetCallback() { this.value = this.getAttribute('value') ?? ''; }
  formDisabledCallback(isDisabled) { this._input && (this._input.disabled = isDisabled); }
}
customElements.define('quantity-input', QuantityInput);

В <form method="get" action="…"> такой элемент участвует «как поле»: попадёт в query-строку под своим name, валидируется под required/min, реагирует на reset и disabled. Раньше это эмулировали вложенным скрытым <input> — сегодня ElementInternals это прямой, стандартный путь.

20. Constraint Validation API: свой компонент «подсветится» как родной

this._internals.setValidity(...) (см. выше) и this._internals.willValidate, this.checkValidity(), this.reportValidity() дают компоненту нативное поведение валидации: красные рамки браузера, сообщение о невалидности, form.requestSubmit() не отправит пока вы невалидны. Не городите свою валидацию-оверлей, если можно прокинуть validity внутреннего нативного контрола через setValidity.


Часть VII. Практика: собираем <search-box> и думаем про SSR

21. Компонент целиком

<search-box>: именованный слот под кнопку, проп endpoint, event=search наружу, таймаут/отмена через AbortController (статья «Fetch API»), safe-render (без innerHTML с данными):

<template id="search-box-tpl">
  <style>
    :host { display: flex; gap: .5rem; }
    .box { flex: 1; }
    input {
      width: 100%;
      font: inherit;
      padding: .5rem .75rem;
      border: 1px solid var(--search-border, #d1d5db);
      border-radius: 8px;
      background: var(--search-bg, #fff);
      color: var(--search-text, #111);
    }
    ::part(button) { padding: .5rem 1rem; }
  </style>
  <form class="box">
    <input part="input" type="search" name="q" placeholder="Поиск…" autocomplete="off">
    <slot name="submit"><button type="submit" part="button">Искать</button></slot>
    <output part="results"></output>
  </form>
</template>

<script type="module">
customElements.define('search-box', class extends HTMLElement {
  static observedAttributes = ['endpoint'];

  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.append(document.querySelector('#search-box-tpl').content.cloneNode(true));
    this._ac = null;   // текущий AbortController
  }

  connectedCallback() {
    this._form   = this.shadowRoot.querySelector('form');
    this._input  = this.shadowRoot.querySelector('input');
    this._out    = this.shadowRoot.querySelector('output');
    this._form.addEventListener('submit', this._onSubmit);
  }

  disconnectedCallback() {
    this._form?.removeEventListener('submit', this._onSubmit);
    this._ac?.abort();           // уходим — отменяем висящий запрос (статья Fetch §15)
  }

  _onSubmit = async (e) => {
    e.preventDefault();
    this._ac?.abort();           // 1: отменить предыдущий поиск
    const ac = new AbortController();
    this._ac = ac;
    const signal = AbortSignal.any([ac.signal, AbortSignal.timeout(8000)]);

    const q = this._input.value.trim();
    if (!q) { this._out.replaceChildren(); return; }

    this._out.textContent = 'Загрузка…';
    try {
      const url = new URL('/search', this.endpoint);
      url.searchParams.set('q', q);
      const res = await fetch(url, { headers: { Accept: 'application/json' }, signal });
      if (!res.ok) throw new Error('HTTP ' + res.status);
      const items = await res.json();

      // XSS-safe рендер: ни одного innerHTML с данными
      this._out.replaceChildren(...items.map((it) => {
        const li = document.createElement('li');
        li.textContent = it.title;        // текстовый узел
        return li;
      }));

      // событие наружу: bubbling + composed, иначе снаружи не дойдёт (Часть V)
      this.dispatchEvent(new CustomEvent('search', {
        bubbles: true, composed: true,
        detail: { q, count: items.length },
      }));
    } catch (err) {
      if (err.name === 'AbortError') return;   // сами отменили — не показываем ошибку
      this._out.textContent = 'Ошибка загрузки';
    }
  };

  get endpoint() { return this.getAttribute('endpoint') || '/'; }
  set endpoint(v) { this.setAttribute('endpoint', v); }
  attributeChangedCallback() { /* endpoint меняется — ничего переделывать не надо, он читается на лету */ }
});
</script>

Что здесь покрывается:

  • attached-логика в connectedCallback + снятие в disconnectedCallback (Part II).
  • <template> + cloneNode как базовый рендер, без innerHTML строкой с данными (Part IV).
  • <slot name="submit"> — родитель может заменить кнопку своей (Part III).
  • ::part(button), --search-bg/-border — публичные API стилизации: «покрасить кусок» и «сменить тему» (Part III §12).
  • search-событие с composed:true, bubbles:true (Part V).
  • AbortController + AbortSignal.any + таймаут + отмена на disconnected (Part VII, из статьи про Fetch).
  • new URL(...).searchParams — query без конкатенации (Fetch §6).
  • textContent-рендер вместо innerHTML — нет XSS из API (Fetch §XSS).

22. Server-side rendering и Declarative Shadow DOM

Кастомные элементы на CSR дают «мигающий» контент: сервер отдаёт пустые <search-box></search-box>, JS достраивает после загрузки. Стандартный ответ — Declarative Shadow DOM (DSD): сервер сразу отдаёт shadow-разметку как <template shadowrootmode="open"> внутри элемента, браузер «поднимает» её в shadow-корень до загрузки скрипта, а кастомный элемент просто находит уже собранный shadowRoot:

<user-card name="Ada">
  <template shadowrootmode="open">
    <style>:host { display: inline-flex; }</style>
    <img part="avatar" src="/ada.jpg" alt="">
    <span part="name"><slot></slot></span>
  </template>
</user-card>
connectedCallback() {
  const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
  // при DSD this.shadowRoot уже есть и наполнен — не пересоздавать, не клонировать повторно
  if (!this.shadowRoot) this._hydrateTemplate(root);
}

DSD даёт мгновенный First Contentful Paint и совместим с гидрацией: сервер рендерит shadow, клиент вешает слушатели. (Поддержка и тонкости SSR-фреймворков — сверяйте по caniuse; это сравнительно свежий для некоторых движков API.)

23. Когда Web Components — плохой выбор

  • Глубокий связный UI с общей глобальной стейт-машиной (большой SPA-дашборд): изоляция shadow усложняет общий стейт/стилизацию, а отсутствие «реактивности из коробки» означает, что вы пишете её руками. Фреймворк тут быстрее.
  • Где уже есть дизайн-система на фреймворке — не надо оборачивать её в WC ради «модности»: два слоя абстракций вместо одного.
  • Нужен «тот же компонент» и в React-пропсах, и в JSX-атрибутах с объектами: WC удобно берёт строки (атрибуты), но пропсы-объекты/функции-колбэки через атрибуты не передаются (только через properties/events). Это решаемо, но добавляет слой.
  • Нет SSR-контроля в стеке (например, чистый WordPress-шаблон без билда) — DSD требует, чтобы сервер умел генерить shadow-разметку.

Хорошая ниша WC — встраиваемые «виджеты» в чужую страницу (админ-панели, CMS-блоки, сторонние вставки) и дизайн-система, не привязанная к фреймворку: один <my-button> работает и в React, и в Vue, и в голимом HTML, без дублирования под каждую экосистему.


Чек-лист: готовность и грабли

  • Имя элемента содержит дефис — без него customElements.define бросит SyntaxError.
  • Конструктор «бесплатный»: нет getAttribute/детей/querySelector/чтения слотов. Вся DOM-работа — в connectedCallback.
  • attachShadow один раз на инстанс: защищайтесь от «reconnect после upgrade» (if (this.shadowRoot) return). Иначе attachShadow второй раз бросит InvalidStateError.
  • static observedAttributes — без него attributeChangedCallback не сработает, даже если атрибут реально меняется.
  • boolean-атрибут: hasAttribute('on') — истина по факту наличия; on="false" — всё равно «истина». Не парсите строку, используйте hasAttribute.
  • Слушаете внутренний клик снаружи? Нативные события не пересекают shadow — для своих нужен composed:true (+bubbles:true для всплытия).
  • e.target снаружи = хост. Настоящая цель внутри — e.composedPath()[0].
  • Темизация через CSS-переменные (наследуются сквозь shadow) и ::part, а не через лезть в shadowRoot извне.
  • mode:'open' — closed не защищает от XSS и усложняет отладку; closed это про API-контракт, редко он реально нужен.
  • <template> вместо строкового innerHTML: инертность (нет предзагрузки/исполнения), безопасные textContent-вставки.
  • ::slotted() — стилизуете контент из родителя, а не «глубину» чужого узла; и стили назначенного контента живут в document tree scope.
  • Форма: нужен static formAssociated = true + attachInternals() в конструкторе + setFormValue, иначе ваш тег не попадёт в FormData.
  • disconnectedCallback обязан снять подписки на внешние цели (window/document), таймеры, observers, abort() fetch — иначе утечки и «двойной рендер» при reconnect.
  • SSR/CLS: для мгновенного контента — Declarative Shadow DOM (<template shadowrootmode>), и в connectedCallback проверяйте this.shadowRoot, прежде чем клонировать заготовку.
  • Не оборачивайте WC вокруг уже существующей фреймворковой дизайн-системы «для галочки» — два слоя абстракции дороже одного.

Материал носит образовательно-практический характер. Синтаксис и семантика соответствуют актуальным стандартам Web Components v1 (Custom Elements, Shadow DOM v1, HTML Templates/Slots), ElementInternals/Form-associated Custom Elements и Declarative Shadow DOM. Поведение отдельных API (в частности DSD и ElementInternals) в старых/мобильных браузерах сверяйте по MDN и caniuse для вашей аудитории.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.