Web Components
Web Components
Основы
★ Web Components (веб-компоненты) — набор стандартов браузера (W3C), с помощью которых можно объявить свой HTML-тег с собственной разметкой, стилями и поведением. Такой тег работает в React, Vue, Angular и в странице без фреймворка.
DOM (Document Object Model) — дерево узлов, из которого браузер строит страницу. Web Components добавляют к обычному DOM изолированное поддерево и регистрацию новых имён тегов.
Четыре части стандарта:
- Custom Elements — свои теги вроде
<my-button> - Shadow DOM — изолированная разметка и стили внутри элемента
<template>— заготовка HTML, которая не показывается сразу- Slots — отверстия, куда страница вставляет свой текст
Руководство — MDN: Web Components. Базовая разметка — HTML — основы, события — JavaScript — события. Стили снаружи — Tailwind CSS.
Custom Elements (пользовательские элементы)
Браузер знает встроенные теги (<button>, <div>). Custom Element — тег, который регистрируете вы через customElements.define.
Имя обязательно с дефисом (my-button, app-header), чтобы не пересечься со стандартными тегами HTML.
class MyButton extends HTMLElement {
connectedCallback() {
this.textContent = this.textContent || "Нажми меня";
this.addEventListener("click", () => {
this.dispatchEvent(new CustomEvent("pressed", { bubbles: true }));
});
}
}
customElements.define("my-button", MyButton);
<my-button>Сохранить</my-button>
Жизненный цикл элемента — подробнее в отдельном разделе ниже.
Атрибуты и свойства
В HTML атрибут — строка в разметке. В JavaScript у DOM-узла свойство (property) может быть числом, boolean или объектом.
<input type="checkbox" checked>
- атрибут
checkedв HTML - свойство
element.checked === true(boolean)
У custom elements та же пара:
<user-badge score="42" active></user-badge>
class UserBadge extends HTMLElement {
static get observedAttributes() { return ["score", "active"]; }
get score() {
return Number(this.getAttribute("score") || 0);
}
set score(v) {
this.setAttribute("score", String(v));
}
get active() {
return this.hasAttribute("active");
}
set active(v) {
if (v) this.setAttribute("active", "");
else this.removeAttribute("active");
}
}
| Атрибут | Свойство | |
|---|---|---|
| Тип | всегда строка (или отсутствует) | любой JS-тип |
| Отражение в HTML | виден в outerHTML | не всегда сериализуется |
| Изменение из JS | setAttribute | element.prop = value |
| Boolean | disabled / отсутствие | true / false |
Reflecting attributes — когда свойство синхронизируют с атрибутом для SSR и DevTools:
set value(v) {
this._value = v;
this.setAttribute("value", v);
}
Не отражайте тяжёлые объекты в атрибутах — только JSON-строки или id ссылок.
React передаёт props; для custom elements часть props мапится на attributes, часть — только на properties (см. React 19 ниже).
observedAttributes и attributeChangedCallback
observedAttributes — статический список имён атрибутов, за которыми браузер следит. При setAttribute, removeAttribute или изменении в HTML вызывается attributeChangedCallback.
class PriceTag extends HTMLElement {
static get observedAttributes() {
return ["amount", "currency"];
}
constructor() {
super();
this._shadow = this.attachShadow({ mode: "open" });
this._shadow.innerHTML = `
<style>
:host { font-variant-numeric: tabular-nums; }
.price { font-weight: 700; }
</style>
<span class="price"></span>
`;
}
connectedCallback() {
this._render();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
this._render();
}
_render() {
const amount = this.getAttribute("amount") ?? "0";
const currency = this.getAttribute("currency") ?? "₽";
this._shadow.querySelector(".price").textContent = `${amount} ${currency}`;
}
}
customElements.define("price-tag", PriceTag);
<price-tag amount="1990" currency="₽"></price-tag>
Правила:
- без
observedAttributescallback не вызовется - при первом подключении к DOM порядок:
constructor→attributeChangedCallback(если атрибуты уже есть) →connectedCallback - не делайте тяжёлую работу в callback на каждый символ — debounce или сравнивайте
oldValue === newValue
getAttribute vs dataset
<my-widget data-user-id="7"></my-widget>
this.dataset.userId === "7" // camelCase в JS
data-* удобны для конфигурации из HTML; typed API лучше через observed attributes.
Расширенный жизненный цикл
Полная цепочка для класса, наследующего HTMLElement:
| Метод | Когда | Типичное использование |
|---|---|---|
constructor | new или document.createElement | attachShadow один раз, bind, без детей/light DOM |
static observedAttributes | определение класса | список watched attrs |
attributeChangedCallback | изменился observed атрибут | обновить UI |
connectedCallback | узел вставлен в document | listeners, fetch, render |
disconnectedCallback | узел удалён | cleanup timers, observers |
adoptedCallback | узел перенесён в другой document | редко |
class TimerWidget extends HTMLElement {
constructor() {
super();
this._interval = null;
}
connectedCallback() {
if (this._interval) return;
this._interval = setInterval(() => this._tick(), 1000);
}
disconnectedCallback() {
clearInterval(this._interval);
this._interval = null;
}
_tick() {
const n = Number(this.textContent || 0) + 1;
this.textContent = String(n);
}
}
customElements.define("timer-widget", TimerWidget);
Customized built-in elements — расширяют встроенный тег через extends:
class PrimaryButton extends HTMLButtonElement {
connectedCallback() {
this.classList.add("primary");
}
}
customElements.define("primary-button", PrimaryButton, { extends: "button" });
<button is="primary-button">OK</button>
Safari долго не поддерживал; для кросс-браузерности чаще autonomous custom elements (<primary-button>).
Upgrade — когда класс регистрируют после того, как HTML уже в DOM:
<my-alert>Текст</my-alert>
<script>
class MyAlert extends HTMLElement { connectedCallback() { ... } }
customElements.define("my-alert", MyAlert);
// браузер "upgrade" существующий тег
</script>
customElements.whenDefined("my-alert").then(...).
Shadow DOM (теневой DOM)
Shadow DOM — отдельное дерево DOM, прикреплённое к элементу. Стили из основной страницы не перекрашивают кнопку внутри компонента. Стили компонента не ломают заголовки на странице.
class UserCard extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
.card { padding: 1rem; border: 1px solid #ccc; border-radius: 8px; }
h2 { margin: 0; font-size: 1.1rem; }
</style>
<div class="card">
<h2><slot name="title">Без имени</slot></h2>
<slot></slot>
</div>
`;
}
}
customElements.define("user-card", UserCard);
mode: "open" — к shadow root можно обратиться через element.shadowRoot (удобно в отладке). closed — скрыт от внешнего кода.
Селекторы CSS со страницы не проникают в shadow. Наследуются только свойства вроде color и font-family. Тему передают через CSS-переменные (:host, var(--accent)) — см. функции и переменные в CSS, каскад.
/* внутри shadow */
:host {
display: block;
--accent: var(--brand-color, #4f46e5);
}
:host([disabled]) { opacity: 0.5; pointer-events: none; }
Declarative Shadow DOM
Declarative Shadow DOM (DSD) — shadow tree прямо в HTML, без JavaScript attachShadow:
<user-card>
<template shadowrootmode="open">
<style>
.card { padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; }
</style>
<div class="card">
<h2><slot name="title">Без имени</slot></h2>
<slot></slot>
</div>
</template>
<span slot="title">Мария</span>
<p>Аналитик</p>
</user-card>
Плюсы:
- SSR и статический HTML без flash of unstyled content
- компонент виден до загрузки JS
Ограничения:
- нужна регистрация custom element для поведения, DSD даёт только разметку+стили
- старые браузеры — fallback script
// polyfill pattern
if (!HTMLTemplateElement.prototype.hasOwnProperty("shadowRoot")) {
document.querySelectorAll("template[shadowrootmode]").forEach(t => {
const host = t.parentNode;
const mode = t.getAttribute("shadowrootmode");
host.attachShadow({ mode }).append(t.content.cloneNode(true));
t.remove();
});
}
Стили part и slotted в CSS
::part — стилизация элементов внутри shadow, которые автор компонента явно экспортировал:
shadow.innerHTML = `
<style>
button { padding: 0.5rem 1rem; }
</style>
<button part="base icon">OK</button>
`;
/* глобальный CSS страницы */
my-button::part(base) {
border-radius: 9999px;
background: #4f46e5;
color: white;
}
Несколько part через пробел: part="tab tab-active".
exportparts — проброс part из вложенного shadow:
<div part="container" exportparts="inner: inner-part">
<span part="inner">...</span>
</div>
::slotted — стили контента из light DOM, попавшего в slot:
/* внутри shadow */
::slotted(h2) {
margin: 0;
color: var(--accent);
}
::slotted(.highlight) {
background: yellow;
}
::slotted не меняет layout light DOM — только визуальные inherited/limited правила. Для полного контроля используйте обёртку в shadow.
С Tailwind на host + CSS variables + ::part получают themeable design system без пробива shadow.
HTML-шаблон <template>
Тег <template> хранит кусок разметки вне видимой страницы. Браузер не рисует его, пока вы не склонируете в DOM через JavaScript.
<template id="row-tpl">
<tr>
<td class="name"></td>
<td class="score"></td>
</tr>
</template>
const tpl = document.getElementById("row-tpl");
const row = tpl.content.cloneNode(true);
row.querySelector(".name").textContent = "Иван";
document.querySelector("tbody").appendChild(row);
В Web Components шаблон часто лежит в <template> на странице или в строке внутри класса элемента.
const template = document.createElement("template");
template.innerHTML = `<style>...</style><div>...</div>`;
class MyEl extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" }).append(template.content.cloneNode(true));
}
}
Клонирование template.content эффективнее, чем innerHTML на каждый instance.
Slots (слоты) — базовый и продвинутый
Слот — место внутри shadow DOM, куда страница подставляет свой контент.
<user-card>
<span slot="title">Мария Петрова</span>
<p>Аналитик данных</p>
</user-card>
<slot name="title">— именованный слот<slot></slot>без имени — слот по умолчанию для остального содержимого
Fallback content — текст внутри <slot>, если снаружи ничего не передали.
Несколько default slots — нельзя; один unnamed slot на компонент.
slotchange event
const slot = shadow.querySelector("slot[name=title]");
slot.addEventListener("slotchange", () => {
const nodes = slot.assignedNodes({ flatten: true });
console.log("assigned", nodes);
});
assignedNodes vs children — light DOM дети <user-card> не равны визуальному месту; slot переназначает их в shadow.
Named slot + CSS
<slot name="actions" class="actions"></slot>
Conditional slots pattern
<!-- если нужен optional header -->
<slot name="header"></slot>
<div class="body"><slot></slot></div>
get hasHeader() {
return [...this.children].some(el => el.getAttribute("slot") === "header");
}
Menu / list composition
<tab-bar>
<tab-item slot="tab" id="one">Обзор</tab-item>
<tab-item slot="tab" id="two">Детали</tab-item>
<panel-item slot="panel" for="one">...</panel-item>
<panel-item slot="panel" for="two">...</panel-item>
</tab-bar>
Компонент в connectedCallback связывает tab и panel по id — паттерн без framework.
Form-associated custom elements
Form-associated custom elements (FACE) — custom element участвует в <form> как native control: name, value, validation, submit.
class MyInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this._internals = this.attachInternals();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<input part="field" type="text">
`;
this.shadowRoot.querySelector("input").addEventListener("input", (e) => {
this._internals.setFormValue(e.target.value);
});
}
get value() {
return this._internals.formValue ?? "";
}
}
customElements.define("my-input", MyInput);
<form>
<label>Email <my-input name="email" required></my-input></label>
<button type="submit">Отправить</button>
</form>
ElementInternals API:
setFormValue(value, state?)setValidity(flags, message, anchor?)checkValidity(),reportValidity()form,labels
Поддержка — современные Chromium и Firefox; проверяйте caniuse.
Связь с HTML-формами и доступностью.
События из shadow и composed
События в DOM всплывают (bubble), но shadow boundary по умолчанию обрезает всплытие наружу для некоторых событий.
composed: true — событие пересекает shadow границу:
this.dispatchEvent(new CustomEvent("item-select", {
bubbles: true,
composed: true,
detail: { id: this.itemId },
}));
document.querySelector("my-list").addEventListener("item-select", (e) => {
console.log(e.detail.id);
});
| bubbles | composed | выходит на document | |
|---|---|---|---|
| click (native) | true | true | да |
| CustomEvent default | false | false | нет |
| CustomEvent для host | true | true | да |
Не полагайтесь на event.target после shadow — используйте event.composedPath()[0] или detail.
Подробнее о модели событий — JavaScript — события.
host.addEventListener("click", (e) => {
const path = e.composedPath();
console.log("path", path);
});
Библиотека Lit — вводный пример
Lit — тонкая обёртка над Web Components: декларативные шаблоны, реактивные properties, lit-html.
npm install lit
import { LitElement, html, css } from "lit";
class HelloBox extends LitElement {
static properties = {
name: { type: String },
count: { type: Number },
};
static styles = css`
:host { display: block; padding: 1rem; border: 1px solid #ccc; }
button { margin-top: 0.5rem; }
`;
constructor() {
super();
this.name = "мир";
this.count = 0;
}
render() {
return html`
<p>Привет, ${this.name}! Счётчик: ${this.count}</p>
<button @click=${this._inc}>+1</button>
`;
}
_inc() {
this.count++;
}
}
customElements.define("hello-box", HelloBox);
<hello-box name="IT Universe"></hello-box>
Lit сам:
- создаёт shadow root
- обновляет только изменившиеся части DOM
- синхронизирует properties и attributes
Другие библиотеки — Shoelace, FAST, Stencil (компилятор).
React 19 и custom elements
React 19 улучшил поддержку Web Components:
- custom tags в JSX без
classNamewarning - client-side property assignment для props, не только attributes
- события on
onprefix vs addEventListener — см. документацию React
function App() {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
el.addEventListener("item-select", (e) => console.log(e.detail));
return () => el.removeEventListener("item-select", (e) => console.log(e.detail));
}, []);
return (
<my-list ref={ref} items={JSON.stringify([1, 2, 3])}>
<span slot="title">Список</span>
</my-list>
);
}
Ограничения:
- boolean и object props — через ref и property, не attribute
- children в slot — React 19 передаёт; проверяйте на конкретном CE
- SSR — нужен declarative shadow DOM или гидрация
Обёртка-паттерн:
function MyListWrapper(props) {
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
ref.current.items = props.items;
}
}, [props.items]);
return <my-list ref={ref} />;
}
Компоненты-рецепты React — для сравнения подхода JSX.
Vue 3 и custom elements
Vue 3 умеет defineCustomElement:
import { defineCustomElement } from "vue";
const MyWidget = defineCustomElement({
props: { msg: String },
template: `<p>{{ msg }}</p>`,
});
customElements.define("my-widget", MyWidget);
Или использовать чужие CE в SFC:
<template>
<user-card>
<span slot="title">{{ user.name }}</span>
<p>{{ user.role }}</p>
</user-card>
</template>
compilerOptions.isCustomElement — чтобы Vue не ругался на неизвестные теги:
// vite.config.js
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith("app-"),
},
},
});
События — @item-select на host, если emits настроен в defineCustomElement.
Доступность (ARIA) в custom elements
Custom elements не accessible автоматически.
Role и aria- на host*
<expand-panel expanded aria-label="Детали заказа">
...
</expand-panel>
connectedCallback() {
this.setAttribute("role", "button");
if (!this.hasAttribute("tabindex")) this.tabIndex = 0;
this.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this.toggle();
}
});
}
Shadow и aria — idref (aria-labelledby) не пересекают shadow в старых браузерах; ставьте aria на light DOM host или используйте ElementInternals role.
Focus management — modal в shadow должен ловить Tab (focus trap).
Labels для form-associated — attachInternals().labels.
Скрытый decorative — aria-hidden="true" на слотах с иконками.
Чеклист:
- keyboard access (Tab, Enter, Escape)
- focus-visible стили (Tailwind focus-visible)
- contrast в shadow
- live regions (
aria-live) для динамических alert
Тестирование Web Components
@open-wc/testing + Web Test Runner:
import { expect, fixture, html } from "@open-wc/testing";
import "./alert-box.js";
describe("alert-box", () => {
it("renders slot content", async () => {
const el = await fixture(html`
<alert-box type="warn">Внимание</alert-box>
`);
expect(el.shadowRoot.querySelector(".box.warn")).to.exist;
expect(el.textContent).to.include("Внимание");
});
it("updates on attribute change", async () => {
const el = await fixture(html`<alert-box type="info"></alert-box>`);
el.setAttribute("type", "warn");
await el.updateComplete; // если Lit
expect(el.getAttribute("type")).to.equal("warn");
});
});
Playwright — e2e:
await page.goto("/demo.html");
const box = page.locator("alert-box");
await expect(box).toContainText("Сохранено");
await box.evaluate((el) => el.setAttribute("type", "warn"));
Testing Library — screen.getByRole("alert") если role выставлен.
Тестируйте:
- attributes → UI
- events
composed: true - form submit с FACE
- a11y axe-core
Сборка с Vite
Структура пакета компонентов:
my-ui/
src/
alert-box.js
index.js // re-export all
vite.config.js
package.json
// vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
build: {
lib: {
entry: "src/index.js",
formats: ["es"],
fileName: "my-ui",
},
rollupOptions: {
external: /^lit/,
},
},
});
{
"name": "@company/my-ui",
"type": "module",
"exports": "./dist/my-ui.js",
"customElements": "custom-elements.json"
}
custom-elements.json — manifest для Storybook и IDE (CEM analyzer).
Потребление:
<script type="module">
import "@company/my-ui";
</script>
<alert-box>Готово</alert-box>
С Tailwind в компонентах — @tailwind в shadow через adoptedStyleSheets или CSS-in-JS Lit; Play CDN только для demo.
Практический смысл
- один тег можно вставить на любую страницу и в любой фреймворк
- стили и логика спрятаны внутри — меньше конфликтов имён классов
- дизайн-системы поставляют кнопки и поля как npm-пакет с custom elements
Ограничения:
- нет встроенного роутинга и глобального состояния как в React — только UI-кирпич
- сложные SPA по-прежнему чаще строят на фреймворке; Web Components — переиспользуемые детали внутри
Пример целиком
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Web Component demo</title>
</head>
<body>
<alert-box type="info">
Данные сохранены в <strong>SQLite</strong>.
</alert-box>
<script>
class AlertBox extends HTMLElement {
static get observedAttributes() { return ["type"]; }
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
`;
}
this._updateType();
}
attributeChangedCallback() { this._updateType(); }
_updateType() {
const type = this.getAttribute("type") || "info";
const box = this.shadowRoot.querySelector(".box");
box.className = "box " + type;
}
}
customElements.define("alert-box", AlertBox);
</script>
</body>
</html>
Практика разметки — HTML-страницы целиком. Диалоги без своего компонента — <dialog> в практике HTML.
Сравнение с React и Vue
| Web Components | React / Vue | |
|---|---|---|
| Основа | стандарт браузера | библиотека |
| Разметка | HTML + класс JS | JSX или SFC |
| Стили | Shadow DOM | CSS Modules, scoped CSS |
| Состояние | вручную или Lit | useState, stores |
| SSR | DSD, сложнее | first-class |
| Размер runtime | нулевой (native) | React ~40+ KB |
React 19+ и Vue 3 умеют вставлять custom elements как обычные теги. Часто добавляют тонкий адаптер для props и событий.
Упражнения
1. Минимальный CE — <greeting-msg name="..."> выводит «Привет, {name}!» через connectedCallback без shadow.
2. Shadow + slot — <profile-card> с slot title и default; стили .card не должны затронуть h1 на странице.
3. observedAttributes — <color-badge color="red"> меняет фон при смене атрибута color без пересоздания shadow.
4. Attributes vs properties — checkbox CE checked property; убедитесь, что el.checked = true обновляет UI и attribute.
5. ::part — экспортируйте part="label" и перекрасьте с глобального CSS страницы.
6. ::slotted — стилизуйте ::slotted(strong) внутри alert-box.
7. Declarative Shadow DOM — статический HTML с <template shadowrootmode="open"> без JS attachShadow.
8. События — кнопка в shadow dispatch CustomEvent("save", { bubbles: true, composed: true }); слушатель на document.
9. Lit — перепишите alert-box на Lit с @property({ type: String }) type.
10. Form-associated — <my-counter name="qty"> с setFormValue; отправка form включает value.
11. React — вставьте CE в React 19, передайте object через ref property.
12. Vue — defineCustomElement с prop label.
13. a11y — expandable <faq-item> с role="button", aria-expanded, клавиши Enter/Space.
14. Тест — один unit-тест @open-wc/testing на slot content.
15. Vite lib — соберите один CE в dist/ и подключите на статической странице.
16. Tailwind + CE — прочитайте Tailwind CSS; объясните, почему class="bg-red-500" на host не красит div внутри closed shadow без variables.
17. События — нарисуйте схему bubble vs composed по JS событиям.
18. Lab HTML — сверстайте страницу в Lab 1153, замените один блок на custom element.
Shadow DOM — retargeting и query
event.target после клика внутри shadow часто указывает на host, а не на кнопку в shadow — retargeting. Поэтому делегирование на document требует composedPath():
document.addEventListener("click", (e) => {
const path = e.composedPath();
const host = path.find((n) => n instanceof HTMLElement && n.localName === "my-menu");
if (host) host.close();
});
querySelector не ищет внутри shadow чужих компонентов:
document.querySelector("my-card .title"); // null
document.querySelector("my-card").shadowRoot.querySelector(".title"); // ok если open
Для тестов и Storybook держите mode: "open".
Adopted stylesheets — один CSSStyleSheet на много shadow roots (Chrome, Firefox):
const sheet = new CSSStyleSheet();
sheet.replaceSync(`:host { display: block; } .btn { padding: 0.5rem; }`);
class SharedButton extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: "open" });
root.adoptedStyleSheets = [sheet];
root.innerHTML = `<button class="btn"><slot></slot></button>`;
}
}
Экономит память в design system с сотнями экземпляров.
Продвинутые слоты — проекция и fallback
Multiple named slots — типичный layout shell:
<app-shell>
<header slot="header">Шапка сайта</header>
<nav slot="sidebar">Меню</nav>
<main>Контент по умолчанию</main>
<footer slot="footer">© 2026</footer>
</app-shell>
shadow.innerHTML = `
<div class="grid">
<header><slot name="header"></slot></header>
<aside><slot name="sidebar"><p>Нет меню</p></slot></aside>
<main><slot></slot></main>
<footer><slot name="footer"></slot></footer>
</div>
`;
Dynamic slot assignment — редко нужно; браузер сам распределяет по атрибуту slot.
Slot fallback + aria
<slot name="label">Подпись по умолчанию</slot>
Если автор страницы не передал label, читатель экрана всё равно видит fallback.
Nested components
<outer-card>
<inner-chip slot="badge">NEW</inner-chip>
<p>Текст карточки</p>
</outer-card>
inner-chip в light DOM outer-card попадает в slot badge только если shadow outer-card содержит <slot name="badge">. Вложенный CE внутри light DOM не автоматически монтируется в inner shadow.
Manual slot API (deprecated in favor of declarative slots) — HTMLSlotElement.assign() для programmatic slotting в advanced scenarios.
Lit — properties, styles и lifecycle
Полный пример с reactive properties и custom event:
import { LitElement, html, css } from "lit";
export class RatingStars extends LitElement {
static properties = {
value: { type: Number, reflect: true },
max: { type: Number },
readonly: { type: Boolean, reflect: true },
};
static styles = css`
:host { display: inline-flex; gap: 2px; }
button {
border: none; background: none; cursor: pointer;
font-size: 1.25rem; color: #cbd5e1;
}
button.active { color: #f59e0b; }
:host([readonly]) button { cursor: default; }
`;
constructor() {
super();
this.value = 0;
this.max = 5;
this.readonly = false;
}
render() {
return html`
${Array.from({ length: this.max }, (_, i) => {
const star = i + 1;
return html`
<button
class=${star <= this.value ? "active" : ""}
?disabled=${this.readonly}
aria-label="Оценка ${star} из ${this.max}"
@click=${() => this._select(star)}
>★</button>
`;
})}
`;
}
_select(star) {
if (this.readonly) return;
this.value = star;
this.dispatchEvent(new CustomEvent("rating-change", {
bubbles: true,
composed: true,
detail: { value: star },
}));
}
}
customElements.define("rating-stars", RatingStars);
<rating-stars value="3" max="5"></rating-stars>
<script type="module">
document.querySelector("rating-stars").addEventListener("rating-change", (e) => {
console.log("новая оценка", e.detail.value);
});
</script>
reflect: true синхронизирует property value с attribute — удобно для CSS :host([value="5"]).
updateComplete — Promise после render (для тестов):
const el = await fixture(html`<rating-stars value="2"></rating-stars>`);
el.value = 4;
await el.updateComplete;
expect(el.getAttribute("value")).to.equal("4");
React 19 — детали интеграции
Custom element manifest в TypeScript (JSX):
declare global {
namespace JSX {
interface IntrinsicElements {
"rating-stars": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & { value?: number; max?: number },
HTMLElement
>;
}
}
}
Controlled vs uncontrolled
function ProductForm() {
const [rating, setRating] = useState(0);
const ref = useRef(null);
useEffect(() => {
const node = ref.current;
if (!node) return;
node.value = rating;
const onChange = (e) => setRating(e.detail.value);
node.addEventListener("rating-change", onChange);
return () => node.removeEventListener("rating-change", onChange);
}, [rating]);
return <rating-stars ref={ref} max={5} />;
}
Server Components — CE рендерятся на клиенте; для SSR используйте DSD или placeholder.
Next.js App Router — 'use client' boundary если CE с state; import side-effect:
'use client';
import '@company/rating-stars';
Vue 3 — defineCustomElement подробнее
import { defineCustomElement } from "vue";
const TodoItem = defineCustomElement({
props: { done: Boolean, text: String },
emits: ["toggle"],
template: `
<label>
<input type="checkbox" :checked="done" @change="$emit('toggle')">
<span :class="{ done }">{{ text }}</span>
</label>
`,
styles: [`
.done { text-decoration: line-through; opacity: 0.6; }
`],
});
customElements.define("todo-item", TodoItem);
Vue автоматически инкапсулирует styles в shadow.
В обычном .vue SFC:
<script setup>
import '@company/todo-item';
</script>
<template>
<todo-item
:done="item.done"
:text="item.text"
@toggle="item.done = !item.done"
/>
</template>
Custom Elements Manifest (CEM)
Для IDE autocomplete и документации генерируют Custom Elements Manifest:
npm install -D @custom-elements-manifest/analyzer
npx cem analyze --litelement --globs "src/**/*.js"
{
"modules": [{
"declarations": [{
"kind": "class",
"name": "AlertBox",
"tagName": "alert-box",
"attributes": [{ "name": "type", "type": "string" }],
"events": [{ "name": "dismiss" }]
}]
}]
}
Storybook 8+ читает manifest для controls panel.
Vite — dev server и HMR
// vite.config.js — dev demo page
import { defineConfig } from "vite";
export default defineConfig({
root: "demo",
build: {
outDir: "../dist-demo",
},
server: {
open: "/index.html",
},
});
<!-- demo/index.html -->
<script type="module" src="/src/main.js"></script>
<alert-box type="info">Hot reload работает</alert-box>
// demo/src/main.js
import "../src/alert-box.js";
При изменении alert-box.js Vite перезагрузит module; CE upgrade существующие узлы через customElements.define один раз.
Import maps на статическом хостинге без bundler:
<script type="importmap">
{
"imports": {
"lit": "https://cdn.jsdelivr.net/npm/lit@3/+esm"
}
}
</script>
<script type="module" src="./app.js"></script>
Сценарии использования в продакшене
| Сценарий | Подход |
|---|---|
| Design system для 5 команд на разных стеках | npm-пакет CE + CEM |
| Виджет на чужой CMS (WordPress) | один <script type="module"> + DSD |
| Микрофронтенд | CE как boundary между React и Vue |
| Поля формы в legacy jQuery | FACE elements |
| Embed analytics dashboard | closed shadow + iframe fallback |
Версионирование — attribute version или npm semver; не меняйте поведение slot без major.
Content Security Policy — inline <script> в demo запрещён; используйте external modules script-src 'self'.
Типичные ошибки
| Симптом | Причина | Решение |
|---|---|---|
attributeChangedCallback молчит | атрибут не в observedAttributes | добавить в static list |
| Стили страницы ломают CE | нет shadow | attachShadow |
| Tailwind не работает внутри | shadow boundary | CSS variables, ::part |
| React не обновляет prop | attribute vs property | ref + property |
| Событие не доходит | composed: false | composed: true |
| Slot пустой | неверное slot="name" | проверить имя |
| FOUC до JS | нет DSD | declarative shadow |
| Form не видит value | не FACE | formAssociated + internals |
| Дубли listeners | connectedCallback без guard | флаг или disconnect cleanup |
| Имя без дефиса | invalid custom element | my-widget, не widget |
Связанные материалы
- HTML — основы · основные теги · формы
- Tailwind CSS
- JavaScript — события
- React — компоненты-рецепты
- JavaScript — о разделе
- HTML-страницы целиком
- Доступность в CSS