Web Components: нативные компоненты браузера
Web Components — стандарт для создания переиспользуемых компонентов с инкапсуляцией. Это набор нативных API браузера, позволяющих создавать компоненты без фреймворков. Компоненты работают в любом современном браузере и совместимы с React, Vue, Angular.
Custom Elements
Custom Elements позволяют создавать собственные HTML-теги с инкапсулированной логикой и стилями.
class MyElement extends HTMLElement
Базовый класс для всех custom elements — HTMLElement. Расширяя его, вы создаёте новый тег.
class MyCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
}
.title {
font-weight: bold;
font-size: 1.2em;
}
</style>
<div class="title"><slot name="title">Default Title</slot></div>
<div class="content"><slot></slot></div>
`;
}
}
customElements.define('my-card', MyCard);
Метод attachShadow создаёт Shadow DOM — изолированное дерево элементов. Стили внутри Shadow DOM не влияют на остальную страницу.
Lifecycle callbacks
Web Components имеют хуки жизненного цикла для управления поведением:
class MyElement extends HTMLElement {
constructor() {
super();
// Создание shadow root
// Вызывается всегда при создании элемента
}
connectedCallback() {
// Добавлен в DOM
// Инициализация, загрузка данных
}
disconnectedCallback() {
// Удалён из DOM
// Очистка, отписка от событий
}
attributeChangedCallback(name, oldVal, newVal) {
// Изменение атрибута
// Только для атрибутов из observedAttributes
}
static get observedAttributes() {
return ['title', 'variant'];
}
}
Эти хуки аналогичны useEffect в React или onMounted в Vue.
Observed attributes
observedAttributes позволяет компоненту реагировать на изменения HTML-атрибутов:
class MyButton extends HTMLElement {
static get observedAttributes() {
return ['disabled', 'variant'];
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'disabled') {
this.render();
}
}
}
Shadow DOM
Mode: open vs closed
// open - можно получить через element.shadowRoot
const shadow = element.attachShadow({ mode: 'open' });
// closed - shadowRoot = null (не рекомендуется)
const shadow = element.attachShadow({ mode: 'closed' });
Styles
class MyElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
:host([hidden]) {
display: none;
}
:host(.primary) {
--btn-color: blue;
}
::slotted(*) {
color: gray;
}
</style>
<button class="btn"><slot></slot></button>
`;
}
}
HTML Templates
<template id="my-template">
<style>
.card {
padding: 16px;
border: 1px solid #ddd;
}
</style>
<div class="card">
<slot name="header"></slot>
<slot></slot>
</div>
</template>
<script>
class MyCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const template = document.getElementById('my-template');
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
customElements.define('my-card', MyCard);
</script>
Slots
<my-card>
<span slot="title">Заголовок</span>
<p>Контент</p>
</my-card>
// Доступ к slot
const slot = this.shadowRoot.querySelector('slot');
const assigned = slot.assignedNodes();
Events
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.querySelector('button')
.addEventListener('click', this._handleClick.bind(this));
}
_handleClick(e) {
this.dispatchEvent(new CustomEvent('my-click', {
bubbles: true,
composed: true,
detail: { message: 'clicked' }
}));
}
}
// Listening
document.querySelector('my-button')
.addEventListener('my-click', (e) => console.log(e.detail));
Properties
class MyElement extends HTMLElement {
static get observedAttributes() {
return ['count'];
}
get count() {
return this.getAttribute('count');
}
set count(value) {
this.setAttribute('count', value);
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'count') {
this.render();
}
}
}
Пример: Modal
class MyModal extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this._setupEvents();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: none;
}
:host([open]) {
display: flex;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
align-items: center;
justify-content: center;
}
.modal {
background: white;
padding: 24px;
border-radius: 8px;
min-width: 300px;
}
.close {
float: right;
cursor: pointer;
}
</style>
<div class="modal">
<span class="close">×</span>
<slot name="title"></slot>
<slot></slot>
</div>
`;
}
_setupEvents() {
this.shadowRoot.querySelector('.close')
.addEventListener('click', () => {
this.removeAttribute('open');
});
}
static get observedAttributes() {
return ['open'];
}
}
customElements.define('my-modal', MyModal);
Использование
<my-modal open>
<span slot="title">Заголовок модального окна</span>
<p>Контент модального окна</p>
</my-modal>
<my-button variant="primary">Нажми меня</my-button>
Заключение
Web Components предоставляют стандартный способ создания переиспользуемых компонентов с полной инкапсуляцией.