Мы можем создавать пользовательские HTML-элементы, описываемые нашим классом, со своими методами и свойствами, событиями и так далее.
Как только пользовательский элемент определён, мы можем использовать его наравне со встроенными HTML-элементами.
Это замечательно, ведь словарь HTML-тегов богат, но не бесконечен. Не существует <easy-tabs>
, <sliding-carousel>
, <beautiful-upload>
… Просто подумайте о любом другом теге, который мог бы нам понадобиться.
Мы можем определить их с помощью специального класса, а затем использовать, как если бы они всегда были частью HTML.
Существует два вида пользовательских элементов:
- Автономные пользовательские элементы – «полностью новые» элементы, расширяющие абстрактный класс
HTMLElement
. - Пользовательские встроенные элементы – элементы, расширяющие встроенные, например кнопку
HTMLButtonElement
и т.п.
Сначала мы разберёмся с автономными элементами, а затем перейдём к пользовательским встроенным.
Чтобы создать пользовательский элемент, нам нужно сообщить браузеру ряд деталей о нём: как его показать, что делать, когда элемент добавляется или удаляется со страницы и т.д.
Это делается путём создания класса со специальными методами. Это просто, так как существует всего несколько методов, и все они являются необязательными.
Вот набросок с полным списком:
class
MyElement
extends
HTMLElement
{
constructor
(
)
{
super
(
)
;
// элемент создан
}
connectedCallback
(
)
{
// браузер вызывает этот метод при добавлении элемента в документ
// (может вызываться много раз, если элемент многократно добавляется/удаляется)
}
disconnectedCallback
(
)
{
// браузер вызывает этот метод при удалении элемента из документа
// (может вызываться много раз, если элемент многократно добавляется/удаляется)
}
static
get
observedAttributes
(
)
{
return
[
/* массив имён атрибутов для отслеживания их изменений */
]
;
}
attributeChangedCallback
(
name,
oldValue,
newValue
)
{
// вызывается при изменении одного из перечисленных выше атрибутов
}
adoptedCallback
(
)
{
// вызывается, когда элемент перемещается в новый документ
// (происходит в document.adoptNode, используется очень редко)
}
// у элемента могут быть ещё другие методы и свойства
}
После этого нам нужно зарегистрировать элемент:
// сообщим браузеру, что <my-element> обслуживается нашим новым классом
customElements.
define
(
"my-element"
,
MyElement)
;
Теперь для любых HTML-элементов с тегом <my-element>
создаётся экземпляр MyElement
и вызываются вышеупомянутые методы. Также мы можем использовать document.createElement('my-element')
в JavaScript.
-
Имя пользовательского элемента должно содержать дефис -
, например, my-element
и super-button
– валидные имена, а myelement
– нет.
Это чтобы гарантировать отсутствие конфликтов имён между встроенными и пользовательскими элементами HTML.
Пример: «time-formatted»
Например, элемент <time>
уже существует в HTML для даты/времени. Но сам по себе он не выполняет никакого форматирования.
Давайте создадим элемент <time-formatted>
, который отображает время в удобном формате с учётом языка:
<
script
>
class
TimeFormatted
extends
HTMLElement
{
// (1)
connectedCallback
(
)
{
let
date =
new
Date
(
this
.
getAttribute
(
'datetime'
)
||
Date.
now
(
)
)
;
this
.
innerHTML =
new
Intl.
DateTimeFormat
(
"default"
,
{
year
:
this
.
getAttribute
(
'year'
)
||
undefined
,
month
:
this
.
getAttribute
(
'month'
)
||
undefined
,
day
:
this
.
getAttribute
(
'day'
)
||
undefined
,
hour
:
this
.
getAttribute
(
'hour'
)
||
undefined
,
minute
:
this
.
getAttribute
(
'minute'
)
||
undefined
,
second
:
this
.
getAttribute
(
'second'
)
||
undefined
,
timeZoneName
:
this
.
getAttribute
(
'time-zone-name'
)
||
undefined
,
}
)
.
format
(
date)
;
}
}
customElements.
define
(
"time-formatted"
,
TimeFormatted)
;
// (2)
</
script
>
<!-- (3) -->
<
time-formatted
datetime
=
"
2019-12-01"
year
=
"
numeric"
month
=
"
long"
day
=
"
numeric"
hour
=
"
numeric"
minute
=
"
numeric"
second
=
"
numeric"
time-zone-name
=
"
short"
>
</
time-formatted
>
- Класс имеет только один метод
connectedCallback()
– браузер вызывает его, когда элемент<time-formatted>
добавляется на страницу (или когда HTML-парсер обнаруживает его), и он использует встроенный форматировщик данных Intl.DateTimeFormat, хорошо поддерживаемый в браузерах, чтобы показать красиво отформатированное время. - Нам нужно зарегистрировать наш новый элемент, используя
customElements.define(tag, class)
. - И тогда мы сможем использовать его везде.
Если браузер сталкивается с элементами <time-formatted>
до customElements.define
, то это не ошибка. Но элемент пока неизвестен, как и любой нестандартный тег.
Такие «неопределённые» элементы могут быть стилизованы с помощью CSS селектора :not(:defined)
.
Когда вызывается customElements.define
, они «обновляются»: для каждого создаётся новый экземпляр TimeFormatted
и вызывается connectedCallback
. Они становятся :defined
.
Чтобы получить информацию о пользовательских элементах, есть следующие методы:
customElements.get(name)
– возвращает класс пользовательского элемента с указанным именемname
,customElements.whenDefined(name)
– возвращает промис, который переходит в состояние «успешно выполнен» со значением конструктора пользовательского элемента, когда определён пользовательский элемент с указанным именемname
.
connectedCallback
, не в constructor
В приведённом выше примере содержимое элемента рендерится (создаётся) в connectedCallback
.
Почему не в constructor
?
Причина проста: когда вызывается constructor
, делать это слишком рано. Экземпляр элемента создан, но на этом этапе браузер ещё не обработал/назначил атрибуты: вызовы getAttribute
вернули бы null
. Так что мы не можем рендерить здесь.
Кроме того, если подумать, это лучше с точки зрения производительности – отложить работу до тех пор, пока она действительно не понадобится.
connectedCallback
срабатывает, когда элемент добавляется в документ. Не просто добавляется к другому элементу как дочерний, но фактически становится частью страницы. Таким образом, мы можем построить отдельный DOM, создать элементы и подготовить их для последующего использования. Они будут рендериться только тогда, когда попадут на страницу.
Наблюдение за атрибутами
В текущей реализации <time-formatted>
после того, как элемент отрендерился, дальнейшие изменения атрибутов не дают никакого эффекта. Это странно для HTML-элемента. Обычно, когда мы изменяем атрибут, например a.href
, мы ожидаем, что изменение будет видно сразу. Так что давайте исправим это.
Мы можем наблюдать за атрибутами, поместив их список в статический геттер observedAttributes()
. При изменении таких атрибутов вызывается attributeChangedCallback
. Он срабатывает не для любого атрибута по соображениям производительности.
Вот новый <time-formatted>
, который автоматически обновляется при изменении атрибутов:
<
script
>
class
TimeFormatted
extends
HTMLElement
{
render
(
)
{
// (1)
let
date =
new
Date
(
this
.
getAttribute
(
'datetime'
)
||
Date.
now
(
)
)
;
this
.
innerHTML =
new
Intl.
DateTimeFormat
(
"default"
,
{
year
:
this
.
getAttribute
(
'year'
)
||
undefined
,
month
:
this
.
getAttribute
(
'month'
)
||
undefined
,
day
:
this
.
getAttribute
(
'day'
)
||
undefined
,
hour
:
this
.
getAttribute
(
'hour'
)
||
undefined
,
minute
:
this
.
getAttribute
(
'minute'
)
||
undefined
,
second
:
this
.
getAttribute
(
'second'
)
||
undefined
,
timeZoneName
:
this
.
getAttribute
(
'time-zone-name'
)
||
undefined
,
}
)
.
format
(
date)
;
}
connectedCallback
(
)
{
// (2)
if
(
!
this
.
rendered)
{
this
.
render
(
)
;
this
.
rendered =
true
;
}
}
static
get
observedAttributes
(
)
{
// (3)
return
[
'datetime'
,
'year'
,
'month'
,
'day'
,
'hour'
,
'minute'
,
'second'
,
'time-zone-name'
]
;
}
attributeChangedCallback
(
name,
oldValue,
newValue
)
{
// (4)
this
.
render
(
)
;
}
}
customElements.
define
(
"time-formatted"
,
TimeFormatted)
;
</
script
>
<
time-formatted
id
=
"
elem"
hour
=
"
numeric"
minute
=
"
numeric"
second
=
"
numeric"
>
</
time-formatted
>
<
script
>
setInterval
(
(
)
=>
elem.
setAttribute
(
'datetime'
,
new
Date
(
)
)
,
1000
)
;
// (5)
</
script
>
- Логика рендеринга перенесена во вспомогательный метод
render()
. - Мы вызываем его один раз, когда элемент вставляется на страницу.
- При изменении атрибута, указанного в
observedAttributes()
, вызываетсяattributeChangedCallback
. - …и происходит ререндеринг элемента.
- В конце мы легко создаём живой таймер.
Порядок рендеринга
Когда HTML-парсер строит DOM, элементы обрабатываются друг за другом, родители до детей. Например, если у нас есть <outer><inner></inner></outer>
, то элемент <outer>
создаётся и включается в DOM первым, а затем <inner>
.
Это приводит к важным последствиям для пользовательских элементов.
Например, если пользовательский элемент пытается получить доступ к innerHTML
в connectedCallback
, он ничего не получает:
<
script
>
customElements.
define
(
'user-info'
,
class
extends
HTMLElement {
connectedCallback
(
)
{
alert
(
this
.
innerHTML)
;
// пусто (*)
}
}
)
;
</
script
>
<
user-info
>
Джон</
user-info
>
Если вы запустите это, alert
будет пуст.
Это происходит именно потому, что на этой стадии ещё не существуют дочерние элементы, DOM не завершён. HTML-парсер подключил пользовательский элемент <user-info>
и теперь собирается перейти к его дочерним элементам, но пока не сделал этого.
Если мы хотим передать информацию в пользовательский элемент, мы можем использовать атрибуты. Они доступны сразу.
Или, если нам действительно нужны дочерние элементы, мы можем отложить доступ к ним, используя setTimeout
с нулевой задержкой.
Это работает:
<
script
>
customElements.
define
(
'user-info'
,
class
extends
HTMLElement {
connectedCallback
(
)
{
setTimeout
(
(
)
=>
alert
(
this
.
innerHTML)
)
;
// Джон (*)
}
}
)
;
</
script
>
<
user-info
>
Джон</
user-info
>
Теперь alert
в строке (*)
показывает «Джон», поскольку мы запускаем его асинхронно, после завершения парсинга HTML. Мы можем обработать дочерние элементы при необходимости и завершить инициализацию.
С другой стороны, это решение также не идеально. Если вложенные пользовательские элементы тоже используют setTimeout
для инициализации, то они встают в очередь: первым запускается внешний setTimeout
, а затем внутренний.
Так что внешний элемент завершает инициализацию раньше внутреннего.
Продемонстрируем это на примере:
<
script
>
customElements.
define
(
'user-info'
,
class
extends
HTMLElement {
connectedCallback
(
)
{
alert
(
`
${
this
.
id}
connected.
`
)
;
setTimeout
(
(
)
=>
alert
(
`
${
this
.
id}
initialized.
`
)
)
;
}
}
)
;
</
script
>
<
user-info
id
=
"
outer"
>
<
user-info
id
=
"
inner"
>
</
user-info
>
</
user-info
>
Порядок вывода:
- outer connected.
- inner connected.
- outer initialized.
- inner initialized.
Мы ясно видим, что внешний элемент outer
завершает инициализацию (3)
до внутреннего inner
(4)
.
Нет встроенного колбэка, который срабатывает после того, как вложенные элементы готовы. Если нужно, мы можем реализовать подобное самостоятельно. Например, внутренние элементы могут отправлять события наподобие initialized
, а внешние могут слушать и реагировать на них.
Модифицированные встроенные элементы
Новые элементы, которые мы создаём, такие как <time-formatted>
, не имеют связанной с ними семантики. Они не известны поисковым системам, а устройства для людей с ограниченными возможностями не могут справиться с ними.
Но такие вещи могут быть важны. Например, поисковой системе было бы интересно узнать, что мы показываем именно время. А если мы делаем специальный вид кнопки, почему не использовать существующую функциональность <button>
?
Мы можем расширять и модифицировать встроенные HTML-элементы, наследуя их классы.
Например, кнопки <button>
являются экземплярами класса HTMLButtonElement
, давайте построим элемент на его основе.
-
Унаследуем
HTMLButtonElement
нашим классом:class
HelloButton
extends
HTMLButtonElement
{
/* методы пользовательского элемента */
}
-
Предоставим третий аргумент в
customElements.define
, указывающий тег:customElements
.
define
(
'hello-button'
,
HelloButton,
{
extends
:
'button'
}
)
;
Бывает, что разные теги имеют одинаковый DOM-класс, поэтому указание тега необходимо.
-
В конце, чтобы использовать наш пользовательский элемент, вставим обычный тег
<button>
, но добавим к немуis="hello-button"
:
...<
buttonis
=
"
hello-button"
>
</
button>
Вот полный пример:
<
script
>
// Кнопка, говорящая "привет" по клику
class
HelloButton
extends
HTMLButtonElement
{
constructor
(
)
{
super
(
)
;
this
.
addEventListener
(
'click'
,
(
)
=>
alert
(
"Привет!"
)
)
;
}
}
customElements.
define
(
'hello-button'
,
HelloButton,
{
extends
:
'button'
}
)
;
</
script
>
<
button
is
=
"
hello-button"
>
Нажми на меня</
button
>
<
button
is
=
"
hello-button"
disabled
>
Отключена</
button
>
Наша новая кнопка расширяет встроенную. Так что она сохраняет те же стили и стандартные возможности, наподобие атрибута disabled
.
Ссылки
- HTML Living Standard: https://html.spec.whatwg.org/#custom-elements.
- Совместимость: https://caniuse.com/#feat=custom-elementsv1.
Итого
Есть два типа пользовательских элементов:
-
«Автономные» – новые теги, расширяющие
HTMLElement
.Схема определения:
class
MyElement
extends
HTMLElement
{
constructor
(
)
{
super
(
)
;
/* ... */
}
connectedCallback
(
)
{
/* ... */
}
disconnectedCallback
(
)
{
/* ... */
}
static
get
observedAttributes
(
)
{
return
[
/* ... */
]
;
}
attributeChangedCallback
(
name
,
oldValue,
newValue)
{
/* ... */
}
adoptedCallback
(
)
{
/* ... */
}
}
customElements.
define
(
'my-element'
,
MyElement)
;
/* <my-element> */
-
«Модифицированные встроенные элементы» – расширения существующих элементов.
Требуют ещё один аргумент в
.define
и атрибутis="..."
в HTML:class
MyButton
extends
HTMLButtonElement
{
/*...*/
}
customElements.
define
(
'my-button'
,
MyElement,
{
extends
:
'button'
}
)
;
/* <button is="my-button"> */
Пользовательские элементы широко поддерживаются среди браузеров. Существует полифил: https://github.com/webcomponents/polyfills/tree/master/packages/webcomponentsjs.
Комментарии
<code>
, для нескольких строк кода — тег<pre>
, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)