Photo by NeONBRAND
Веб-компоненты – это общее название набора технологий, призванных помочь веб-разработчикам создавать переиспользуемые блоки. Компонентый подход создания интерфейсов хорошо закрепился во фронтенд-фреймворках, и кажется хорошей идеей встроить эту функциональность нативно в браузеры. Поддержка этой технологии браузерами уже достигла достаточного уровня, чтобы можно было всерьез задуматься об использовании этой технологии для своих рабочих проектов.
В этой статье мы посмотрим на особенности использования веб-компонентов, о которых почему-то не говорят евангелисты этих технологий.
Что такое веб-компоненты
Для начала нужно определиться, что именно входит в понятие веб-компонентов. Хорошее описание технологии есть на MDN. Если совсем коротко, то обычно в это понятие включают следующие возможности:
- Custom elements – возможность регистрировать свои html-тэги с определенным поведением
- Shadow DOM – создание изолированного контекста CSS
- Slots – возможность комбинировать внешний html-контент со внутренним html компонента
В качестве примера напишем hello-world
компонент, который будет приветствовать пользователя по имени:
// веб-компоненты должны наследоваться от стандартных html-элементов
class HelloWorld extends HTMLElement {
constructor() {
super();
// создадим Shadow DOM
this.attachShadow({ mode: "open" });
}
connectedCallback() {
const name = this.getAttribute("name");
// Отрендерим наш контент внутрь Shadow DOM
this.shadowRoot.innerHTML = `Hello, <strong>${name}</strong> :)`;
}
}
// зарегистрируем наш компонент как html-тэг
window.customElements.define("hello-world", HelloWorld);
Таким образом, каждый раз, когда на странице будет размещен тэг <hello-world name="%username%"></hello-world>
, на его месте отобразится приветствие. Очень удобно!
Посмотреть этот код в действии можно здесь.
Вам все равно понадобятся фреймворки
Распространено мнение, что внедрение веб-компонентов сделает фреймворки ненужными, потому что встроенной функциональности будет достаточно для создания интерфейсов. Однако, это не так. Кастомные html-тэги действительно напоминают Vue или React компоненты, но этого недостаточно, чтобы заменить их целиком. В браузеры не встроено ничего похожего на VDOM – подхода к описанию интерфейсов, когда разработчик просто описывает желаемый html, а фреймворк сам позаботится об обновлении DOM–элементов, которые действительно изменились по сравнению с прошлым состоянием. Этот подход существенно упрощает работу с большими и сложными компонентами, так что без VDOM придется тяжеловато.
Кроме того, в предыдущем разделе с примером компонента вы могли заметить, что нам пришлось написать какое-то количество кода для регистрации компонента и активации Shadow DOM. Этот код будет повторяться в каждом создаваемом компоненте, так что имеет смысл вынести его в базовый класс – и вот у нас уже есть зачатки фреймворка! Для более сложных компонентов нам понадобятся еще подписка на изменения атрибутов, удобные шаблоны, работа с событиями и т.д.
На самом деле фреймворки, основанные на веб-компонентах, уже существуют, например lit-element. В lit-element уже есть встроенный VDOM, lit-html, и другие базовые возможности. Написание компонентов таким образом гораздо удобнее, чем через нативное API.
Еще часто говорят о пользе веб-компонентов в виде уменьшения размера загружаемого Javascript. Однако lit-element, который использует веб-компоненты весит в лучшем случае 6 кб, в то время как есть preact, который использует свои компоненты, похожие на React, но при этом весит в 2 раза меньше, 3 кб. Таким образом, размер кода и использование веб-компонентов вещи ортогональные и одно другому никак не противоречит.
Shadow DOM и производительность
Для стилизации больших html-страниц может понадобится много CSS, и придумывать уникальные имена классам может оказаться сложно. Здесь на помощь приходит Shadow DOM. Эта технология позволяет создавать области изолированного CSS. Таким образом, можно отрендерить компонент со своими стилями, которые не будут пересекаться с другими стилями на странице. Даже если у вас будет имя класса, совпадающее с чем-то еще, стили не смешаются, если каждый из них будет жить в своем Shadow DOM. Создается Shadow DOM вызовом метода this.attachShadow()
, а затем мы должны добавить внутрь Shadow DOM наши стили, либо тэгом <style></style>
либо через <link rel="stylesheet">
.
Таким образом, каждый экземпляр компонента получает свою копию CSS, что очевидно должно сказаться на производительности. Вот это демо показывает, насколько именно. Если рендер обычных элементов без Shadow DOM занимает порядка 30мс, то с Shadow DOM это около 50мс. Возможно, в будущем производители браузеров улучшат производительность, но в настоящее время лучше отказаться от мелких веб-компонентов и постараться делать компоненты типа <my-list items="myItems">
вместо отдельных <my-item item="item">
.
Также стоит заметить, что у альтернативых подходов, вроде CSS-модулей, таких проблем нет, поскольку там все происходит на этапе сборки, и в браузер поступает обычный CSS.
Глобальные имена компонентов
Каждый веб-компонент привязывается к своему имени тега с помощью customElements.define
. Проблема в том, что имена компонентов объявляются глобально, то есть если кто-то уже занял имя my-button
, вы ничего не сможете с этим сделать. В маленьких проектах, где все имена компонентов контролируются вами, это не представляет особой проблемы, но если вы используете стороннюю библиотеку, то все может внезапно сломаться, когда вы они добавят новый компонент с тем же именем, что вы уже использовали сами. Конечно, от этого можно защититься конвенцией именования с использованием префиксов, но такой подход сильно похож на проблемы с именами CSS-классов, избавление от которых нам обещали веб-компоненты.
Tree-shaking
Из глобального регистра компонентов следует еще одна проблема – у вас нет четкой связи между местом регистрации компонента и его использованием. Например, в React любой используемый компонент должен быть импортирован в модуль
import { Button } from "./button";
//...
render() {
return <Button>Click me!</Button>
}
Мы явным образом импортируем компонент Button. Если удалить импорт, то у нас произойдет ошибка в рендеринге. С веб-компонентами ситуация другая, мы просто рендерим html-тэги, а они магическим образом оживают. Аналогичный пример с кнопкой на lit-element будет выглядеть вот так:
import '@polymer/paper-button/paper-button.js';
// ...
render() {
return html`<paper-button>Click me!</paper-button>`;
}
Никакой связи между импортом и использованием нет. Если мы удалим импорт, но он останется в каком-то другом файле, то кнопка продолжит работать. Если импорт внезапно пропадет и из другого файла тоже, то только тогда у нас что-то сломается и это будет очень внезапно.
Отсутствие явной связи и между импортом и использованием не позволяет делать tree-shaking вашего кода, автоматическое удаление неиспользуемых импортов. Например, если мы импортируем несколько компонентов, но используем не все, они будут автоматически удалены:
import { Button, Icon } from './components';
//...
render() {
return <Button>Click me!</Button>
}
Icon в этом файле не используется и будет спокойно удален. В ситуации с веб-компонентами этот номер не пройдет, потому что бандлер не в состоянии отследить эту связь. Ситуация очень напоминает 2010 год, когда мы вручную подключали в шапке сайта необходимые нам jquery-плагины.
Проблемы с типизацией
Javascript по своей природе динамический язык, и это нравится не всем. В больших проектах разработчики предпочитают типизацию, добавляя ее с помощью Typescript или Flow. Эти технологии прекрасно интегрируются с современными фреймворками типа React, проверяя корректность вызова компонентов:
class Button extends Component<{ text: string }> {}
<Button /> // ошибка: отсутствует обязательное поле text
<Button text="Click me" action="test" /> // ошибка: лишнее поле action
<Button text="Click me" /> // все как надо, ошибок нет
С веб-компонентами так не получится. В предыдущем разделе рассказывалось, что место определения веб-компонента статически никак не связано с его использованием, и по этой же причине Typescript не сможет вывести допустимые значения для веб-компонента. Здесь на помощь может прийти JSX.IntrinsicElements
– специальный интерфейс, откуда Typescript берет информацию для нативных тегов. Мы можем добавить туда определение для нашей кнопки
namespace JSX {
interface IntrinsicElements {
'paper-button': {
raised: boolean;
disabled: boolean;
children: string
}
}
}
Теперь Typescript будет знать о типах нашего веб-компонента, но они никак не связаны с его исходниками. Если в компонент добавят новые свойства, в JSX определение его будет нужно добавлять вручную. Кроме того, эта декларация никак не помогает нам при работе с элементом через querySelector
. Там придется самим кастовать значение к нужному типу:
const component = document.querySelector('paper-button') as PaperButton;
Возможно, по мере распространения стандарта, в Typescript придумают способ статически типизировать веб-компоненты, но пока при использовании веб-компонентов придется попрощаться с типобезопасностью.
Групповое обновление свойств
Нативные браузерные компоненты, такие как <input>
или <button>
, принимают значения в виде текстовых атрибутов. Однако, иногда может понадобиться передавать более сложные данные в наши компоненты, объекты, например. Для этого предлагается использовать свойства с геттерами и сеттерами.
// находим наш компонент в DOM
const component = document.querySelector("users-list");
// передаем в него данные
component.items = myData;
На стороне компонента мы определяем сеттер, который эти данные обработает:
class UsersList extends HTMLElement {
set items(items) {
// сохраняем значение
this.__items = items;
// перерисовываем компонент
this.__render();
}
}
В lit-element для этого есть удобный декоратор – property:
class UsersList extends HTMLElement {
@property()
users: User[];
}
Однако, может случиться ситуация, что нам нужно обновить несколько свойств сразу:
const component = document.querySelector("users-list");
component.expanded = true;
component.items = myData;
component.selectedIndex = 3;
Каждый сеттер вызывает рендеринг, ведь он не знает, что там будут обновлены и другие свойства. В результате у нас будут два лишних обновления, с которыми нужно что-то делать. Стандарт ничего готового не предоставляет, поэтому разработчикам нужно выкручиваться самим. В lit-element это решают асинхронным рендерингом, то есть сеттер не вызывает обновление напрямую, а оставляет запрос на отложенный рендеринг, что-то вроде setTimeout(() => this.__render(), 0)
. Такой подход позволяет избавиться от лишних перерисовок, но усложняет работу с компонентом, например его тестирование:
component.items = [{ id: 1, name: "test" }];
// не сработает, рендер еще не произошел
// expect(component.querySelectorAll(".item")).toHaveLength(1);
await delay(); // нужно подождать пока обновление применится
expect(component.querySelectorAll(".item")).toHaveLength(1);
Таким образом, реализация правильного обновления компонента это еще один аргумент за использование фреймворка вместо работы с веб-компонентами напрямую.
Выводы
После прочтения этой статьи может показаться, что веб-компоненты плохие и у них нет будущего. Это не совсем так, они могут пригодится в некоторых сценариях использования:
- Встраивание клиенткой логики в большой сервер-рендерный проект. По такому пути сейчас идет Github. Они активно используют веб-компоненты для своего интерфейса и даже опубликовали в open-source некоторые из них. В ситуации, когда у вас большая часть страницы статическая или рендерится сервером, веб-компоненты помогут придать интерактивности некоторым частям.
- Реализация микро-фронтендов. На странице рендерятся независимые виджеты, которые могут быть написаны на совсем разных фреймворках и разными командами, но им надо как-то уживаться вместе. При этом они вываливают свой CSS в глобальную область и всячески мешают друг другу. Для борьбы с этим у нас раньше были только iframe, теперь же мы можем завернуть отдельные микро-фронтенды в Shadow DOM, чтобы они жили там своей жизнью.
Есть также и вещи, которые я бы на веб-компонентах делать не стал:
- UI-библиотека получится неудобной, по причине проблем с tree-shaking и типами, которые раскрыты в этой статье. Написание UI-компонентов (кнопок, инпутов и т.д.) на том же фреймворке, что и основная часть страницы (React, Vue и пр.) позволит им лучше взаимодествовать с основной частью страницы.
- Для основонго контента страницы веб-компоненты не подойдут. С точки зрения пользователя, рендеринг страницы с единственным веб-компонентом
<my-app />
ничем не отличается от использования SPA-фреймворка. Пользователь будет вынужден ждать пока прогрузится весь Javascript, чтобы наконец-то увидеть контент. И если в случае Angular/React/Vue это можно ускорить путем пре-рендера страницы на сервере, то в случае веб-компонентов таких возможностей нет. - Инкапсулировать части своего кода в веб-компоненты тоже смысла нет. Вы получите проблемы с производительностью, отсутствие типов и никаких особых преимуществ взамен.
Надеюсь эта информация окажется вам полезной при выборе технологического стека в этом году. Буду рад услышать что вы об этом думаете.
Автор: Борис Сердюк