Настраиваемые элементы: как определять новые элементы в HTML

Внимание! В этой статье обсуждаются API, которые еще не полностью стандартизированы и вполне могут измениться. Будьте осторожны с использованием экспериментальных API внутри своих проектов.

Введение

Вебу не хватает выразительности. Это легко проиллюстрировать — вот, посмотрите на «современное» веб-приложение вроде Gmail:

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, которые проходят по ведомству веб-компонентов, но из всех них, пожалуй, самая важная. Веб-компоненты просто не могут существовать без тех функций, которые предоставляют кастомные элементы:

  1. возможность определять новые элементы HTML/DOM;
  2. создавать элементы, которые расширяют функции других элементов;
  3. логически объединять кастомную функциональность в один тэг;
  4. расширять API существующих DOM-элементов.

Регистрация новых элементов

Кастомные элементы можно создать с помощью функции document.register():

var XFoo = document.register('x-foo');
document.body.appendChild(new XFoo());

Первый аргумент document.register() — название тэга элемента. Это название обязательно должно содержать дефис (-). Например, x-tagsmy-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 дает кастомным элементам:

  1. возможность прятать от пользователя внутреннюю сторону своей реализации;
  2. изоляция стилей — бесплатно!

Создавать элемент 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. Моя разметка взята из &lt;template&gt;.</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>

В этой паре строк кода довольно много всего. Давайте разберемся во всем, что происходит:

  1. мы зарегистрировали в HTML новый элемент: <template>;
  2. из <template> мы создали DOM элемента;
  3. все страшные детали элемента спрятаны в Shadow DOM;
  4. 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 — обширная тема! Если вы хотите узнать ее лучше, советую прочесть несколько моих статей:

Предотвращаем мигание контента с помощью :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. Здесь более чем достаточно информации для старта.