- PVSM.RU - https://www.pvsm.ru -
В один момент мне предстояло срочно познакомиться с веб-компонентами и найти способ удобно разрабатывать с их помощью. Я планирую написать серию статей, что бы
как-то систематизировать знания по веб-компонентам, lit-element и дать краткое ознакомление с этой технологией для других. Я не являюсь экспертом в данной технологии и с радостью приму любой фидбек.
lit-element [1] — это обертка (базовый шаблон) для нативных веб-компонентов. Она реализует множество удобных методов, которых нет в спецификации. За счет своей близости к нативной реализации lit-element показывает очень хорошие результаты в различных benchmark [2] относительно других подходов (на 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/ [3].
Если взглянем в html, увидим, что перед закрывающим тегом находится webcomponents-loader.js [4]. Это набор полифиллов для веб-компонентов, и для кроссбраузерной работы веб-компонента желательно, чтобы был данный полифилл. Посмотрим на таблицу браузеров [5], реализующих все стандарты для работы веб-компонентов, там указано, что EDGE все еще не до конца реализует стандарты (я молчу про IE11, который до сих пор требуется поддерживать).
Реализовано 2 варианта этого полифилла:
Также прошу обратить внимание на еще один полифилл — custom-elements-es5-adapter.js [8]. Согласно спецификации, в нативный 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, которые должны быть изменены.
Почти все из этого кода было в простом примере, но добавлен декоратор [9] @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
. Правила преобразования указаны тут [10].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
элемента в соответствии со спецификацией [11]. Таким образом улучшается перфоманс при большом количестве элементов, т.к. при регистрации нового компонента он уже знает, какие свойства ему определяют его стили, их не надо регистрировать каждый раз и пересчитывать.
При этом, если данная спецификация не поддерживается, то создается обычный тег 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. Изменение свойств в этом методе не вызывает другого обновления.
Чтобы понять все нюансы жизненного цикла компонента, советую обратиться к документации [12].
На работе у меня проект на adobe experience manager (AEM), в его авторинге пользователь может делать drag & drop компонентов на страницу, и по идеологии AEM этот компонент содержит тег script
, в котором содержится все что нужно для реализации логики данного компонента. Но по факту, такой подход порождал множество блокирующих ресурсов и сложностей с реализацией фронта в данной системе. Для реализации фронта были выбраны веб-компоненты как способ не изменять рендеринг на стороне сервера (с чем он прекрасно справлялся), а также мягко, поэлементно, обогащать старую реализацию новым подходом. На мой взгляд, есть несколько вариантов реализации подгрузки веб-компонентов для данной системы: собрать бандл (он может стать очень большим) или разбить на чанки (очень много мелких файлов, нужна динамическая подгрузка), или использовать уже текущий подход с встраиванием script в каждый компонент, который рендерится на стороне сервера (очень не хочется к этому возвращаться). На мой взгляд, первый и третий вариант – не вариант. Для второго нужен динамический загрузчик, как в stencil. Но для lit-element в «коробке» такого не предоставляется. Со стороны разработчиков lit-element была попытка создать динамический загрузчик [13], но он является экспериментом, и использовать его в продакшен не рекомендуется. Также от разработчиков lit-element есть issue [14] в репозиторий спецификации веб-компонентов [15] с предложением добавить в спецификацию возможность динамически подгружать необходимый js для веб-компонента на основе html разметки на странице. И, на мой взгляд, этот нативный инструмент – очень хорошая идея, которая позволит создавать одну точку инициализации веб-компонентов и просто добавлять ее на всех страницах сайта.
Для динамической подгрузки веб-компонентов на основе lit-element ребятами из PolymerLabs был разработан split-element [13]. Это эксперементальное решение. Работает оно следующим способом:
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 [16]
следующему выводу: инструмент вполне рабочий, надо все определения веб-компонентов собирать в один файл, а описание самого компонента через чанки подключать отдельно. Без http2 данный подход не работает, т.к. формируется очень большой пул мелких файлов, описывающих компоненты. Если исходить из принципа atomic design [17], то импортирование атомов необходимо определять в организме, а вот организм уже подключать как отдельный компонент. Одно из «узких» мест – это то, что пользователю в браузер придет множество определений пользовательских элементов, которые будут так или иначе инициализированы в браузере, и им будет определено первоначальное состояние. Такое решение избыточно. Один из вариантов простого решения для загрузчика компонентов это следующий алгоритм:
Для более удобной работы с веб-компонентами и lit-element я бы предложил обратить внимание на проект open-wc.org [18]. Там предложены генераторы для сборщиков на основе webpack и rollup, туллинг для тестирования веб-компонентов и их демонстрации с помощью storybook, а также советы и рекомендации по разработке и настройки IDE.
Автор: dagot32167
Источник [30]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/312697
Ссылки в тексте:
[1] lit-element: https://github.com/Polymer/lit-element
[2] benchmark: https://vogloblinsky.github.io/web-components-benchmark/
[3] http://localhost:5000/: http://localhost:5000/
[4] webcomponents-loader.js: https://github.com/webcomponents/webcomponentsjs
[5] таблицу браузеров: https://www.webcomponents.org/
[6] webcomponents-bundle.js: https://github.com/webcomponents/webcomponentsjs#using-webcomponents-bundlejs
[7] webcomponents-loader.js: https://github.com/webcomponents/webcomponentsjs#using-webcomponents-loaderjs
[8] custom-elements-es5-adapter.js: https://github.com/webcomponents/webcomponentsjs#custom-elements-es5-adapterjs
[9] декоратор: https://github.com/tc39/proposal-decorators#decorators
[10] тут: https://lit-element.polymer-project.org/guide/properties#conversion
[11] спецификацией: https://wicg.github.io/construct-stylesheets/
[12] документации: https://lit-element.polymer-project.org/guide/lifecycle
[13] попытка создать динамический загрузчик: https://github.com/PolymerLabs/split-element
[14] issue: https://github.com/w3c/webcomponents/issues/782
[15] репозиторий спецификации веб-компонентов: https://github.com/w3c/webcomponents
[16] https://github.com/malay76a/elbrus-split-litelement-web-components: https://github.com/malay76a/elbrus-split-litelement-web-components
[17] atomic design: http://bradfrost.com/blog/post/atomic-web-design/
[18] open-wc.org: http://open-wc.org/
[19] Let's Build Web Components! Part 5: LitElement: https://dev.to/bennypowers/lets-build-web-components-part-5-litelement-906
[20] Web Component Essentials: https://books.google.ru/books?id=HStxDwAAQBAJ&pg=PA71&lpg=PA71&dq=LitElement+performance&source=bl&ots=d_axG-lsTB&sig=sdyQdApVjn0kGsi4ov6x6d3Sm6Q&hl=ru&sa=X&ved=2ahUKEwirjtq11KbfAhWKZlAKHdPXB34Q6AEwB3oECAAQAQ#v=onepage&q=LitElement&f=false
[21] A night experimenting with Lit-HTML…: https://medium.com/@lucamezzalira/a-night-experimenting-with-lit-html-585a8c69892a
[22] LitElement To Do App: https://medium.com/@westbrook/litelement-to-do-app-1e08a31707a4
[23] LitElement app tutorial part 1: Getting started: https://www.youtube.com/watch?v=UcCsGZDCw-Q
[24] LitElement tutorial part 2: Templating, properties, and events: https://www.youtube.com/watch?v=s6P3R-J0IiI
[25] LitElement tutorial part 3: State management with Redux: https://www.youtube.com/watch?v=_Gt12UhGLY0
[26] LitElement tutorial part 4: Navigation and code splitting: https://www.youtube.com/watch?v=JajSgc7xelI
[27] LitElement tutorial part 5: PWA and offline: https://www.youtube.com/watch?v=ToxKlmqgZHw
[28] Lit-html workshop: https://github.com/LarsDenBakker/lit-html-workshop
[29] Awesome lit-html: https://github.com/web-padawan/awesome-lit-html
[30] Источник: https://habr.com/ru/post/445438/?utm_campaign=445438
Нажмите здесь для печати.