Почему важна синхронизация атрибутов
Веб‑компоненты часто используются как заменители стандартных HTML‑элементов. Если ваш кастомный элемент заменяет <input>, пользователи ожидают, что он будет реагировать на привычные атрибуты — disabled, required, value и т.п. При отсутствии корректного отражения (reflection) между атрибутами и свойствами элемент может вести себя непредсказуемо: изменение атрибута в разметке не будет влиять на внутреннее состояние, а изменение свойства в скрипте не отразится в DOM‑разметке. Поэтому синхронизация атрибутов и свойств — обязательный шаг при проектировании любого интерактивного веб‑компонента.
Content‑attributes vs IDL‑attributes
HTML‑спецификация различает два типа атрибутов:
- Content‑attributes – строки, записанные в разметке (
<my-input disabled></my-input>). Они являются частью DOM‑tree и доступны черезelement.getAttribute()/setAttribute(). - IDL‑attributes – свойства объекта JavaScript (
element.disabled). Их тип определяется в интерфейсе IDL (Interface Definition Language) и может быть булевым, числовым, строковым и т.д.
Для большинства встроенных элементов браузер автоматически поддерживает отражение: изменение element.disabled меняет атрибут disabled и наоборот. В кастомных элементах такой механизм отсутствует, и его необходимо реализовать вручную.
Ограничения attributeChangedCallback
Метод attributeChangedCallback(name, oldValue, newValue) вызывается после изменения атрибута. Это удобно для реакции на уже совершённые изменения, но не позволяет контролировать процесс изменения. Например, если атрибут disabled установлен в неверном виде (disabled="false"), callback получит уже изменённое значение, а корректировать его до применения к свойству нельзя. Кроме того, простой вызов setAttribute внутри attributeChangedCallback приводит к бесконечному циклу.
Реализация отражения через геттеры/сеттеры
Наиболее надёжный способ синхронизации — использовать пары геттер/сеттер, которые управляют приватным полем и отражают значение в атрибут. Пример базовой структуры:
class MyInput extends HTMLElement {
// Список атрибутов, за которыми хотим наблюдать
static get observedAttributes() {
return ['disabled', 'required', 'value'];
}
constructor() {
super();
// Приватные поля (символы или #private)
this._disabled = false;
this._required = false;
this._value = '';
// Shadow DOM, стили и разметка
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `<input part="control">`;
this._input = shadow.querySelector('input');
}
// ---------- Геттеры / Сеттеры ----------
get disabled() {
return this._disabled;
}
set disabled(val) {
const bool = Boolean(val);
if (bool === this._disabled) return;
this._disabled = bool;
// Отражаем в атрибут
if (bool) this.setAttribute('disabled', '');
else this.removeAttribute('disabled');
// Синхронно обновляем внутренний <input>
this._input.disabled = bool;
}
get required() {
return this._required;
}
set required(val) {
const bool = Boolean(val);
if (bool === this._required) return;
this._required = bool;
if (bool) this.setAttribute('required', '');
else this.removeAttribute('required');
this._input.required = bool;
}
get value() {
return this._value;
}
set value(val) {
const str = String(val);
if (str === this._value) return;
this._value = str;
this.setAttribute('value', str);
this._input.value = str;
}
// ---------- Callback для изменений атрибутов ----------
attributeChangedCallback(name, oldValue, newValue) {
// Защита от двойного обновления: если изменение инициировано сеттером,
// соответствующее свойство уже синхронизировано.
switch (name) {
case 'disabled':
if (this.disabled !== this.hasAttribute('disabled')) {
this.disabled = this.hasAttribute('disabled');
}
break;
case 'required':
if (this.required !== this.hasAttribute('required')) {
this.required = this.hasAttribute('required');
}
break;
case 'value':
if (this.value !== newValue) {
this.value = newValue;
}
break;
}
}
// ---------- Жизненный цикл ----------
connectedCallback() {
// Инициализируем состояние из атрибутов при подключении к DOM
this.disabled = this.hasAttribute('disabled');
this.required = this.hasAttribute('required');
this.value = this.getAttribute('value') ?? '';
// Привязываем события внутреннего input
this._input.addEventListener('input', () => {
this.value = this._input.value;
});
}
}
customElements.define('my-input', MyInput);
Ключевые идеи:
- Приватные поля хранят «истинное» состояние. Геттеры возвращают их, сеттеры обновляют поле, атрибут и внутренний элемент.
- Сеттеры отвечают за нормализацию: любые входные данные приводятся к нужному типу (булево, строка).
attributeChangedCallbackслужит лишь «зеркалом» для изменений, выполненных вне сеттеров (например, черезelement.setAttributeв пользовательском коде). Внутри callback проверяется, действительно ли состояние отличается, чтобы избежать рекурсии.connectedCallbackсинхронизирует начальное состояние с тем, что уже присутствует в разметке.
Обработка булевых атрибутов
Булевые атрибуты (disabled, required, checked) в HTML считаются присутствующими, если они указаны без значения. При чтении через getAttribute они возвращают "" (пустую строку) или null, если отсутствуют. Поэтому в сеттерах обычно используется проверка element.hasAttribute(name). При отражении в атрибут следует применять setAttribute(name, '') или removeAttribute(name) — так браузер будет корректно отображать булевый статус в разметке.
Полный пример кода
Ниже представлена упрощённая версия компонента, готовая к использованию в проектах:
class SimpleToggle extends HTMLElement {
static get observedAttributes() { return ['checked']; }
constructor() {
super();
this._checked = false;
const shadow = this.attachShadow({ mode: 'closed' });
shadow.innerHTML = `
<style>
:host { display: inline-block; cursor: pointer; }
.box { width: 20px; height: 20px; border: 1px solid #777; }
.box[checked] { background: #4caf50; }
</style>
<div class="box"></div>
`;
this._box = shadow.querySelector('.box');
this._box.addEventListener('click', () => this.checked = !this.checked);
}
get checked() { return this._checked; }
set checked(val) {
const bool = Boolean(val);
if (bool === this._checked) return;
this._checked = bool;
if (bool) this.setAttribute('checked', '');
else this.removeAttribute('checked');
this._box.toggleAttribute('checked', bool);
this.dispatchEvent(new Event('change'));
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'checked') {
this.checked = this.hasAttribute('checked');
}
}
connectedCallback() {
this.checked = this.hasAttribute('checked');
}
}
customElements.define('simple-toggle', SimpleToggle);
Компонент simple-toggle демонстрирует минимальную схему отражения булевого атрибута в свойство и обратно, а также синхронное обновление визуального представления. Подобный паттерн можно масштабировать на любые наборы атрибутов, обеспечивая предсказуемое и единообразное поведение кастомных элементов.