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