Состояние дел с веб-компонентами
Перевод статьи The state of Web Components с сайта hacks.mozilla.org, автор — Уилсон Пэйдж.
Веб-компоненты уже давно на примете у разработчиков. Впервые их предложил Алекс Рассел на конференции Fronteers 2011. Идея всколыхнула сообщество и стала темой многих последующих докладов и обсуждений.
В 2013 г. Google выпустил фреймворк на основе веб-компонентов под названием Polymer, чтобы опробовать эти новые API, собрать отклики сообщества и, приняв их во внимание, сделать «конфетку».
Теперь, 4 года спустя, веб-компонентам пора бы быть повсюду, но фактически лишь в Chrome реализована «какая-то их версия». Даже с учетом полифилов ясно, что сообщество не готово полностью принять веб-компоненты, пока они не начнут работать в большинстве браузеров.
Почему это настолько затянулось?
Вкратце — браузеры не смогли договориться.
Веб-компоненты были плодом трудов Google, который не удосужился толком обсудить их с другими браузерами до выпуска. Как и во многих других переговорах в жизни, стороны, не чувствующие сопричастности, не очень горят энтузиазмом и желанием соглашаться.
Веб-компоненты были смелой задумкой. Изначальный API был высокоуровневым и трудным в реализации (пусть и не без причин), что лишь добавило споров и разногласий между браузерами.
Google гнул свою линию, его разработчики получили отклик и сумели заинтересовать сообщество; но задним числом понятно, что до поддержки другими браузерами пользоваться этим нельзя.
Полифилы означали, теоретически, что веб-компоненты могут работать и в еще не реализовавших их браузерах, но они никогда не считались чем-либо, подходящим для практического применения.
Помимо всего этого, Microsoft не был готов вводить много новых DOM API, так как плотно работал над Edge (и почти завершил эту работу). А Apple сосредоточилась на альтернативных возможностях для Safari.
Пользовательские элементы
Из всех технологий веб-компонентов пользовательские элементы — наименее спорная. В основном все согласны, что возможность определять вид и поведение частички UI, а также возможность переносить эту частичку из браузера в браузер и из фреймворка во фреймворк — это полезно.
«Апгрейд»
Под термином «апгрейд» понимается, когда элемент преобразуется из старого доброго HTMLElement
в новёхонький пользовательский элемент с определенным жизненным циклом и свойством prototype
. На сегодня при апгрейде элементов для них вызывается метод createdCallback
.
var proto = Object.create(HTMLElement.prototype); proto.createdCallback = function() { ... }; document.registerElement('x-foo', { prototype: proto });
На сегодня есть пять предложений от разных браузеров, два из них выглядят наиболее многообещающими.
«Способ Дмитрия»
(Способ предложил Дмитрий Ломов, один из разработчиков движка V8 и член рабочей группы TC39, соавтор определения классов в ES2015 — прим. перев.)
Развитие идеи с createdCallback
, рассчитанное на работу с классами ES6. Суть createdCallback
остается, но подклассы гораздо привычнее.
class MyEl extends HTMLElement { createdCallback() { ... } } document.registerElement("my-el", MyEl);
Как и в сегодняшней реализации, пользовательский элемент начинает свою жизнь как HTMLUnknownElement
, затем через какое-то время его прототип заменяется («подменяется») зарегистрированным прототипом, и вызывается createdCallback
.
Недостаток этого подхода — то, что это не похоже на поведение самой платформы. Сначала элементы «неизвестные», а потом, в какой-то момент после, они принимают свою окончательную форму, что может запутать разработчика.
Синхронный конструктор
Конструктор, зарегистрированный разработчиком, вызывается парсером в тот же момент, когда пользовательский элемент создается и вставляется в дерево.
class MyEl extends HTMLElement { constructor() { ... } } document.registerElement("my-el", MyEl);
Хотя это кажется разумным, это значит, что никакой пользовательский элемент в исходном загруженном документе не сможет обновиться, если скрипт с определением registerElement
для него будет загружаться асинхронно. От этого мало толку в наступающей эпохе асинхронных модулей ES6.
Кроме того, при вписывании синхронных конструкторов в существующую платформу обнаружились проблемы в связи с .cloneNode()
.
Ожидается, что разработчики браузеров решат, куда двигаться дальше, на личной встрече в июле 2015 г.
is=""
Атрибут is
дает разработчику возможность делать поведение пользовательского элемента «надстройкой» над стандартным встроенным элементом.
<input type="text" is="my-text-input">
Аргументы за
- Позволяет расширять встроенные функции элементов, недоступные в виде примитивов (напр. характеристики доступности, элементы форм,
<template>
). - Дает возможность «прогрессивно улучшать» элемент, так что он остается функциональным и без JavaScript.
Аргументы против
- Запутывающий синтаксис.
- Он маскирует более глубокую проблему, что нам не хватает многих ключевых примитивов доступности на уровне платформы.
- Он маскирует более глубокую проблему, что у нас нет возможности правильно расширять встроенные элементы.
- Ограниченная область применения — как только разработчики подключают теневую DOM, все встроенные функции доступности теряются.
Консенсус
В общем все согласны, что is
— это «бородавка» на спецификации пользовательских элементов. Google уже реализовал is
и рассматривает его как временную меру, пока не появится доступ к низкоуровневым примитивам. Mozilla и Apple сейчас предпочли бы поскорее выпустить первую версию пользовательских элементов и как следует заняться этой проблемой во второй, не засоряя платформу «бородавками».
«HTML как пользовательские элементы» — проект Доменика Дениколы, пытающийся воссоздать встроенные HTML-элементы с помощью пользовательских, чтобы выяснить, каких DOM-примитивов платформе не хватает.
Теневая DOM
Теневая DOM пока что вызывает больше всего споров между браузерами. Настолько, что приходится отдельно планировать функции для «первой версии» и для «второй», чтобы побыстрее прийти хоть к какому-то согласию.
Распределение
Распределение — фаза, на которой потомки теневого хоста оказываются визуально «спроецированы» в слоты в теневой DOM хоста. Именно эта функция позволяет компоненту задействовать контент, который пользователь вкладывает в него.
Текущий API
Текущий API полностью декларативен. Чтобы указать, куда вы хотите визуально вставить потомков хоста, можно использовать специальный элемент <content>
внутри теневой DOM.
<content select="header"></content>
Apple и Microsoft дружно отказались от этого подхода из-за опасений по поводу сложности и быстродействия.
Новый императивный API
Даже на личной встрече не удалось договориться насчет декларативного API, так что все браузеры сошлись на необходимости императивного решения.
Все четыре организации (Microsoft, Google, Apple и Mozilla) получили задание описать этот новый API до июля 2015 г. Пока что есть три предложения. Простейшее из трех выглядит примерно так:
var shadow = host.createShadowRoot({ distribute: function(nodes) { var slot = shadow.querySelector('content'); for (var i = 0; i < nodes.length; i++) { slot.add(nodes[i]); } } }); shadow.innerHTML = '<content></content>'; // Сначала вызываем ... shadow.distribute(); // затем воспользуемся MutationObserver
Главное препятствие: синхронизация по времени. Если потомки ноды-хоста изменятся, и мы перераспределим их в момент срабатывания коллбэка MutationObserver
, обращение к зависящим от раскладки свойствам вернет неправильный результат.
myHost.appendChild(someElement); someElement.offsetTop; //=> старое значение // распределение по коллбэку на событие изменения (асинхронно) someElement.offsetTop; //=> новое значение
Обращение к offsetTop вызовет синхронную перекомпоновку до распределения!
Хоть с виду это вроде и не катастрофа, но скриптам и внутренним механизмам браузера часто необходимо правильное значение offsetTop
для выполнения множества действий, напр. прокрутки к определенному элементу.
Если решить эти проблемы не получится, нам придется опять вернуться к обсуждению декларативного API. Либо в стиле <content select>
, как сейчас, либо в виде «именованных слотов», которые совсем недавно предложила Apple.
Новый декларативный API — «именованные слоты»
Идея «именованных слотов» — упрощенный вариант нынешнего API «content select», в котором пользователь компонента должен явно указать в своем контенте, в какой слот он должен быть распределен.
Теневой корень элемента
<slot name="header"></slot> <slot></slot> <slot name="footer"></slot> <div>какой-то теневой контент</div>
Использование
<x-page> <header slot="header">header</header> <footer slot="footer">footer</footer> <h1>заголовок моей страницы</h1> <p>контент моей страницы<p> </x-page>
Итоговое/отрисованное дерево (то, что видит пользователь):
<x-page> <header slot="header">header</header> <h1>заголовок моей страницы</h1> <p>контент моей страницы<p> <footer slot="footer">footer</footer> <div>какой-то теневой контент</div> </x-page>
Браузер рассмотрел непосредственных потомков теневого хоста (myXPage.children
) и проверил, есть ли у какого-либо из них атрибут slot, соответствущий имени элемента shadowRoot
хоста.
Если совпадение нашлось, нода визуально «распределяется» на место соответствующего элемента
За:
- Распределение нагляднее и понятнее, меньше «магии».
- Движку проще рассчитывать распределение.
Против:
- Не объясняет, как работают встроенные элементы типа <select>.
- Навешивать на контент атрибуты slot — лишняя работа для пользователя.
- Менее выразительно.
«Закрытый» и «открытый»
Если shadowRoot
«закрыт», то к нему нет доступа через myHost.shadowRoot
. Это дает автору компонента некоторую гарантию, что пользователи не полезут в тонкости реализации, аналогично замыканиям для хранения приватной информации.
В Apple с уверенностью посчитали, что это важный момент, которым они не поступятся. Они настаивали, что детали реализации вообще не должны быть доступны внешнему миру, и что «закрытый» режим должен быть обязательным, когда «изолированные» пользовательские элементы станут реальностью.
С другой стороны, в Google посчитали, что «закрытые» корневые узлы теневой DOM могут мешать доступности и практическим сценариям работы компонентов. Они доказывали, что невозможно обратиться к shadowRoot
по ошибке, и если люди этого хотят, наверняка на это есть причина. JS/DOM открыт, пусть так и будет.
На апрельском совещании стало ясно, что для дальнейшего продвижения куда-либо «режим» должен стать свойством, но браузеры никак не могли прийти к согласию, должно ли оно по умолчанию иметь значение «открыто» или «закрыто». В итоге все согласились, что для первой версии «режим» станет обязательным параметром, так что значения по умолчанию у него просто не будет.
element.createShadowRoot({ mode: 'open' }); element.createShadowRoot({ mode: 'closed' });
Теневые комбинаторы
«Теневой комбинатор» — специальный «комбинатор» в CSS, позволяющий обратиться к элементу внутри корневого узла теневой DOM из внешнего мира. Пример — /deep/, позже переименованный в >>>:
.foo >>> div { color: red }
Когда писалась первая спецификация веб-компонентов, это казалось необходимым, но после анализа их реального использования оказалось скорее лишь источником проблем, как слишком простой способ нарушить ту инкапсуляцию стилей, за которую веб-компоненты так полюбились разработчикам.
Производительность
Расчеты стилей в теневой DOM с ее тесной областью видимости могут быть невероятно быстрыми, если движку не придется учитывать каких-либо внешних селекторов или состояний. Само существование теневого комбинатора исключает подобные оптимизации.
Альтернативы
Отмена теневых комбинаторов не означает, что пользователям нельзя будет управлять внешним видом компонентов извне.
Пользовательские свойства CSS (переменные)
В Firefox OS определенные стилевые свойства, которые можно задать (или переопределить) извне, доступны с помощью пользовательских свойств CSS.
Во внешних (пользовательских) стилях:
x-foo { --x-foo-border-radius: 10px; }
Во внутренних (авторских) стилях:
.internal-part { border-radius: var(--x-foo-border-radius, 0); }
Свой псевдоэлемент
Разработчики некоторых браузеров выразили желание вернуть возможность определять свои псевдоселекторы, которые позволяли бы применять стили к определенным внутренним частям (наподобие того, как сейчас мы оформляем части <input type=»range» />).
x-foo::my-internal-part { ... }
Скорее всего, эту идею будут рассматривать для второй версии спецификации теневой DOM.
Миксины — @extend
Есть спецификация-предложение перенести поведение директивы @extend из SASS в CSS. Это был бы удобный инструмент для разработчиков компонентов, дающий пользователям возможность применять свойства к определенной внутренней части сразу «пачками».
Во внешних (пользовательских) стилях:
.x-foo-part { background-color: red; border-radius: 4px; }
Во внутренних (авторских) стилях:
.internal-part { @extend .x-foo-part; }
Несколько корневых элементов в теневой DOM
«Зачем мне больше одного теневого корневого элемента в одном элементе?» — спросите вы. Ответ — наследование.
Представим, что я пишу компонент <x-dialog>
. В этом компоненте у меня будут вся разметка, стили и интерактивность, необходимая для открытия и закрытия диалогового окна.
<x-dialog> <h1>Мой заголовок</h1> <p>Немного подробностей</p> <button>Отмена</button> <button>OK</button> </x-dialog>
Теневой корневой элемент вытягивает любой контент, заданный пользователем, в div.inner
посредством точки вставки <content>
.
<div class="outer"> <div class="inner"> <content></content> </div> </div>
Еще я хочу создать <x-dialog-alert>
, который бы выглядел и вел себя совсем как <x-dialog>
, но с более ограниченным API, что-то вроде alert('foo')
.
<x-dialog-alert>foo</x-dialog-alert>
var proto = Object.create(XDialog.prototype); proto.createdCallback = function() { XDialog.prototype.createdCallback.call(this); this.createShadowRoot(); this.shadowRoot.innerHTML = templateString; }; document.registerElement('x-dialog-alert', { prototype: proto });
У нового компонента будет свой собственный корневой теневой элемент, но он рассчитан на работу поверх теневого корневого элемента родительского класса. Элемент <shadow>
представляет собой «старый» теневой корневой элемент, внутрь которого можно «проецировать» контент.
<shadow> <h1>Alert</h1> <content></content> <button>OK</button> </shadow>
Как только вы освоитесь с несколькими корневыми теневыми элементами, они превратятся в мощный механизм. Недостаток — большая сложность и вытекающее из нее множество пограничных случаев.
Наследование без нескольких теневых корневых элементов
Наследование возможно и без нескольких теневых корневых элементов, но для этого требуется вручную менять теневой корневой элемент родительского класса.
var proto = Object.create(XDialog.prototype); proto.createdCallback = function() { XDialog.prototype.createdCallback.call(this); var inner = this.shadowRoot.querySelector('.inner'); var h1 = document.createElement('h1'); h1.textContent = 'Alert'; inner.insertBefore(h1, inner.children[0]); var button = document.createElement('button'); button.textContent = 'OK'; inner.appendChild(button); ... }; document.registerElement('x-dialog-alert', { prototype: proto });
Недостатки этого подхода:
- Не так элегантно.
- Дочерний компонент зависит от деталей реализации родительского.
- Неприменим, если теневой корневой элемент родительского компонента «закрыт», поскольку значением
this.shadowRoot
будетundefined
.
HTML-импорт
HTML-импорт позволяет импортировать все ресурсы, определенные в одном HTML-документе, в другой.
<link rel="import" href="/path/to/imports/stuff.html">
Как уже отмечалось, Mozilla пока не планирует реализовать HTML-импорт. Отчасти это потому, что нам сначала посмотреть бы, как приживутся модули ES6, прежде чем выпускать другой способ импорта внешних ресурсов, а отчасти — на наш взгляд, они мало что добавят к имеющимся возможностям.
Мы уже больше года работаем с веб-компонентами в Firefox OS и обнаружили, что существующего синтаксиса для модулей (AMD и CommonJS) для разрешения дерева зависимостей, регистрации элементов, загруженных обычным тегом <script>
, на практике вполне достаточно.
HTML-импорт уже годится для более простого и более декларативного стиля разработки, как со старым <element>
и нынешним синтаксисом регистрации элемента в Polymer.
В связи с этой простотой у сообщества возникли критические замечания, что импорт не дает достаточного контроля, чтобы всерьез рассматривать его как решение для управления зависимостями.
До решения, принятого несколько месяцев назад, у Mozilla была работающая реализация, включаемая с помощью флага, но она уперлась в неполноту спецификации.
Что с ним будет?
Предложение изолированных пользовательских элементов от Apple использует подход в стиле HTML-импорта, чтобы у пользовательских элементов была собственная область видимости. Может быть, за этим будущее.
Мы в Mozilla хотим поизучать, как импорт объявлений пользовательских элементов сочетается с API модулей ES6, который уже на подходе. Мы будем готовы реализовать его, если/когда обнаружится, что он дает разработчикам какие-то принципиально новые возможности по сравнению с уже имеющимися.
В заключение
Веб-компоненты — ярчайший пример того, как трудно в наши дни внедрять крупные нововведения в браузеры. Любой уже добавленный API продолжает жить своей жизнью и мешает добавлять новые.
Это похоже на распутывание огромного клубка нитей, чтобы добавить нескольких новых и запутать его обратно. Этот клубок — наша платформа — становится всё больше и всё сложнее.
Вот уже больше трех лет веб-компоненты в стадии планирования, но мы надеемся, что финиш уже близок. Все ведущие браузеры заинтересованы в этом и не жалеют времени и сил на решение оставшихся проблем.
Так что готовьтесь делать веб компонентным!
Дополнительно
- Подключайтесь к продолжающемуся обсуждению в списке рассылки public-webapps
- Следите за развитием репозитория W3C Web Components
- Подпишитесь на новости Web Components Weekly
- Играйте с веб-компонентами в Firefox уже сейчас, включив настройку «dom.webcomponents.enabled» в about:config.
P.S. Это тоже может быть интересно: