Хабрахабр

Веб-компоненты в реальном мире

Photo by NeONBRAND

Компонентый подход создания интерфейсов хорошо закрепился во фронтенд-фреймворках, и кажется хорошей идеей встроить эту функциональность нативно в браузеры. Веб-компоненты – это общее название набора технологий, призванных помочь веб-разработчикам создавать переиспользуемые блоки. Поддержка этой технологии браузерами уже достигла достаточного уровня, чтобы можно было всерьез задуматься об использовании этой технологии для своих рабочих проектов.

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

Что такое веб-компоненты

Хорошее описание технологии есть на MDN. Для начала нужно определиться, что именно входит в понятие веб-компонентов. Если совсем коротко, то обычно в это понятие включают следующие возможности:

  • Custom elements – возможность регистрировать свои html-тэги с определенным поведением
  • Shadow DOM – создание изолированного контекста CSS
  • Slots – возможность комбинировать внешний html-контент со внутренним html компонента

В качестве примера напишем hello-world компонент, который будет приветствовать пользователя по имени:

// веб-компоненты должны наследоваться от стандартных html-элементов
class HelloWorld extends HTMLElement ); } 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>, на его месте отобразится приветствие.

Посмотреть этот код в действии можно здесь.

Вам все равно понадобятся фреймворки

Однако, это не так. Распространено мнение, что внедрение веб-компонентов сделает фреймворки ненужными, потому что встроенной функциональности будет достаточно для создания интерфейсов. В браузеры не встроено ничего похожего на VDOM – подхода к описанию интерфейсов, когда разработчик просто описывает желаемый html, а фреймворк сам позаботится об обновлении DOM–элементов, которые действительно изменились по сравнению с прошлым состоянием. Кастомные html-тэги действительно напоминают Vue или React компоненты, но этого недостаточно, чтобы заменить их целиком. Этот подход существенно упрощает работу с большими и сложными компонентами, так что без VDOM придется тяжеловато.

Этот код будет повторяться в каждом создаваемом компоненте, так что имеет смысл вынести его в базовый класс – и вот у нас уже есть зачатки фреймворка! Кроме того, в предыдущем разделе с примером компонента вы могли заметить, что нам пришлось написать какое-то количество кода для регистрации компонента и активации Shadow DOM. Для более сложных компонентов нам понадобятся еще подписка на изменения атрибутов, удобные шаблоны, работа с событиями и т.д.

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

Однако lit-element, который использует веб-компоненты весит в лучшем случае 6 кб, в то время как есть preact, который использует свои компоненты, похожие на React, но при этом весит в 2 раза меньше, 3 кб. Еще часто говорят о пользе веб-компонентов в виде уменьшения размера загружаемого Javascript. Таким образом, размер кода и использование веб-компонентов вещи ортогональные и одно другому никак не противоречит.

Shadow DOM и производительность

Здесь на помощь приходит Shadow DOM. Для стилизации больших html-страниц может понадобится много CSS, и придумывать уникальные имена классам может оказаться сложно. Таким образом, можно отрендерить компонент со своими стилями, которые не будут пересекаться с другими стилями на странице. Эта технология позволяет создавать области изолированного CSS. Создается Shadow DOM вызовом метода this.attachShadow(), а затем мы должны добавить внутрь Shadow DOM наши стили, либо тэгом <style></style> либо через <link rel="stylesheet">. Даже если у вас будет имя класса, совпадающее с чем-то еще, стили не смешаются, если каждый из них будет жить в своем Shadow DOM.

Вот это демо показывает, насколько именно. Таким образом, каждый экземпляр компонента получает свою копию CSS, что очевидно должно сказаться на производительности. Возможно, в будущем производители браузеров улучшат производительность, но в настоящее время лучше отказаться от мелких веб-компонентов и постараться делать компоненты типа <my-list items="myItems"> вместо отдельных <my-item item="item">. Если рендер обычных элементов без Shadow DOM занимает порядка 30мс, то с Shadow DOM это около 50мс.

Также стоит заметить, что у альтернативых подходов, вроде CSS-модулей, таких проблем нет, поскольку там все происходит на этапе сборки, и в браузер поступает обычный CSS.

Глобальные имена компонентов

Проблема в том, что имена компонентов объявляются глобально, то есть если кто-то уже занял имя my-button, вы ничего не сможете с этим сделать. Каждый веб-компонент привязывается к своему имени тега с помощью customElements.define. Конечно, от этого можно защититься конвенцией именования с использованием префиксов, но такой подход сильно похож на проблемы с именами CSS-классов, избавление от которых нам обещали веб-компоненты. В маленьких проектах, где все имена компонентов контролируются вами, это не представляет особой проблемы, но если вы используете стороннюю библиотеку, то все может внезапно сломаться, когда вы они добавят новый компонент с тем же именем, что вы уже использовали сами.

Tree-shaking

Например, в React любой используемый компонент должен быть импортирован в модуль Из глобального регистра компонентов следует еще одна проблема – у вас нет четкой связи между местом регистрации компонента и его использованием.

import { Button } from "./button"; //... render() { return <Button>Click me!</Button>
}

Если удалить импорт, то у нас произойдет ошибка в рендеринге. Мы явным образом импортируем компонент Button. Аналогичный пример с кнопкой на lit-element будет выглядеть вот так: С веб-компонентами ситуация другая, мы просто рендерим html-тэги, а они магическим образом оживают.

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-плагины.

Проблемы с типизацией

В больших проектах разработчики предпочитают типизацию, добавляя ее с помощью Typescript или Flow. Javascript по своей природе динамический язык, и это нравится не всем. Эти технологии прекрасно интегрируются с современными фреймворками типа React, проверяя корректность вызова компонентов:

class Button extends Component<{ text: string }> {} <Button /> // ошибка: отсутствует обязательное поле text
<Button text="Click me" action="test" /> // ошибка: лишнее поле action <Button text="Click me" /> // все как надо, ошибок нет

В предыдущем разделе рассказывалось, что место определения веб-компонента статически никак не связано с его использованием, и по этой же причине Typescript не сможет вывести допустимые значения для веб-компонента. С веб-компонентами так не получится. IntrinsicElements – специальный интерфейс, откуда Typescript берет информацию для нативных тегов. Здесь на помощь может прийти JSX. Мы можем добавить туда определение для нашей кнопки

namespace JSX { interface IntrinsicElements { 'paper-button': { raised: boolean; disabled: boolean; children: string } }
}

Если в компонент добавят новые свойства, в JSX определение его будет нужно добавлять вручную. Теперь Typescript будет знать о типах нашего веб-компонента, но они никак не связаны с его исходниками. Там придется самим кастовать значение к нужному типу: Кроме того, эта декларация никак не помогает нам при работе с элементом через 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 это можно ускорить путем пре-рендера страницы на сервере, то в случае веб-компонентов таких возможностей нет.
  • Инкапсулировать части своего кода в веб-компоненты тоже смысла нет. Вы получите проблемы с производительностью, отсутствие типов и никаких особых преимуществ взамен.

Буду рад услышать что вы об этом думаете. Надеюсь эта информация окажется вам полезной при выборе технологического стека в этом году.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть