Внимание! В этой статье обсуждаются API, которые еще не полностью стандартизированы и вполне могут измениться. Будьте осторожны с использованием экспериментальных API внутри своих проектов.
Вебу не хватает выразительности. Это легко проиллюстрировать — вот, посмотрите на «современное» веб-приложение вроде Gmail:
Современные веб-приложения это каша из <div>
ов.
В каше из <div>
ов нет ничегошеньки современного. А все-таки вот так мы сейчас
разрабатываем веб-приложения. И это весьма печально. Не стоит ли нам все-таки
потребовать несколько больше от нашей платформы?
HTML дает нам отличный инструмент для структурирования документа, но его словарь ограничен элементами, определенными в стандарте HTML .
Ну а если бы разметка Gmail была бы не настолько ужасной, а, наоборот, красивой:
<hangout-module>
<hangout-chat from="Пол Эдди">
<hangout-discussion>
<hangout-message from="Пол" profile="profile.png"
profile="118075919496626375791" datetime="2013-07-17T12:02">
<p>По полной врубаюсь в эту штуку — веб-компоненты.</p>
<p>Слышал о такой?</p>
</hangout-message>
</hangout-discussion>
</hangout-chat>
<hangout-chat>...</hangout-chat>
</hangout-module>
Уф, глоток свежего воздуха! Все в этом приложении на своем месте: оно осмысленно, его легко понять и, что лучше всего, легко поддерживать. Мне (или вам) в будущем будет абсолютно понятно, что это приложение делает, стоит только взглянуть на его декларативный скелет.
Кастомные элементы, на помощь! Вы — наша единственная надежда!
Кастомные элементы позволяют веб-разработчикам определять новые типы HTML-элементов. Эта спецификация — одна из нескольких новых корневых API, которые проходят по ведомству веб-компонентов, но из всех них, пожалуй, самая важная. Веб-компоненты просто не могут существовать без тех функций, которые предоставляют кастомные элементы:
- возможность определять новые элементы HTML/DOM;
- создавать элементы, которые расширяют функции других элементов;
- логически объединять кастомную функциональность в один тэг;
- расширять API существующих DOM-элементов.
Кастомные элементы можно создать с помощью функции document.registerElement()
:
var XFoo = document.registerElement('x-foo');
document.body.appendChild(new XFoo());
Первый аргумент document.registerElement()
— название тэга элемента. Это название
обязательно должно содержать дефис (-). Например, x-tags
, my-element
и
my-awesome-app
— это разрешенные имена для новых элементов, а tabs
и foo_bar
использовать нельзя. Это ограничение позволяет парсеру отличать кастомные элементы
от обычных и обеспечивает будущую совместимость, когда к HTML будут добавляться
новые тэги.
Второй (необязательный) аргумент — объект, который описывает прототип элемента. Здесь к элементам можно добавлять кастомную функциональность (например, публичные свойства и методы). Но об этом — позже.
По умолчанию кастомные элементы наследуют от HTMLElement
. Таким образом,
предыдущий пример соответствует следующему коду:
var XFoo = document.registerElement('x-foo', {
prototype: Object.create(HTMLElement.prototype)
});
Вызов document.registerElement('x-foo')
обучает браузер новому элементу и возвращает
функцию-конструктор, которую можно использовать для того, чтобы создавать
экземпляры x-foo
. Если вы не хотите использовать конструктор, то есть и
другие способы инициализации элемента.
Если вы не хотите, чтобы конструктор находился внутри глобального элемента window
,
его можно поместить в некое пространство имен (var myapp = {}; myapp.XFoo = document.registerElement('x-foo');
)
или вообще нигде не сохранять на него ссылку.
Допустим, вы недовольны обычной, простой кнопкой button
. Вы бы хотели
серьезно расширить её возможности, чтобы кнопка стала мега-кнопкой. Для этого,
чтобы расширить элемент button
, вам нужно создать новый элемент, который
наследует прототип HTMLButtonElement
:
var MegaButton = document.registerElement('mega-button', {
prototype: Object.create(HTMLButtonElement.prototype)
});
Чтобы создать элемент A, расширяющий элемент B, элемент A должен наследовать прототип от элемента B.
Такие кастомные элементы называются кастомными элементами расширения типа. Они
наследуют от конкретного HTMLElement
, как бы говоря: «элемент X — это Y».
Пример:
<button is="mega-button">
Задумывались ли вы когда-нибудь, почему HTML-парсер не ругается на нестандартные
тэги? Например, он совершенно не будет против, если мы объявим на странице <randomtag>
.
Согласно спецификации HTML:
Для HTML-элементов, которые не определены в этой спецификации, должен использоваться
интерфейс HTMLUnknownElement
.
Прости, randomtag
! Ты у нас нестандартный и наследуешь от HTMLUnknownElement
.
А вот кастомных элементов это не касается. Элементы с корректными именами для
кастомных элементов наследуют от HTMLElement
. Это можно проверить, открыв
консоль: Ctrl+Shift+J (или Cmd+Opt+J на Mac) и вставив следующие строчки кода —
обе возвратят true
:
// «tabs» — некорректное имя для кастомного элемента
document.createElement('tabs').__proto__ === HTMLUnknownElement.prototype
// «x-tabs» — корректное имя для кастомного элемента
document.createElement('x-tabs').__proto__ == HTMLElement.prototype
Примечание:
<x-tabs>
все равно будет являтьсяHTMLUnknownElement
в тех браузерах, которые не поддерживаютdocument.registerElement()
.
Поскольку кастомные элементы регистрируются через скрипт (document.registerElement()
),
они могут быть объявлены или созданы до того, как браузер зарегистрирует их
определение. Например, на странице вы можете определить x-tabs
, а
document.registerElement('x-tabs')
выполнить намного позднее.
Перед тем, как элементы обновят свои определения, они называются неопознанными элементами. Это HTML-элементы, у которых есть корректное имя для кастомного элемента, но они еще не зарегистрированы.
Эта таблица может помочь немного прояснить ситуацию:
Название | Наследует от | Примеры |
---|---|---|
Неопознанный элемент | HTMLElement | <x-tabs>, <my-element>, <my-awesome-app> |
Неизвестный элемент | HTMLUnknownElement | <tabs>, <foo_bar> |
Неопознанные элементы находятся как бы в лимбе: это потенциальные кандидаты для браузера, с которыми он может работать дальше. Браузер говорит: «Ну что же, в вас есть все качества, которые я ищу в новом элементе. Я обещаю, что сделаю вас настоящим элементом, когда мне дадут ваше определение».
Все общие приемы создания элементов относятся и к кастомным элементам. Как и любой стандартный элемент, его можно объявить в HTML или создать внутри DOM с помощью JavaScript.
Объявите их:
<x-foo></x-foo>
Создайте DOM с помощью JavaScript:
var xFoo = document.createElement('x-foo');
xFoo.addEventListener('click', function(e) {
alert('Спасибо!');
});
Используйте оператор new
:
var xFoo = new XFoo();
document.body.appendChild(xFoo);
Инициализировать кастомные элементы, расширяющие тип, можно практически так же, как и кастомные тэги.
Объявите их:
<!-- <button> — это мега-кнопка -->
<button is="mega-button">
Создайте DOM с помощью JavaScript:
var megaButton = document.createElement('button', 'mega-button');
// megaButton instanceof MegaButton === true
Как видите, функция document.createElement()
может принимать атрибут is=""
в качестве своего второго параметра.
Используйте оператор new
:
var megaButton = new MegaButton();
document.body.appendChild(megaButton);
Итак, мы разобрались, как использовать document.registerElement()
для того, чтобы
рассказать браузеру о новом тэге… ну и что? Пока ничего не происходит. Давайте
добавим свойства и методы.
Самое интересное в кастомных элементах — то, что вы можете дополнять функциональные возможности элемента как вам угодно, доопределив его свойства и методы. Это можно представить себе как способ создавать для своего элемента публичный API.
Вот полный пример:
var XFooProto = Object.create(HTMLElement.prototype);
// 1. Определяем для x-foo метод foo().
XFooProto.foo = function() {
alert('вызван метод foo()');
};
// 2. Определяем свойство «bar» (только для чтения).
Object.defineProperty(XFooProto, "bar", {value: 5});
// 3. Регистрируем определение x-foo.
var XFoo = document.registerElement('x-foo', {prototype: XFooProto});
// 4. Создаем элемент x-foo.
var xfoo = document.createElement('x-foo');
// 5. Добавляем его на страницу.
document.body.appendChild(xfoo);
Конечно, есть тысяча способов сконструировать прототип. Если вам не нравится создавать прототипы так, как описано выше, вот краткая версия этого кода:
var XFoo = document.registerElement('x-foo', {
prototype: Object.create(HTMLElement.prototype, {
bar: {
get: function() { return 5; }
},
foo: {
value: function() {
alert('вызван метод foo()');
}
}
})
});
Первый формат позволяет использовать Object.defineProperty
из ES5.
Второй позволяет использовать get/set.
Элементы могут определять специальные методы для того, чтобы вы могли включиться в интересные моменты их существования. Эти методы называются колбэками жизненного цикла. У каждого есть свое название и смысл:
Название коллбэка | Вызывается, когда |
---|---|
createdCallback | создан экземпляр элемента |
enteredDocumentCallback | экземпляр вставлен в документ |
leftDocumentCallback | экземпляр удален из документа |
attributeChangedCallback(attrName, oldVal, newVal) | был добавлен, удален или изменен атрибут |
Пример: определяем createdCallback()
и enteredDocumentCallback()
для x-foo
:
var proto = Object.create(HTMLElement.prototype);
proto.createdCallback = function() {...};
proto.enteredDocumentCallback = function() {...};
var XFoo = document.registerElement('x-foo', {prototype: proto});
Все коллбэки жизненного цикла необязательны, определяйте их тогда, когда
это имеет смысл. Например, если у вас достаточно сложный элемент, который должен
открывать соединение к IndexedDB в createdCallback()
. Тогда перед тем, как этот
элемент будет удален из DOM, подчистите все внутри leftDocumentCallback()
.
Примечание: нельзя рассчитывать только на это: может случиться и так, что
пользователь просто закроет таб, но все-таки думайте об этом как о возможности
для оптимизации.
Еще один сценарий использования коллбэков жизненного цикла — устанавливать на элементе обработчики событий по умолчанию:
proto.createdCallback = function() {
this.addEventListener('click', function(e) {
alert('Спасибо!');
});
};
Мы создали x-foo
, описали для него JavaScript-API, но тэг пустой! Давайте
выведем внутри него какой-нибудь HTML?
Здесь нам пригодятся коллбэки жизненного цикла. А если конкретно, можно
использовать createdCallback()
и приписать элементу какой-нибудь HTML по
умолчанию:
var XFooProto = Object.create(HTMLElement.prototype);
XFooProto.createdCallback = function() {
this.innerHTML = "Я — x-foo-with-markup!";
};
var XFoo = document.registerElement('x-foo-with-markup', {prototype: XFooProto});
Инициализируем этот тэг и смотрим на него в DevTools (правый клик, выбираем «просмотр элемента») и видим:
▾<x-foo-with-markup>
<b>Я — x-foo-with-markup!</b>
</x-foo-with-markup>
Сам по себе Shadow DOM — это мощный инструмент для независимого хранения контента. Используйте его вместе с кастомными элементами — и все приобретет магический оттенок!
Shadow DOM дает кастомным элементам:
- возможность прятать от пользователя внутреннюю сторону своей реализации;
- изоляция стилей — бесплатно!
Создавать элемент Shadow DOM можно точно так же, как и создавать элемент
разметки. Разница содержится в createdCallback()
:
var XFooProto = Object.create(HTMLElement.prototype);
XFooProto.createdCallback = function() {
// 1. Создаем теневой корневой элемент
var shadow = this.createShadowRoot();
// 2. Помещаем в него разметку
shadow.innerHTML = "Я внутри Shadow DOM элемента!";
};
var XFoo = document.registerElement('x-foo-shadowdom', {prototype: XFooProto});
Вместо того, чтобы устанавливать .innerHTML
элемента, я создал теневой
корневой элемент для x-foo-shadowdom
и поместил туда разметку. Если внутри
инструментов разработчика у вас включена настройка «Показывать Shadow DOM»,
то вы увидите, что #document-fragment
можно раскрыть:
▾<x-foo-shadowdom>
▾#document-fragment
<b>Я внутри Shadow DOM элемента!</b>
</x-foo-shadowdom>
Вот и он, теневой корневой элемент!
HTML-шаблоны — это еще один новый низкоуровневый API, который прекрасно вписывается в мир кастомных элементов.
Если кто еще не знает, элемент <template>
позволяет вас объявлять
фрагменты DOM, которые парсятся, с ними ничего не происходит на этапе загрузке
страницы, но потом они инициализируются через JavaScript. HTML-шаблоны —
идеальный формат для того, чтобы объявлять структуру кастомного элемента.
Пример: регистрируем элемент, созданный из <template>
и Shadow DOM:
<template id="sdtemplate">
<style>
p { color: orange; }
</style>
<p>Я внутри Shadow DOM. Моя разметка взята из <template>.</p>
</template>
<script>
var proto = Object.create(HTMLElement.prototype, {
createdCallback: {
value: function() {
var t = document.querySelector('#sdtemplate');
this.createShadowRoot().appendChild(t.content.cloneNode(true));
}
}
});
document.registerElement('x-foo-from-template', {prototype: proto});
</script>
В этой паре строк кода довольно много всего. Давайте разберемся во всем, что происходит:
- мы зарегистрировали в HTML новый элемент:
<template>
; - из
<template>
мы создали DOM элемента; - все страшные детали элемента спрятаны в Shadow DOM;
- Shadow DOM дает элементу изоляцию стилей: т.е.
p {color: orange;}
не заливает оранжевым всю страницу.
Отлично!
Как и в случае любого HTML-тэга, ваш кастомный тэг можно стилизовать используя селекторы:
<style>
app-panel {
display: flex;
}
[is="x-item"] {
transition: opacity 400ms ease-in-out;
opacity: 0.3;
flex: 1;
text-align: center;
border-radius: 50%;
}
[is="x-item"]:hover {
opacity: 1.0;
background: rgb(255, 0, 255);
color: white;
}
app-panel > [is="x-item"] {
padding: 5px;
list-style: none;
margin: 0 7px;
}
</style>
<app-panel>
<li is="x-item">До</li>
<li is="x-item">Ре</li>
<li is="x-item">Ми</li>
</app-panel>
Кроличья нора ведет гораздо, гораздо глубже, когда вы создаете решения использующие Shadow DOM. Кастомным элементам, которые используют Shadow DOM достаются и все преимущества последней.
Shadow DOM дает элементу изоляцию стилей. Стили, которые определяются в теневом корневом элементе, не выходят за его пределы и не растекаются по странице. В случае кастомного элемента сам элемент — и есть корневой элемент для стилей. Свойства изоляции стилей, кроме того, позволяют кастомным элементам определять и стили по умолчанию для самих себя.
Стилизация Shadow DOM — обширная тема! Если вы хотите узнать ее лучше, советую прочесть несколько моих статей:
- «Руководство по стилизации элементов» в документации по Polymer.
- «Второй курс по Shadow DOM: CSS и стили» на html5rocks.com
Чтобы предотвратить мигание контента (FOUC), в спецификации по кастомным
элементам предусмотрен новый CSS-псевдокласс :unresolved
. Вы можете целенаправленно
использовать его на неопознанных элементах, и он будет применяться ровно
до тех пор, пока браузер не вызовет createdCallback()
(см. методы жизненного цикла).
После того, как это произойдет, элемент больше не является неопознанным, он
обновится и превратился в элемент, соответствующий его определению.
CSS-псевдокласс :unresolved
поддерживается Chrome 29.
Пример: заставляем тэги x-foo
всплывать, когда они зарегистрированы:
<style>
x-foo {
opacity: 1;
transition: opacity 300ms;
}
x-foo:unresolved {
opacity: 0;
}
</style>
Учтите, что :unresolved
применяется только к неопознанным элементам, но
не к элементам, которые наследуют от HTMLUnkownElement
(см. Обновление элементов).
<style>
/* применить пунктирную границу ко всем неопознанным элементам */
:unresolved {
border: 1px dashed red;
display: inline-block;
}
/* неопознанные x-panel — красные */
x-panel:unresolved {
color: red;
}
/* после того, как определение x-panel регистрируется, они становятся зелеными */
x-panel {
color: green;
display: block;
padding: 5px;
display: block;
}
</style>
<panel>
Я черного цвета, потому что :unresolved не относится к «panel».
Это недопустимое имя для кастомного элемента.
</panel>
<x-panel>Я красного цвета, потому что подхожу под селектор x-panel:unresolved.</x-panel>
Для более подробной информации об :unresolved
смотрите Руководство по стилизации
элементов Polymer.
Определить, поддерживает ли браузер эту функциональность, довольно просто — нужно
проверить, существует ли document.registerElement()
:
function supportsCustomElements() {
return 'registerElement' in document;
}
if (supportsCustomElements()) {
// Отлично!
} else {
// Используйте другие библиотеки для создания компонентов.
}
document.registerElement()
впервые начал поддерживаться в Chrome 27 и Firefox ~23.
Однако спецификация с тех пор несколько развилась. Последняя спецификация
поддерживается начиная с Chrome 31.
Кастомные элементы в Chrome 31 можно включить, поставив флаг на «Экспериментальных
функциях веб-платформы» в about:flags
.
Пока что поддержка браузерами далека от совершенства, но есть несколько отличных полифиллов:
Те, кто следил за работой по разработке стандарта, знают, что раньше существовал
<element>
. Это была самая крутая вещь на деревне. Ее можно использовать, чтобы
декларативно регистрировать новые элементы:
<element name="my-element">
...
</element>
К сожалению, с этой спецификацией было слишком много проблем — когда обновлять статус элемента,
было несколько проблемных сценариев и сценариев, в которых наступал совсем уж конец света.
Решить это было нельзя. element
пришлось положить на полку. В августе 2013 Дмитрий Глазков
объявил в public-webapps о его удалении из спецификации, по крайней мере пока.
Нужно отметить, что внутри Polymer существует декларативная форма регистрации
элемента: <polymer-element>
. Как они это делают? Используется
document.registerElement('polymer-element')
и приемы, которые я описал в главе
«Создание элементов из шаблона».
Кастомные элементы дают нам инструмент для расширения словаря HTML, возможность
научить его новым приемам и прыгать со скоростью света по веб-платформе.
Совместите их с другими низкоуровневыми API — Shadow DOM и <template>
— и вы
увидите полную картину веб-компонентов. Разметка может снова стать сексуальной!
Если вам интересно начать работать с веб-компонентам, посмотрите на Polymer. Здесь более чем достаточно информации для старта.