Содержание
Введение
Вебу не хватает выразительности. Это легко проиллюстрировать — вот, посмотрите на «современное» веб-приложение вроде 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.register()
:
var XFoo = document.register('x-foo');
document.body.appendChild(new XFoo());
Первый аргумент document.register()
— название тэга элемента. Это название обязательно должно содержать дефис (-). Например, x-tags
, my-element
и my-awesome-app
— это разрешенные имена для новых элементов, а tabs
и foo_bar
использовать нельзя. Это ограничение позволяет парсеру отличать кастомные элементы от обычных и обеспечивает будущую совместимость, когда к HTML будут добавляться новые тэги.
Второй (необязательный) аргумент — объект, который описывает прототип элемента. Здесь к элементам можно добавлять кастомную функциональность (например, публичные свойства и методы).
По умолчанию кастомные элементы наследуют от HTMLElement
. Таким образом, предыдущий пример соответствует следующему коду:
var XFoo = document.register('x-foo', {
prototype: Object.create(HTMLElement.prototype)
});
Вызов document.register('x-foo')
обучает браузер новому элементу и возвращает функцию-конструктор, которую можно использовать для того, чтобы создавать экземпляры x-foo
. Если вы не хотите использовать конструктор, то есть и другие способы инициализации элемента.
Если вы не хотите, чтобы конструктор находился внутри глобального элемента window
, его можно поместить в некое пространство имен (var myapp = {}; myapp.XFoo = document.register('x-foo');
) или вообще нигде не сохранять на него ссылку.
Расширение встроенных элементов
Допустим, вы недовольны обычной, простой кнопкой button
. Вы бы хотели серьезно расширить её возможности, чтобы кнопка стала мега-кнопкой. Для этого, чтобы расширить элемент button
, вам нужно создать новый элемент, который наследует прототип HTMLButtonElement
:
var MegaButton = document.register('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.register()
.
Неопознанные элементы
Поскольку кастомные элементы регистрируются через скрипт (document.register()
), они могут быть объявлены или созданы до того, как браузер зарегистрирует их определение. Например, на странице вы можете определить x-tabs
, а document.register('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.register()
для того, чтобы рассказать браузеру о новом тэге… ну и что? Пока ничего не происходит. Давайте добавим свойства и методы.
Добавляем публичные свойства и методы
Самое интересное в кастомных элементах — то, что вы можете дополнять функциональные возможности элемента как вам угодно, доопределив его свойства и методы. Это можно представить себе как способ создавать для своего элемента публичный 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.register('x-foo', {prototype: XFooProto});
// 4. Создаем элемент x-foo.
var xfoo = document.createElement('x-foo');
// 5. Добавляем его на страницу.
document.body.appendChild(xfoo);
Конечно, есть тысяча способов сконструировать прототип. Если вам не нравится создавать прототипы так, как описано выше, вот краткая версия этого кода:
var XFoo = document.register('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.register('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.register('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 дает кастомным элементам:
- возможность прятать от пользователя внутреннюю сторону своей реализации;
- изоляция стилей — бесплатно!
Создавать элемент 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.register('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.register('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 дает элементу изоляцию стилей. Стили, которые определяются в теневом корневом элементе, не выходят за его пределы и не растекаются по странице. В случае кастомного элемента сам элемент — и есть корневой элемент для стилей. Свойства изоляции стилей, кроме того, позволяют кастомным элементам определять и стили по умолчанию для самих себя.
Стилизация Shadow DOM — обширная тема! Если вы хотите узнать ее лучше, советую прочесть несколько моих статей:
- «Руководство по стилизации элементов» в документации по Polymer.
- «Второй курс по Shadow DOM: CSS и стили» на html5rocks.com
Предотвращаем мигание контента с помощью :unresolved
Чтобы предотвратить мигание контента (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.register()
:
function supportsCustomElements() {
return 'register' in document;
}
if (supportsCustomElements()) {
// Отлично!
} else {
// Используйте другие библиотеки для создания компонентов.
}
Поддержка браузерами
document.register()
впервые начал поддерживаться в Chrome 27 и Firefox ~23. Однако спецификация с тех пор несколько развилась. Последняя спецификация поддерживается начиная с Chrome 31.
Кастомные элементы в Chrome 31 можно включить, поставив флаг на «Экспериментальных функциях веб-платформы» в about:flags
.
Пока что поддержка браузерами далека от совершенства, но есть несколько отличных полифиллов:
Что случилось HTMLElementElement?
Те, кто следил за работой по разработке стандарта, знают, что раньше существовал <element>
. Это была самая крутая вещь на деревне. Ее можно использовать, чтобы декларативно регистрировать новые элементы:
<element name="my-element">
...
</element>
К сожалению, с этой спецификацией было слишком много проблем — когда обновлять статус элемента, было несколько проблемных сценариев и сценариев, в которых наступал совсем уж конец света. Решить это было нельзя. element
пришлось положить на полку. В августе 2013 Дмитрий Глазков объявил в public-webapps о его удалении из спецификации, по крайней мере пока.
Нужно отметить, что внутри Polymer существует декларативная форма регистрации элемента:<polymer-element>
. Как они это делают? Используется document.register('polymer-element')
и приемы, которые я описал в главе «Создание элементов из шаблона».
Заключение
Кастомные элементы дают нам инструмент для расширения словаря HTML, возможность научить его новым приемам и прыгать со скоростью света по веб-платформе. Совместите их с другими низкоуровневыми API — Shadow DOM и <template>
— и вы увидите полную картину веб-компонентов. Разметка может снова стать сексуальной!
Если вам интересно начать работать с веб-компонентам, посмотрите на Polymer. Здесь более чем достаточно информации для старта.