Знакомство с lit-element и веб-компонентами на его основе
- среда, 27 марта 2019 г. в 00:21:01
В один момент мне предстояло срочно познакомиться с веб-компонентами и найти способ удобно разрабатывать с их помощью. Я планирую написать серию статей, что бы
как-то систематизировать знания по веб-компонентам, lit-element и дать краткое ознакомление с этой технологией для других. Я не являюсь экспертом в данной технологии и с радостью приму любой фидбек.
lit-element — это обертка (базовый шаблон) для нативных веб-компонентов. Она реализует множество удобных методов, которых нет в спецификации. За счет своей близости к нативной реализации lit-element показывает очень хорошие результаты в различных benchmark относительно других подходов (на 06.02.2019г).
Бонусы, которые я вижу от использования lit-element как базового класса веб-компонентов:
Создадим простой веб-компонент на lit-element. Обратимся к документации. Нам необходимо следующее:
npm install --save lit-element
Например, нам надо создать веб-компонент, инициализирующийся в теге my-component
. Для этого создадим js файл my-component.js
и определим его базовый шаблон:
// для импорта базового шаблона на основе lit-element
import { } from '';
// для создания логики самого компонента
class MyComponent { }
// для регистрации компонента в браузере
customElements.define();
Первым делом импортируем наш базовый шаблон:
import { LitElement, html } from 'lit-element';
// LitElement - это базовый шаблон (обертка) для нативного веб-компонента
// html - функция lit-html, которая обрабатывает переданную ей строку, парсит
// и вставляет полученный html в структуру документа
Во вторых, создадим сам веб-компонент, используя LitElement
// прошу обратить внимание, в нативной реализации
// вместо LitElement мы бы использовали HTMLElement
class MyComponent extends LitElement {
// жизненный цикл компонента LitElement гораздо богаче
// и нам не обязательно вызывать constructor или connectedCallback
// мы можем сразу указать что именно должен отрисовать наш компонент
// прошу так же заметить, что по умолчанию к компоненту добавляется
// shadowDOM с опцией {mode: 'open'}
render() {
return html`<p>Hello World!</p>`
}
}
И последнее — зарегистрировать веб-компонент в браузере
customElements.define('my-component', MyComponent);
В итоге получаем следующее:
import { LitElement, html } from 'lit-element';
class MyComponent extends LitElement {
render() {
return html`<p>Hello World!</p>`
}
}
customElements.define('my-component', MyComponent);
Если исключить необходимость подключать my-component.js
к html, то это все. Самый простой компонент готов.
Предлагаю не изобретать велосипед и взять готовую сборку lit-element-build-rollup. Следуем инструкции:
git clone https://github.com/PolymerLabs/lit-element-build-rollup.git
cd lit-element-build-rollup
npm install
npm run build
npm run start
После выполнения всех команд переходим на страницу в браузере http://localhost:5000/.
Если взглянем в html, увидим, что перед закрывающим тегом находится webcomponents-loader.js. Это набор полифиллов для веб-компонентов, и для кроссбраузерной работы веб-компонента желательно, чтобы был данный полифилл. Посмотрим на таблицу браузеров, реализующих все стандарты для работы веб-компонентов, там указано, что EDGE все еще не до конца реализует стандарты (я молчу про IE11, который до сих пор требуется поддерживать).
Реализовано 2 варианта этого полифилла:
Также прошу обратить внимание на еще один полифилл — custom-elements-es5-adapter.js. Согласно спецификации, в нативный customElements.define могут быть добавлены только ES6 классы. Для лучшей производительности код на ES6 стоит передавать только тем браузерам, которые его поддерживают, а ES5 — всем остальным. Так не всегда получается сделать, поэтому для лучшей кроссбраузерности, рекомендуется весь ES6 код переводить в ES5. Но в таком случае веб-компоненты на ES5 не смогут работать в браузерах. Для решения этой проблемы и существует custom-elements-es5-adapter.js.
Теперь давайте откроем файл ./src/my-element.js
import {html, LitElement, property} from 'lit-element';
class MyElement extends LitElement {
// @property - декоратор, который может обработать babel и ts
// он нужен для определения типа переменной и дальнейшей
// ее проверки, силами транспайлера
@property({type: String}) myProp = 'stuff';
render() {
return html`
<p>Hello World</p>
${this.myProp}
`;
}
}
customElements.define('my-element', MyElement);
Шаблонизатор lit-html может обработать строку по-разному. Приведу несколько вариантов:
// статичный элемент:
html`<div>Hi</div>`
// выражение:
html`<div>${this.disabled ? 'Off' : 'On'}</div>`
// свойство:
html`<x-foo .bar="${this.bar}"></x-foo>`
// атрибут:
html`<div class="${this.color} special"></div>`
// атрибут типа boolean, если checked === false,
// то данный атрибут не будет добавлен в HTML:
html`<input type="checkbox" ?checked=${checked}>`
// обработчик события:
html`<button @click="${this._clickHandler}"></button>`
Советы по оптимизации функции render():
Не делайте обновление DOM вне функции render().
За отрисовку lit-element отвечает lit-html – это декларативный способ описания того, как должен отображаться веб-компонент. lit-html гарантирует быстрое обновления, меняя только те части DOM, которые должны быть изменены.
Почти все из этого кода было в простом примере, но добавлен декоратор @property
для свойства myProp
. Данный декоратор указывает на то, что мы ожидаем атрибут с именем myprop
в нашем my-element
. Если такой атрибут не задан, ему по умолчанию задается строковое значение stuff
.
<!-- Атрибут myProp не задан, по этому он будет сгенерирован в веб-компоненте
со значением 'stuff' -->
<my-element></my-element>
<!-- Атрибут myprop из нотации в строчном формате соотносится
с нотацией lowerCamelCase т.е. myProp и в веб-компоненте
этому свойству будет задано значение 'else' -->
<my-element myprop="else"></my-element>
lit-element предоставляет 2 способа работы с property
:
properties
.Первый вариант дает возможность указать каждое свойство отдельно:
@property({type: String}) prop1 = '';
@property({type: Number}) prop2 = 0;
@property({type: Boolean}) prop3 = false;
@property({type: Array}) prop4 = [];
@property({type: Object}) prop5 = {};
Второй – указать все в одном месте, но в этом случае, если у свойства есть значение по умолчанию, его необходимо прописывать в методе конструктора класса:
static get properties() {
return {
prop1: {type: String},
prop2: {type: Number},
prop3: {type: Boolean},
prop4: {type: Array},
prop5: {type: Object}
};
}
constructor() {
this.prop1 = '';
this.prop2 = 0;
this.prop3 = false;
this.prop4 = [];
this.prop5 = {};
}
API для работы с properties в lit-element довольно обширное:
false
, то атрибут будет исключен из наблюдения, для него не будет создан геттер. Если true
или attribute
отсутствует, то свойство, указанное в геттере в формате lowerCamelCase, будет соотноситься с атрибутом в строчный формат. Если задана строка, например my-prop
– то будет соотноситься с таким же названием в атрибутах.fromAttribute
и toAttribute
, эти ключи содержат отдельные функции для конвертации значений. По умолчанию свойство содержит преобразование в базовые типы Boolean
, String
, Number
, Object
и Array
. Правила преобразования указаны тут.true
) и изменяться в соответствии с правилами из type
и converter
.Boolean
. Если true
– то запускает обновление элемента.Boolean
и по умолчанию false
. Оно запрещает генерировать геттеры и сеттеры для каждого свойства для обращения к ним из класса. Это не отменяет конвертацию. Сделаем гипотетический пример: напишем веб-компонент, который содержит параметр, в котором содержится строка, на экран должно быть отрисовано это слово, в котором каждая буква больше предыдущей.
<!-- index.html -->
<ladder-of-letters latters="абвгде"></ladder-of-letters>
//ladder-of-letters.js
import {html, LitElement, property} from 'lit-element';
class LadderOfLetters extends LitElement {
@property({
type: Array,
converter: {
fromAttribute: (val) => {
// console.log('in fromAttribute', val);
return val.split('');
}
},
hasChanged: (value, oldValue) => {
if(value === undefined || oldValue === undefined) {
return false;
}
// console.log('in hasChanged', value, oldValue.join(''));
return value !== oldValue;
},
reflect: true
}) letters = [];
changeLetter() {
this.letters = ['Б','В','Г','Д','Е'];
}
render() {
// console.log('in render', this.letters);
// для стилизации есть директивы, тут не использовано
// что бы не нагромождать функционала в примере
return html`
<div>${this.letters.map((i, idx) => html`<span style="font-size: ${idx + 2}em">${i}</span>`)}</div>
// @click это краткая запись о том, что мы добавляем слушатель
// на событие 'click' по данному элементу
<button @click=${this.changeLetter}>Изменить на 'БВГДЕ'</button>
`;
}
}
customElements.define('ladder-of-letters', LadderOfLetters);
в итоге получаем:
при нажатии на кнопку было изменено свойство, что вызвало сначала проверку, а потом было отправлено на перерисовку.
а используя reflect
мы можем увидеть также изменения в html
При изменении этого атрибута кодом вне этого веб-компонента мы также вызовем перерисовку веб-компонента.
Теперь рассмотрим стилизацию компонента. У нас есть 2 способа стилизовать lit-element:
render() {
return html`
<style>
p {
color: green;
}
</style>
<p>Hello World</p>
`;
}
styles
import {html, LitElement, css} from 'lit-element';
class MyElement extends LitElement {
static get styles() {
return [
css`
p {
color: red;
}
`
];
}
render() {
return html`
<p>Hello World</p>
`;
}
}
customElements.define('my-element', MyElement);
В итоге получаем, что тег со стилями не создается, а прописывается (>= Chrome 73
) в Shadow DOM
элемента в соответствии со спецификацией. Таким образом улучшается перфоманс при большом количестве элементов, т.к. при регистрации нового компонента он уже знает, какие свойства ему определяют его стили, их не надо регистрировать каждый раз и пересчитывать.
При этом, если данная спецификация не поддерживается, то создается обычный тег style
в компоненте.
Плюс, не забывайте, что таким образом мы также можем разделить, какие стили будут добавлены и рассчитаны на странице. Например, использовать медиазапросы не в css, а в JS и имплементировать только нужный стиль, например (это дико, но имеет место быть):
static get styles() {
const mobileStyle = css`p { color: red; }`;
const desktopStyle = css`p { color: green; }`;
return [
window.matchMedia("(min-width: 400px)").matches ? desktopStyle : mobileStyle
];
}
Соответственно, это мы увидим, если пользователь зашел на устройстве с шириной экрана более 400px.
А это – если пользователь зашел на сайт с устройства с шириной менее 400px.
Мое мнение: практически нет ни одного адекватного кейса, когда пользователь, работая на мобильном устройстве, неожиданно окажется перед полноценным монитором с шириной экрана 1920px. Добавим к этому еще и ленивую загрузку компонентов. В итоге получим очень оптимизированный фронт с быстрым рендерингом компонентов. Единственная проблема – сложность в поддержке.
Теперь предлагаю ознакомиться с методами жизненного цикла lit-element:
lit-html
. В идеале, функция render
– это чистая функция, которая использует только текущие свойства элемента. Метод render()
вызывается функцией update()
.requestUpdate()
. Аргумент функции changedProperties
– это Map
, содержащий ключи измененных свойств. По умолчанию данный метод всегда возвращает true
, но логику метода можно изменить, чтобы контролировать обновлением компонента.render()
. Также он выполняет обновление атрибутов элемента в соответствии со значением свойства. Установка свойств внутри этого метода не вызовет другое обновление.updated()
. Этот метод может быть полезен для захвата ссылок на визуализированные статические узлы, с которыми нужно работать напрямую, например, в updated()
.this
.Как происходит обновление элемента:
hasChanged(value, oldValue)
возвращает false
, элемент не обновляется. Иначе планируется обновление путем вызова requestUpdate()
.true
.updated()
.lit-html
шаблон для отрисовки элемента в DOM. Изменение свойств в этом методе не вызывает другого обновления.Чтобы понять все нюансы жизненного цикла компонента, советую обратиться к документации.
На работе у меня проект на adobe experience manager (AEM), в его авторинге пользователь может делать drag & drop компонентов на страницу, и по идеологии AEM этот компонент содержит тег script
, в котором содержится все что нужно для реализации логики данного компонента. Но по факту, такой подход порождал множество блокирующих ресурсов и сложностей с реализацией фронта в данной системе. Для реализации фронта были выбраны веб-компоненты как способ не изменять рендеринг на стороне сервера (с чем он прекрасно справлялся), а также мягко, поэлементно, обогащать старую реализацию новым подходом. На мой взгляд, есть несколько вариантов реализации подгрузки веб-компонентов для данной системы: собрать бандл (он может стать очень большим) или разбить на чанки (очень много мелких файлов, нужна динамическая подгрузка), или использовать уже текущий подход с встраиванием script в каждый компонент, который рендерится на стороне сервера (очень не хочется к этому возвращаться). На мой взгляд, первый и третий вариант – не вариант. Для второго нужен динамический загрузчик, как в stencil. Но для lit-element в «коробке» такого не предоставляется. Со стороны разработчиков lit-element была попытка создать динамический загрузчик, но он является экспериментом, и использовать его в продакшен не рекомендуется. Также от разработчиков lit-element есть issue в репозиторий спецификации веб-компонентов с предложением добавить в спецификацию возможность динамически подгружать необходимый js для веб-компонента на основе html разметки на странице. И, на мой взгляд, этот нативный инструмент – очень хорошая идея, которая позволит создавать одну точку инициализации веб-компонентов и просто добавлять ее на всех страницах сайта.
Для динамической подгрузки веб-компонентов на основе lit-element ребятами из PolymerLabs был разработан split-element. Это эксперементальное решение. Работает оно следующим способом:
customElements.define()
.SplitElement
загружает класс реализации и выполняет upgrade()
.Пример заглушки:
import {SplitElement, property} from '../split-element.js';
export class MyElement extends SplitElement {
// MyElement содержит асинхронную функцию load которая будет
// вызвана в момент при вызове connectedCallback() пользовательского элемента
static async load() {
// через динамический импорт указывается путь и класс
// элемента который будет имплементирован вместо MyElement
return (await import('./my-element-impl.js')).MyElementImpl;
}
// желательно указать некоторое первоначальное значение
// для свойств веб-компонента
@property() message: string;
}
customElements.define('my-element', MyElement);
Пример реализации:
import {MyElement} from './my-element.js';
import {html} from '../split-element.js';
// MyElementImpl содержит render и всю логику веб-компонента
export class MyElementImpl extends MyElement {
render() {
return html`
<h1>I've been upgraded</h1>
My message is ${this.message}.
`;
}
}
Пример SplitElement на ES6:
import {LitElement, html} from 'lit-element';
export * from 'lit-element';
// подменяем базовый класс LitElement на SplitElement
// в котором реализуем логику асинхронной подгрузки
export class SplitElement extends LitElement {
static load;
static _resolveLoaded;
static _rejectLoaded;
static _loadedPromise;
static implClass;
static loaded() {
if (!this.hasOwnProperty('_loadedPromise')) {
this._loadedPromise = new Promise((resolve, reject) => {
this._resolveLoaded = resolve;
this._rejectLoaded = reject;
});
}
return this._loadedPromise;
}
// функция которая сменит прототип для веб-компонента
// с его загрузчика на реализацию
static _upgrade(element, klass) {
SplitElement._upgradingElement = element;
Object.setPrototypeOf(element, klass.prototype);
new klass();
SplitElement._upgradingElement = undefined;
element.requestUpdate();
if (element.isConnected) {
element.connectedCallback();
}
}
static _upgradingElement;
constructor() {
if (SplitElement._upgradingElement !== undefined) {
return SplitElement._upgradingElement;
}
super();
const ctor = this.constructor;
if (ctor.hasOwnProperty('implClass')) {
// Реализация уже загружена, немедленно обновить
ctor._upgrade(this, ctor.implClass);
} else {
// Реализация не загружена
if (typeof ctor.load !== 'function') {
throw new Error('A SplitElement must have a static `load` method');
}
(async () => {
ctor.implClass = await ctor.load();
ctor._upgrade(this, ctor.implClass);
})();
}
}
// Заглушка не должна что либо рендерить
render() {
return html``;
}
}
Если вы все еще используете сборку, предложенную выше на Rollup, не забудьте установить для babel возможность обрабатывать динамические импорты
npm install @babel/plugin-syntax-dynamic-import
А в настройках .babelrc добавить
{
"plugins": ["@babel/plugin-syntax-dynamic-import"]
}
Тут я сделал небольшой пример реализации веб-компонентов с отложенной подгрузкой: https://github.com/malay76a/elbrus-split-litelement-web-components
следующему выводу: инструмент вполне рабочий, надо все определения веб-компонентов собирать в один файл, а описание самого компонента через чанки подключать отдельно. Без http2 данный подход не работает, т.к. формируется очень большой пул мелких файлов, описывающих компоненты. Если исходить из принципа atomic design, то импортирование атомов необходимо определять в организме, а вот организм уже подключать как отдельный компонент. Одно из «узких» мест – это то, что пользователю в браузер придет множество определений пользовательских элементов, которые будут так или иначе инициализированы в браузере, и им будет определено первоначальное состояние. Такое решение избыточно. Один из вариантов простого решения для загрузчика компонентов это следующий алгоритм:
Для более удобной работы с веб-компонентами и lit-element я бы предложил обратить внимание на проект open-wc.org. Там предложены генераторы для сборщиков на основе webpack и rollup, туллинг для тестирования веб-компонентов и их демонстрации с помощью storybook, а также советы и рекомендации по разработке и настройки IDE.