Работа с пользовательскими элементами

Введение

В сети катастрофически не хватает выразительности. Чтобы понять, что я имею в виду, взгляните на «современное» веб-приложение, такое как GMail:

Gmail

В супе <div> нет ничего современного. И все же именно так мы создаем веб-приложения. Грустно. Разве мы не должны требовать большего от нашей платформы?

Сексуальная разметка. Давайте сделаем это вещью

HTML дает нам отличный инструмент для структурирования документа, но его словарь ограничен элементами, определяемыми стандартом HTML .

Что, если бы разметка GMail не была ужасной? А что, если бы это было красиво:

<hangout-module>
    <hangout-chat from="Paul, Addy">
    <hangout-discussion>
        <hangout-message from="Paul" profile="profile.png"
            profile="118075919496626375791" datetime="2013-07-17T12:02">
        <p>Feelin' this Web Components thing.
        <p>Heard of it?
        </hangout-message>
    </hangout-discussion>
    </hangout-chat>
    <hangout-chat>...</hangout-chat>
</hangout-module>

Как освежающе! Это приложение тоже имеет смысл. Это содержательно , легко понять и, что самое главное, легко поддерживать . В будущем я/вы точно узнаете, что он делает, просто изучив его декларативную основу.

Начиная

Пользовательские элементы позволяют веб-разработчикам определять новые типы HTML-элементов . Эта спецификация является одним из нескольких новых примитивов API, входящих в состав веб-компонентов , но, возможно, она самая важная. Веб-компоненты не существуют без функций, открываемых пользовательскими элементами:

  1. Определите новые элементы HTML/DOM.
  2. Создавайте элементы, которые вытекают из других элементов.
  3. Логически объединяйте пользовательские функции в один тег.
  4. Расширьте 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.

Второй аргумент — это (необязательный) объект, описывающий prototype элемента. Здесь можно добавить к вашим элементам пользовательские функции (например, общедоступные свойства и методы). Подробнее об этом позже.

По умолчанию пользовательские элементы наследуются от HTMLElement . Таким образом, предыдущий пример эквивалентен:

var XFoo = document.registerElement('x-foo', {
    prototype: Object.create(HTMLElement.prototype)
});

Вызов document.registerElement('x-foo') сообщает браузеру о новом элементе и возвращает конструктор, который вы можете использовать для создания экземпляров <x-foo> . В качестве альтернативы вы можете использовать другие методы создания экземпляров элементов , если не хотите использовать конструктор.

Расширение элементов

Пользовательские элементы позволяют расширять существующие (родные) элементы HTML, а также другие пользовательские элементы. Чтобы расширить элемент, вам необходимо передать в registerElement() имя и prototype элемента, от которого требуется наследовать.

Расширение собственных элементов

Скажем, вас не устраивает Обычный Джо <button> . Вы хотели бы расширить его возможности, чтобы он стал «Мега-кнопкой». Чтобы расширить элемент <button> , создайте новый элемент, который наследует prototype HTMLButtonElement и extends имя элемента. В данном случае «кнопка»:

var MegaButton = document.registerElement('mega-button', {
    prototype: Object.create(HTMLButtonElement.prototype),
    extends: 'button'
});

Пользовательские элементы, которые наследуются от собственных элементов, называются пользовательскими элементами расширения типа . Они наследуются от специализированной версии HTMLElement , чтобы сказать: «элемент X — это Y».

Пример:

<button is="mega-button">

Расширение пользовательского элемента

Чтобы создать элемент <x-foo-extended> , расширяющий пользовательский элемент <x-foo> , просто унаследуйте его прототип и укажите, от какого тега вы наследуете:

var XFooProto = Object.create(HTMLElement.prototype);
...

var XFooExtended = document.registerElement('x-foo-extended', {
    prototype: XFooProto,
    extends: 'x-foo'
});

Дополнительную информацию о создании прототипов элементов см. в разделе «Добавление свойств и методов JS» ниже.

Как обновляются элементы

Вы когда-нибудь задумывались, почему парсер HTML не справляется с нестандартными тегами? Например, было бы здорово объявить <randomtag> на странице. Согласно спецификации HTML :

Извините, <randomtag> ! Вы нестандартны и наследуете от HTMLUnknownElement .

Этого нельзя сказать о пользовательских элементах. Элементы с допустимыми именами пользовательских элементов наследуются от HTMLElement . Вы можете проверить этот факт, запустив консоль: Ctrl + Shift + J (или Cmd + Opt + J на ​​Mac) и вставив следующие строки кода; они возвращают true :

// "tabs" is not a valid custom element name
document.createElement('tabs').__proto__ === HTMLUnknownElement.prototype

// "x-tabs" is a valid custom element name
document.createElement('x-tabs').__proto__ == HTMLElement.prototype

Неразрешенные элементы

Поскольку пользовательские элементы регистрируются сценарием с помощью document.registerElement() , их можно объявить или создать до того, как их определение будет зарегистрировано браузером. Например, вы можете объявить <x-tabs> на странице, но в конечном итоге вызвать document.registerElement('x-tabs') гораздо позже.

Прежде чем элементы будут обновлены до своего определения, они называются неразрешенными элементами . Это элементы HTML, имеющие допустимое имя пользовательского элемента, но не зарегистрированные.

Эта таблица может помочь разобраться:

Имя Наследует от Примеры
Неразрешенный элемент HTMLElement <x-tabs> , <my-element>
Неизвестный элемент HTMLUnknownElement <tabs> , <foo_bar>

Создание элементов

Общие методы создания элементов по-прежнему применимы к пользовательским элементам. Как и любой стандартный элемент, их можно объявить в HTML или создать в DOM с помощью JavaScript.

Создание пользовательских тегов

Объявите их:

<x-foo></x-foo>

Создайте DOM в JS:

var xFoo = document.createElement('x-foo');
xFoo.addEventListener('click', function(e) {
    alert('Thanks!');
});

Используйте new оператор :

var xFoo = new XFoo();
document.body.appendChild(xFoo);

Создание экземпляров элементов расширения типа

Создание экземпляров пользовательских элементов в стиле расширения типа поразительно близко к пользовательским тегам.

Объявите их:

<!-- <button> "is a" mega button -->
<button is="mega-button">

Создайте DOM в JS:

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() , чтобы сообщить браузеру о новом теге… но это мало что дает. Добавим свойства и методы.

Добавление свойств и методов JS

Самое замечательное в пользовательских элементах заключается в том, что вы можете объединить с ними индивидуальные функциональные возможности, определив свойства и методы в определении элемента. Думайте об этом как о способе создания общедоступного API для вашего элемента.

Вот полный пример:

var XFooProto = Object.create(HTMLElement.prototype);

// 1. Give x-foo a foo() method.
XFooProto.foo = function() {
    alert('foo() called');
};

// 2. Define a property read-only "bar".
Object.defineProperty(XFooProto, "bar", {value: 5});

// 3. Register x-foo's definition.
var XFoo = document.registerElement('x-foo', {prototype: XFooProto});

// 4. Instantiate an x-foo.
var xfoo = document.createElement('x-foo');

// 5. Add it to the page.
document.body.appendChild(xfoo);

Конечно, существует бесчисленное множество способов построить prototype . Если вы не поклонник создания подобных прототипов, вот более сокращенная версия того же самого:

var XFoo = document.registerElement('x-foo', {
  prototype: Object.create(HTMLElement.prototype, {
    bar: {
      get: function () {
        return 5;
      }
    },
    foo: {
      value: function () {
        alert('foo() called');
      }
    }
  })
});

Первый формат позволяет использовать ES5 Object.defineProperty . Второй позволяет использовать get/set .

Методы обратного вызова жизненного цикла

Элементы могут определять специальные методы, позволяющие использовать интересные моменты их существования. Эти методы соответственно называются обратными вызовами жизненного цикла . Каждый из них имеет свое название и предназначение:

Имя обратного вызова Вызывается, когда
созданоОбратный вызов создается экземпляр элемента
прикрепленный обратный вызов экземпляр был вставлен в документ
отсоединенныйОбратный вызов экземпляр был удален из документа
атрибутChangedCallback (attrName, oldVal, newVal) атрибут был добавлен, удален или обновлен

Пример: определение createdCallback() и attachedCallback() для <x-foo> :

var proto = Object.create(HTMLElement.prototype);

proto.createdCallback = function() {...};
proto.attachedCallback = function() {...};

var XFoo = document.registerElement('x-foo', {prototype: proto});

Все обратные вызовы жизненного цикла не являются обязательными , но определите их, если/когда это имеет смысл. Например, предположим, что ваш элемент достаточно сложен и открывает соединение с IndexedDB в createdCallback() . Прежде чем он будет удален из DOM, выполните необходимую работу по очистке в detachedCallback() . Примечание: не стоит на это полагаться, например, если пользователь закроет вкладку, а воспринимайте это как возможный крючок оптимизации.

Другой вариант использования обратных вызовов жизненного цикла — для настройки прослушивателей событий по умолчанию для элемента:

proto.createdCallback = function() {
  this.addEventListener('click', function(e) {
    alert('Thanks!');
  });
};

Добавление разметки

Мы создали <x-foo> и предоставили ему JavaScript API, но он пуст! Давайте дадим ему немного HTML для рендеринга?

Здесь пригодятся обратные вызовы жизненного цикла . В частности, мы можем использовать createdCallback() , чтобы наделить элемент некоторым HTML по умолчанию:

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    this.innerHTML = "**I'm an x-foo-with-markup!**";
};

var XFoo = document.registerElement('x-foo-with-markup', {prototype: XFooProto});

Создание экземпляра этого тега и проверка в DevTools (щелкните правой кнопкой мыши, выберите «Проверить элемент») должны показать:

▾<x-foo-with-markup>
  **I'm an x-foo-with-markup!**
</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. Attach a shadow root on the element.
    var shadow = this.createShadowRoot();

    // 2. Fill it with markup goodness.
    shadow.innerHTML = "**I'm in the element's Shadow DOM!**";
};

var XFoo = document.registerElement('x-foo-shadowdom', {prototype: XFooProto});

Вместо установки .innerHTML элемента я создал теневой корень для <x-foo-shadowdom> , а затем заполнил его разметкой. Если в DevTools включен параметр «Показать теневой DOM», вы увидите #shadow-root , который можно расширить:

▾<x-foo-shadowdom>
  ▾#shadow-root
    **I'm in the element's Shadow DOM!**
</x-foo-shadowdom>

Это Теневой Корень!

Создание элементов из шаблона

HTML-шаблоны — это еще один новый примитив API, который прекрасно вписывается в мир пользовательских элементов.

Пример: регистрация элемента, созданного на основе <template> и Shadow DOM:

<template id="sdtemplate">
  <style>
    p { color: orange; }
  </style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<script>
  var proto = Object.create(HTMLElement.prototype, {
    createdCallback: {
      value: function() {
        var t = document.querySelector('#sdtemplate');
        var clone = document.importNode(t.content, true);
        this.createShadowRoot().appendChild(clone);
      }
    }
  });
  document.registerElement('x-foo-from-template', {prototype: proto});
</script>

<template id="sdtemplate">
  <style>:host p { color: orange; }</style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<div class="demoarea">
  <x-foo-from-template></x-foo-from-template>
</div>

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

  1. Мы зарегистрировали новый элемент в HTML: <x-foo-from-template>
  2. DOM элемента был создан из <template>
  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">Do</li>
    <li is="x-item">Re</li>
    <li is="x-item">Mi</li>
</app-panel>

Стилизация элементов, использующих Shadow DOM

Кроличья нора становится намного глубже, когда вы добавляете Shadow DOM. Пользовательские элементы, использующие Shadow DOM, наследуют его большие преимущества.

Shadow DOM наполняет элемент инкапсуляцией стиля. Стили, определенные в Shadow Root, не выходят за пределы хоста и не просачиваются со страницы. В случае пользовательского элемента хостом является сам элемент. Свойства инкапсуляции стилей также позволяют пользовательским элементам определять для себя стили по умолчанию.

Стилизация Shadow DOM — это огромная тема! Если вы хотите узнать об этом больше, я рекомендую несколько других моих статей:

Предотвращение FOUC с использованием :unresolved

Чтобы смягчить FOUC , пользовательские элементы определяют новый псевдокласс CSS :unresolved . Используйте его для нацеливания на неразрешенные элементы , вплоть до того момента, пока браузер не вызовет созданный вами createdCallback() (см. методы жизненного цикла ). Как только это произойдет, элемент больше не будет неразрешенным элементом. Процесс обновления завершен, и элемент трансформировался в свое определение.

Пример : постепенное исчезновение тегов «x-foo» при их регистрации:

<style>
  x-foo {
    opacity: 1;
    transition: opacity 300ms;
  }
  x-foo:unresolved {
    opacity: 0;
  }
</style>

Имейте в виду, что :unresolved применяется только к неразрешенным элементам , а не к элементам, которые наследуются от HTMLUnknownElement (см. Как обновляются элементы ).

<style>
  /* apply a dashed border to all unresolved elements */
  :unresolved {
    border: 1px dashed red;
    display: inline-block;
  }
  /* x-panel's that are unresolved are red */
  x-panel:unresolved {
    color: red;
  }
  /* once the definition of x-panel is registered, it becomes green */
  x-panel {
    color: green;
    display: block;
    padding: 5px;
    display: block;
  }
</style>

<panel>
    I'm black because :unresolved doesn't apply to "panel".
    It's not a valid custom element name.
</panel>

<x-panel>I'm red because I match x-panel:unresolved.</x-panel>

Поддержка истории и браузера

Обнаружение функций

Обнаружение функций заключается в проверке существования document.registerElement() :

function supportsCustomElements() {
    return 'registerElement' in document;
}

if (supportsCustomElements()) {
    // Good to go!
} else {
    // Use other libraries to create components.
}

Поддержка браузера

document.registerElement() впервые начал появляться за флагом в Chrome 27 и Firefox ~23. Однако с тех пор спецификация значительно изменилась. Chrome 31 — первый, в котором реализована настоящая поддержка обновленной спецификации.

Пока поддержка браузеров не станет звездной, существует полифилл , который используется Google Polymer и Mozilla X-Tag .

Что случилось с HTMLElementElement?

Те, кто следил за работой по стандартизации, знают, что когда-то существовал <element> . Это были колени пчел. Вы можете использовать его для декларативной регистрации новых элементов:

<element name="my-element">
    ...
</element>

К сожалению, в процессе обновления было слишком много проблем со сроками, крайних случаев и сценариев, подобных Армагеддону, чтобы все это решить. <element> пришлось отложить. В августе 2013 года Дмитрий Глазков разместил в общедоступных веб-приложениях сообщение об удалении приложения, по крайней мере на данный момент.

Стоит отметить, что Polymer реализует декларативную форму регистрации элементов с помощью <polymer-element> . Как? Он использует document.registerElement('polymer-element') и методы, которые я описал в разделе Создание элементов из шаблона .

Заключение

Пользовательские элементы дают нам инструмент для расширения словарного запаса HTML, обучения новым трюкам и преодоления червоточин веб-платформы. Объедините их с другими примитивами новой платформы, такими как Shadow DOM и <template> , и мы начнем понимать картину веб-компонентов. Разметка снова может быть сексуальной!

Если вы хотите начать работу с веб-компонентами, я рекомендую попробовать Polymer . Этого более чем достаточно, чтобы начать работу.