Специальный элемент – это класс, зарегистрированный для названия тега с помощью customElements.define(). Регистрация и базовый жизненный цикл хорошо понятны. Правила ниже касаются частей, которые обычно реализуются неправильно.
Жизненный цикл: что реализуется неправильно
Сами обратные вызовы хорошо задокументированы, но есть несколько распространенных заблуждений, которые приводят к ошибкам.
connectedCallbackможет выполняться несколько раз. Он срабатывает при каждой вставке, поэтому при перемещении элемента он запускается повторно. Сделайте настройку идемпотентной – не предполагайте, что первый запуск будет единственным.connectedMoveCallbackпропускает пару "отключить/подключить" при перемещении. Когда элемент перемещается с помощьюNode.moveBefore(), этот обратный вызов срабатывает вместоdisconnectedCallback→connectedCallback, поэтому состояние, фокус и воспроизведение iframe/медиафайлов сохраняются. Определяйте его, когда настройка или демонтаж требуют больших затрат или заметно перезапускаются.- Снос через
disconnectedCallback. Удалите глобальные прослушиватели, таймеры и наблюдатели, чтобы избежать утечки памяти. Однако элемент может быть вставлен повторно, поэтому не считайте удаление окончательным. - Сначала вызовите
super()в конструкторе и не полагайтесь на наличие полной DOM. Вызовsuper()после касанияthisприводит к ошибкеReferenceError. Атрибуты обычно (но не всегда) присутствуют; длинные списки дочерних элементов могут быть не полностью проанализированы. ИспользуйтеconnectedCallbackдля однократной инициализации,slotchangeдля отслеживания прямых дочерних элементов иattributeChangedCallbackдля отслеживания атрибутов.connectedCallbackможет сработать до того, как будут проанализированы все дочерние элементы, поэтому не игнорируйте последующие вызовыslotchange/attributeChangedCallback. attributeChangedCallbackсрабатывает только дляobservedAttributes, и не должен требовать много ресурсов для атрибутов, которые не влияют на выходные данные.- Хуки жизненного цикла и
observedAttributesфиксируются при регистрации.customElements.define()считывает оба значения один раз. Добавление хука или расширениеobservedAttributesв классе после вызова игнорируется без уведомления. В частности,attributeChangedCallbackне срабатывает, еслиobservedAttributesне было вdefine().
Сроки перехода и :defined
Элемент может существовать в DOM до регистрации его класса (в HTML, отображаемом сервером, или когда скрипт загружается поздно). До определения это инертный элемент HTMLElement, не имеющий вашего поведения. Когда выполняется customElements.define(), браузер обновляет каждый соответствующий элемент, уже находящийся в документе, запуская его конструктор, а затем connectedCallback.
Два последствия, которые модели обычно упускают:
- Атрибуты и дочерние элементы могут уже присутствовать, когда конструктор/
connectedCallbackзапускается при обновлении. Прочитайте существующие атрибуты вconnectedCallback, чтобы синхронизировать начальное состояние. Не предполагайте, что элемент изначально пуст. - Дочерние элементы могут отсутствовать даже в
connectedCallback, поскольку синтаксический анализатор HTML может прервать работу в середине длинного списка дочерних элементов. В результате обратный вызов иногда выполняется до того, как будут прикреплены все дочерние элементы. Шаблон (slotchange+attributeChangedCallback) описан в пункте о конструкторе в разделе Жизненный цикл выше.
Скрывайте неопределенные элементы, чтобы избежать появления нестилизованного контента, но используйте отказоустойчивость, чтобы контент оставался доступным, если скрипт не загрузится. Анимируйте opacity с задержкой, а не скрывайте с помощью visibility: hidden, чтобы элемент появлялся после тайм-аута, даже если он не обновлен:
my-element:not(:defined) {
opacity: 0;
/* Reveal anyway after 2s, so a failed or slow script never hides content for good. */
animation: defined-fallback 0.2s 2s forwards;
}
@keyframes defined-fallback { to { opacity: 1; } }
Ограничьте область действия этих защит элементами, которым они нужны, или определенным поддеревом через :has(:not(:defined)), а не всем body. Один медленный, некритичный виджет (например, кнопка "Поделиться") не должен задерживать отрисовку всей страницы.
Отражение атрибута ⇄ свойства
Синхронизируйте HTML-атрибут и свойство JS, чтобы элемент можно было использовать как в разметке, так и в скрипте. Значения атрибутов всегда являются строками.
class MyToggle extends HTMLElement {
static observedAttributes = ['label', 'count', 'disabled'];
// String attribute reflected via getter/setter.
get label() { return this.getAttribute('label') ?? ''; }
set label(v) {
v ? this.setAttribute('label', v) : this.removeAttribute('label');
}
// Numeric attribute: coerce from string on read, serialize on write.
get count() { return Number(this.getAttribute('count')) || 0; }
set count(v) { this.setAttribute('count', String(v)); }
// Boolean attribute: present = true, absent = false. Never set it to "false".
get disabled() { return this.hasAttribute('disabled'); }
set disabled(on) { this.toggleAttribute('disabled', !!on); }
attributeChangedCallback(name, oldVal, newVal) {
if (oldVal !== newVal) this.render();
}
}
Где находится значение
В примере выше DOM используется как источник достоверных данных – каждый метод чтения считывает getAttribute. Это правильное значение по умолчанию для нескольких простых строковых атрибутов: состояние может находиться только в одном месте, поэтому оно не может рассинхронизироваться.
Для более сложных компонентов храните источник истины в поле JS и отражайте наружу в атрибут только тогда, когда его должно видеть что-то ещё – таргетинг CSS или читающий его потребитель. Чтение поля намного дешевле, чем синтаксический анализ и приведение атрибута при каждом доступе, а нестроковые значения никогда не проходят сериализацию. Поэтому такие библиотеки, как Lit, хранят состояние в JS и отражают его выборочно, а не рассматривают атрибуты как хранилище: отражают только то, что должен наблюдать внешний мир, а не каждое свойство.
Возможные проблемы:
observedAttributesdiscipline: list only attributes whose change must re-render or trigger a side effect. Если отслеживать все, то для каждого изменения будет добавляться обратный вызов, что не принесет никакой пользы.- Циклы отражения. Сеттер, который записывает атрибут, повторно введет
attributeChangedCallback. Добавьте проверкуoldVal !== newVal, как описано выше. - Состояние только для стиля. Для внутреннего состояния, которое существует только для управления стилем (загрузка, активно, развернуто), предпочитайте пользовательские состояния, а не отражение синтетического атрибута – см. раздел Пользовательские состояния ниже. Отражайте состояние в атрибуте, только если у пользователей должна быть возможность задавать состояние из разметки.
Только в указанных штатах
Пользовательские состояния – это логические флаги, которые элемент предоставляет о себе для CSS. Они задаются с помощью ElementInternals и сопоставляются с псевдоклассом :state(). Используйте их для внутреннего состояния пользовательского интерфейса во время выполнения (busy, expanded, invalid) вместо того, чтобы отражать синтетический атрибут: они не могут конфликтовать с атрибутами потребителя, никогда не появляются в DOM и доступны только для чтения извне (потребитель может стилизовать :state(), но не может его подделать).
class AsyncButton extends HTMLElement {
#internals;
#action = async () => {};
constructor() {
super();
this.#internals = this.attachInternals();
}
set action(fn) { this.#action = fn; } // host supplies the work to run
async run() {
if (this.#internals.states.has('busy')) return; // guard re-entry
this.#internals.states.add('busy');
this.#internals.states.delete('success');
try {
await this.#action();
this.#internals.states.add('success');
} catch {
this.#internals.states.add('error');
} finally {
this.#internals.states.delete('busy');
}
}
}
CustomStateSet – это набор, поэтому независимые состояния объединяются (busy плюс error) без создания комбинированного перечисления data-status.
Специальное состояние доступно только во время выполнения. Он не сериализуется в DOM, поэтому не сохраняется при перезагрузке, повторном создании элемента или отрисовке на сервере. Если состояние должно быть задано декларативно в разметке или сохраняться между загрузками, это работа для атрибута (см. Где находится значение) – восстановите его в connectedCallback из источника достоверных данных. Общее правило: пользовательское состояние для того, что компонент получает и чем владеет во время выполнения и что нужно только стилизовать; атрибут для того, что должно быть задано из разметки или сериализовано.
О том, как задать стили для этих состояний внутри компонента и в таблице стилей пользователя, читайте в статье Стилизация веб-компонентов.
Правила именования
- True-private state: native
#fields (#count) for hard encapsulation; a_prefix only when subclasses must still reach it. Обратите внимание, что поля#не наследуются – подкласс не может видеть закрытые поля базового класса. - Свойства с символьными ключами – это промежуточный вариант: они скрыты от обычного перечисления, но доступны для любого кода, содержащего символ (экспортируйте его или сделайте его полем
staticв классе). Поскольку каждый символ уникален, два символа с одинаковым описанием никогда не конфликтуют. Это удобно, когда подклассу или родственному классу нужен контролируемый доступ, который не могут обеспечить поля#. - Не переопределяйте глобальные атрибуты (
style,class,id,slot,part,title,lang,dir) собственными свойствами, иначе их встроенные функции перестанут работать. Обратите внимание, чтоdisabledявляется глобальным только для элементов управления формой. Для других элементов вам нужно реализовать его эффект самостоятельно.
При выборе названия для нового API следуйте правилам платформы: используйте формат kebab-case в нижнем регистре по крайней мере с одним дефисом для названий тегов пользовательских элементов (например, my-toggle), lowerCamelCase для свойств и методов, а также названия атрибутов в нижнем регистре, синхронизированные с соответствующими свойствами.