Pull to refresh

Знакомство с lit-element и веб-компонентами на его основе

Reading time 14 min
Views 18K

В один момент мне предстояло срочно познакомиться с веб-компонентами и найти способ удобно разрабатывать с их помощью. Я планирую написать серию статей, что бы
как-то систематизировать знания по веб-компонентам, lit-element и дать краткое ознакомление с этой технологией для других. Я не являюсь экспертом в данной технологии и с радостью приму любой фидбек.


lit-element — это обертка (базовый шаблон) для нативных веб-компонентов. Она реализует множество удобных методов, которых нет в спецификации. За счет своей близости к нативной реализации lit-element показывает очень хорошие результаты в различных benchmark относительно других подходов (на 06.02.2019г).


Бонусы, которые я вижу от использования lit-element как базового класса веб-компонентов:


  1. Данная технология реализует уже вторую версию и «переболела детскими болезнями», свойственными только что появившимся инструментам.
  2. Сборка может осуществляться как polymer, так и webpack, typescript, rollup и т.д., это позволяет встроить lit-element в любой современный проект без каких-либо проблем.
  3. У lit-element очень удобная система работы с property в плане типизации, инициирования и конвертирования значений.
  4. lit-element реализует почти такую же логику, как у реакт, т.е. он предоставляет самый минимум — единый шаблон построения компонентов и его рендеринга и не ограничивает разработчика в выборе экосистемы и дополнительных библиотек.

Создадим простой веб-компонент на lit-element. Обратимся к документации. Нам необходимо следующее:


  1. Добавить в нашу сборку npm пакет с lit-element

    npm install --save lit-element
  2. Создать наш компонент.

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


  1. webcomponents-bundle.js — данная версия содержит все возможные варианты полизаполнения, все они инициируются, но каждый полифилл будет работать только на основании обнаруженных признаков.
  2. webcomponents-loader.js — это минимальный загрузчик, который на основании обнаруженных признаков подгружает нужные полифиллы

Также прошу обратить внимание на еще один полифилл — 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():


  • не должна изменять состояние элемента,
  • не должна иметь side effects,
  • должна зависеть только от свойств элемента,
  • должна возвращать одинаковый результат при передаче одинаковых значений.

Не делайте обновление 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:


  1. Через декоратор.
  2. Через статический геттер 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 довольно обширное:


  • attribute: может ли свойство стать наблюдаемым атрибутом. Если значение false, то атрибут будет исключен из наблюдения, для него не будет создан геттер. Если true или attribute отсутствует, то свойство, указанное в геттере в формате lowerCamelCase, будет соотноситься с атрибутом в строчный формат. Если задана строка, например my-prop – то будет соотноситься с таким же названием в атрибутах.
  • converter: содержит описание того, как преобразовать значение из/в атрибута/свойства. Значением может быть функция, которая работает для сериализации и десериализации значения, либо это может быть объект с ключами fromAttribute и toAttribute, эти ключи содержат отдельные функции для конвертации значений. По умолчанию свойство содержит преобразование в базовые типы Boolean, String, Number, Object и Array. Правила преобразования указаны тут.
  • type: указывает на один из базовых типов, который будет содержать данное свойство. Используется как «подсказка» для конвертера о том, какой тип должно содержать свойство.
  • reflect: указывает на то, должен ли атрибут быть связан со свойством (true) и изменяться в соответствии с правилами из type и converter.
  • hasChanged: есть у каждого свойства, содержит функцию, определяющую, есть ли изменение между старым и новым значением, соответственно возвращает Boolean. Если true – то запускает обновление элемента.
  • noAccessor: данное свойство принимает Boolean и по умолчанию false. Оно запрещает генерировать геттеры и сеттеры для каждого свойства для обращения к ним из класса. Это не отменяет конвертацию.

Сделаем гипотетический пример: напишем веб-компонент, который содержит параметр, в котором содержится строка, на экран должно быть отрисовано это слово, в котором каждая буква больше предыдущей.


<!-- index.html -->
<ladder-of-letters letters="абвгде"></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:


  1. Стилизация через добавление тега style в метода render

    render() {
      return html`
        <style>
          p {
            color: green;
          }
        </style>
        <p>Hello World</p>
      `;
    }
    


  2. Через статический геттер 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:


  • render(): реализует описание DOM элемента с помощью lit-html. В идеале, функция render – это чистая функция, которая использует только текущие свойства элемента. Метод render() вызывается функцией update().
  • shouldUpdate(changedProperties): реализуется, если необходимо контролировать обновление и рендеринг, когда были изменены свойства или вызван requestUpdate(). Аргумент функции changedProperties – это Map, содержащий ключи измененных свойств. По умолчанию данный метод всегда возвращает true, но логику метода можно изменить, чтобы контролировать обновлением компонента.
  • performUpdate(): реализуется для контроля времени обновления, например для интеграции с планировщиком.
  • update(changedProperties): этот метод вызывает render(). Также он выполняет обновление атрибутов элемента в соответствии со значением свойства. Установка свойств внутри этого метода не вызовет другое обновление.
  • firstUpdated(changedProperties): вызывается после первого обновления DOM элемента непосредственно перед вызовом updated(). Этот метод может быть полезен для захвата ссылок на визуализированные статические узлы, с которыми нужно работать напрямую, например, в updated().
  • updated(changedProperties): вызывается всякий раз, когда DOM элемента обновляется и отображается. Реализация для выполнения задач после обновления через API DOM, например, фокусировка на элементе.
  • requestUpdate(name, oldValue): вызывает запрос асинхронного обновления элемента. Это следует вызывать, когда элемент должен обновляться на основе некоторого состояния, не вызванного установкой свойства.
  • createRenderRoot(): по умолчанию создает Shadow Root для элемента. Если использование Shadow DOM не нужно, то метод должен вернуть this.

Как происходит обновление элемента:


  • Свойству задают новое значение.
  • Если свойство hasChanged(value, oldValue) возвращает false, элемент не обновляется. Иначе планируется обновление путем вызова requestUpdate().
  • requestUpdate(): обновляет элемент после microtask (в конце event loop и перед следующей перерисовкой).
  • performUpdate(): выполняется обновление, и продолжает остальную часть update API.
  • shouldUpdate(changedProperties): обновление продолжается, если возвращается true.
  • firstUpdated(changedProperties): вызывается когда элемент обновляется в первый раз, сразу же перед вызовом updated().
  • update(changedProperties): обновляет элемент. Изменение свойств в этом методе не вызывает другого обновления.
    • render(): возвращает lit-html шаблон для отрисовки элемента в DOM. Изменение свойств в этом методе не вызывает другого обновления.

  • updated(changedProperties): вызывается всякий раз, когда элемент обновляется.

Чтобы понять все нюансы жизненного цикла компонента, советую обратиться к документации.


На работе у меня проект на adobe experience manager (AEM), в его авторинге пользователь может делать drag & drop компонентов на страницу, и по идеологии AEM этот компонент содержит тег script, в котором содержится все что нужно для реализации логики данного компонента. Но по факту, такой подход порождал множество блокирующих ресурсов и сложностей с реализацией фронта в данной системе. Для реализации фронта были выбраны веб-компоненты как способ не изменять рендеринг на стороне сервера (с чем он прекрасно справлялся), а также мягко, поэлементно, обогащать старую реализацию новым подходом. На мой взгляд, есть несколько вариантов реализации подгрузки веб-компонентов для данной системы: собрать бандл (он может стать очень большим) или разбить на чанки (очень много мелких файлов, нужна динамическая подгрузка), или использовать уже текущий подход с встраиванием script в каждый компонент, который рендерится на стороне сервера (очень не хочется к этому возвращаться). На мой взгляд, первый и третий вариант – не вариант. Для второго нужен динамический загрузчик, как в stencil. Но для lit-element в «коробке» такого не предоставляется. Со стороны разработчиков lit-element была попытка создать динамический загрузчик, но он является экспериментом, и использовать его в продакшен не рекомендуется. Также от разработчиков lit-element есть issue в репозиторий спецификации веб-компонентов с предложением добавить в спецификацию возможность динамически подгружать необходимый js для веб-компонента на основе html разметки на странице. И, на мой взгляд, этот нативный инструмент – очень хорошая идея, которая позволит создавать одну точку инициализации веб-компонентов и просто добавлять ее на всех страницах сайта.


Для динамической подгрузки веб-компонентов на основе lit-element ребятами из PolymerLabs был разработан split-element. Это эксперементальное решение. Работает оно следующим способом:


  • Чтобы создать SplitElement, вы пишете два определения элементов в двух модулях.
  • Одним из них является «заглушка», которая определяет загруженные части элемента: обычно это имя и свойства. Свойства должны быть определены с заглушкой, чтобы lit-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, то импортирование атомов необходимо определять в организме, а вот организм уже подключать как отдельный компонент. Одно из «узких» мест – это то, что пользователю в браузер придет множество определений пользовательских элементов, которые будут так или иначе инициализированы в браузере, и им будет определено первоначальное состояние. Такое решение избыточно. Один из вариантов простого решения для загрузчика компонентов это следующий алгоритм:


  1. подгрузить обязательные утилиты,
  2. подгрузить полифиллы,
  3. собрать пользовательские элементы из light DOM:
    1. выбираются все элементы DOM содержащие дефис в названии тега,
    2. фильтруется список и формируется список из первых элементов.
  4. запустить проход по циклу полученных пользовательских элементов:
    1. на каждый навешивается Intersection Observer,
    2. при попадании первого пользовательского элемента во вьюпорт +- 100px произвести загрузку ресурсов через динамический import.
    1. ИЛИ повторяется с пункта 3 только для пользовательского элемента и его shadowDOM,
    2. ИЛИ компоненты, содержащие в shadowDOM другие компоненты, декларативно реализуют подгрузку необходимых зависимостей, указав import в голове JS.


Для более удобной работы с веб-компонентами и lit-element я бы предложил обратить внимание на проект open-wc.org. Там предложены генераторы для сборщиков на основе webpack и rollup, туллинг для тестирования веб-компонентов и их демонстрации с помощью storybook, а также советы и рекомендации по разработке и настройки IDE.


Дополнительные ссылки:


  1. Let's Build Web Components! Part 5: LitElement
  2. Web Component Essentials
  3. A night experimenting with Lit-HTML…
  4. LitElement To Do App
  5. LitElement app tutorial part 1: Getting started
  6. LitElement tutorial part 2: Templating, properties, and events
  7. LitElement tutorial part 3: State management with Redux
  8. LitElement tutorial part 4: Navigation and code splitting
  9. LitElement tutorial part 5: PWA and offline
  10. Lit-html workshop
  11. Awesome lit-html
Tags:
Hubs:
+23
Comments 17
Comments Comments 17

Articles