Веб-компоненты избавляют от привязки к JavaScript-фреймворку¶
В последнее время мы видели много замечательных постов о веб-компонентах. Многие из них посвящены развивающемуся шаблону веб-компонентов HTML, который отказывается от теневого DOM в пользу постепенного улучшения существующей разметки. Также обсуждался вопрос о полной замене JavaScript-фреймворков веб-компонентами, в том числе и в этом посте.
Однако это не единственные варианты. Вы также можете использовать веб-компоненты в тандеме с фреймворками JavaScript. Для этого я хочу рассказать о ключевом преимуществе, о котором я не так часто упоминал: веб-компоненты могут значительно ослабить связь с JavaScript-фреймворками.
Чтобы доказать это, мы сделаем нечто безумное: создадим приложение, в котором каждый компонент будет написан с использованием другого фреймворка.
Наверное, само собой разумеется, что вы не должны создавать реальное приложение подобным образом! Но есть веские причины для смешивания фреймворков. Может быть, вы постепенно переходите с React на Vue. Может быть, ваше приложение построено на Solid, но вы хотите использовать стороннюю библиотеку, которая существует только в виде компонента Angular. Может быть, вы хотите использовать Svelte для нескольких "островков интерактивности" на статичном сайте.
Вот что мы собираемся создать: простое небольшое приложение, основанное на TodoMVC.
По мере создания мы увидим, как веб-компоненты могут инкапсулировать JavaScript-фреймворки, позволяя нам использовать их, не накладывая более широких ограничений на остальные части приложения.
Что такое веб-компонент?¶
Если вы не знакомы с веб-компонентами, вот краткое руководство по их работе.
Сначала мы объявляем подкласс HTMLElement
в JavaScript. Назовем его MyComponent
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Вызов attachShadow
в конструкторе заставляет наш компонент использовать теневой DOM, который инкапсулирует разметку и стили внутри нашего компонента от остальной части страницы. connectedCallback
вызывается, когда веб-компонент действительно подключается к дереву DOM, рендерингуя содержимое HTML в "теневой корень" компонента.
Это предвещает, как мы заставим наши фреймворки работать с веб-компонентами 1. Обычно мы "прикрепляем" фреймворки к элементу DOM и позволяем им управлять всеми потомками этого элемента. С веб-компонентами мы можем прикрепить фреймворк к теневому корню, что гарантирует, что он сможет получить доступ только к "теневому дереву" компонента.
Далее мы определяем пользовательское имя элемента для нашего класса MyComponent
:
1 |
|
Всякий раз, когда на странице появляется тег с таким именем пользовательского элемента, соответствующий узел DOM на самом деле является экземпляром MyComponent
!
1 2 3 4 5 6 |
|
Посмотрите:
Веб-компоненты — это еще не все, но этого вполне достаточно, чтобы дочитать статью до конца.
Макет экрана¶
Точкой входа в наше приложение будет компонент React 2. Вот наше скромное начало:
1 2 3 4 |
|
Мы могли бы начать добавлять сюда элементы, чтобы заблокировать базовую структуру DOM, но я хочу написать еще один компонент для этого, чтобы показать, как мы можем вложить веб-компоненты таким же образом, как мы вкладываем компоненты фреймворка.
Большинство фреймворков поддерживают композицию с помощью вложенности, как обычные HTML-элементы. Со стороны это обычно выглядит примерно так:
1 2 3 |
|
Внутри фреймворки решают эту проблему несколькими способами. Например, React и Solid предоставляют вам доступ к дочерним элементам в виде специального свойства children
:
1 2 3 |
|
С веб-компонентами, использующими теневой DOM, мы можем сделать то же самое с помощью элемента <slot>
. Когда браузер встречает <slot>
, он заменяет его на дочерние элементы веб-компонента.
<slot>
на самом деле мощнее, чем дочерние элементы children
React или Solid. Если мы дадим каждому слоту атрибут name
, веб-компонент может иметь несколько <slot>
, и мы можем определить, куда попадает каждый вложенный элемент, задав ему атрибут slot
, соответствующий имени <slot>
.
Давайте посмотрим, как это выглядит на практике. Мы напишем наш компонент разметки с помощью Solid:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
|
В нашем веб-компоненте Solid есть две части: обертка веб-компонента в верхней части и собственно компонент Solid в нижней части.
Самое важное, что нужно заметить в компоненте Solid, — это то, что мы используем именованные <slot>
вместо реквизита children
. В то время как children
обрабатывается Solid и позволяет нам вложить другие компоненты Solid, <slot>
обрабатывается самим браузером и позволяет нам вложить любой элемент HTML — включая веб-компоненты, написанные с помощью других фреймворков!
Обертка веб-компонента довольно похожа на пример выше. В конструкторе он создает теневой корень, а затем в методе connectedCallback
рендерит в него компонент Solid.
Обратите внимание, что это не полная реализация обертки для веб-компонента! По крайней мере, мы, вероятно, захотим определить метод attributeChangedCallback
, чтобы мы могли перерисовывать компонент Solid при изменении атрибутов. Если вы используете это в производстве, вам, вероятно, стоит воспользоваться пакетом Solid под названием Solid Element, который сделает все это за вас.
Вернувшись в наше приложение React, мы можем использовать наш компонент TodoLayout
:
1 2 3 4 5 6 7 8 |
|
Обратите внимание, что нам не нужно ничего импортировать из TodoLayout.jsx
— мы просто используем тег пользовательского элемента, который мы определили.
Проверьте это:
Это React-компонент, отображающий компонент Solid, который принимает вложенный React-элемент в качестве дочернего.
Добавление Todos¶
Для ввода тодо мы еще немного раздвинем луковицу и напишем его вообще без фреймворка!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
|
Между этим примером, веб-компонентом и нашим макетом Solid вы, вероятно, заметили закономерность: прикрепляем корень тени, а затем отображаем HTML внутри него. Независимо от того, пишем ли мы HTML вручную или используем фреймворк для его генерации, процесс примерно одинаков.
Здесь мы используем пользовательское событие для связи с родительским компонентом. Когда форма будет отправлена, мы отправим событие add
с вводимым текстом.
Очереди событий часто используются для разделения связи между компонентами программной системы. Браузеры в значительной степени опираются на события, а пользовательские события, в частности, являются важным инструментом в инструментарии веб-компонентов — особенно потому, что пользовательский элемент выступает в качестве естественной шины событий, доступ к которой можно получить извне веб-компонента.
Прежде чем мы продолжим добавлять компоненты, нам нужно понять, как обрабатывать наше состояние. Пока что мы просто сохраним его в нашем компоненте React TodoApp
. Хотя со временем мы перерастем useState
, это отличное место для начала.
У каждого тодо будет три свойства: id
, текстовая строка text
, описывающая его, и булево значение done
, указывающее, был ли он завершен.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
Мы будем хранить массив наших дел в состоянии React. Когда мы добавляем тодо, мы добавляем его в этот массив.
Единственная неудобная часть этого — функция inputRef
. Наш <todo-input>
испускает пользовательское событие add
при отправке формы. Обычно в React мы подключаем слушателей событий с помощью реквизитов вроде onClick
— но это работает только для событий, о которых React уже знает. Нам нужно прослушивать события add
напрямую 3.
В React Land мы используем рефссылки для прямого взаимодействия с DOM. Чаще всего мы используем их с помощью хука useRef
, но это не единственный способ! Реквизит ref
на самом деле является просто функцией, которая вызывается с помощью узла DOM. Вместо того чтобы передавать в этот реквизит ссылку, возвращаемую хуком useRef
, мы можем передать функцию, которая прикрепляет слушатель событий непосредственно к узлу DOM.
Вы можете задаться вопросом, почему мы должны обернуть функцию в useCallback
. Ответ кроется в старой документации React по рефам (и, насколько я могу судить, не был перенесен в новую документацию):
Если обратный вызов ref
определен как встроенная функция, то при обновлении она будет вызвана дважды, сначала с null
, а затем снова с элементом DOM. Это происходит потому, что при каждом рендере создается новый экземпляр функции, поэтому React нужно очистить старую ссылку и установить новую. Вы можете избежать этого, определив обратный вызов ref
как связанный метод класса, но учтите, что в большинстве случаев это не имеет значения.
В данном случае это имеет значение, поскольку мы не хотим подключать слушатель событий заново при каждом рендере. Поэтому мы обернули его в useCallback
, чтобы гарантировать, что каждый раз мы передаем один и тот же экземпляр функции.
Элементы Todo¶
Пока что мы можем добавлять задания, но не видеть их. Следующим шагом будет написание компонента, который будет показывать каждый элемент Todo. Мы напишем этот компонент с помощью Svelte.
Svelte поддерживает пользовательские элементы из коробки. Вместо того чтобы продолжать каждый раз показывать один и тот же шаблон обертки веб-компонента, мы просто воспользуемся этой возможностью!
Вот код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
|
В Svelte тег <script>
не выводится в DOM в буквальном смысле — вместо этого код запускается при инстанцировании компонента. Наш компонент Svelte принимает три реквизита: id
, text
и done
. Он также создает пользовательский диспетчер событий, который может отправлять события на пользовательский элемент.
Синтаксис $:
объявляет реактивный блок. Это означает, что при изменении значений id
или done
он будет отправлять событие check
с новыми значениями. id
, вероятно, не изменится, так что на практике это означает, что он будет отправлять событие check
всякий раз, когда мы проверяем или снимаем отметку с todo.
Вернувшись в наш React-компонент, мы перебираем наши todos и используем наш новый компонент <todo-item>
. Нам также нужна еще пара служебных функций для удаления и проверки тодо, а также еще один обратный вызов для прикрепления слушателей событий к каждому <todo-item>
.
Вот код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
|
Теперь в списке отображаются все наши дела! И когда мы добавляем новое дело, оно появляется в списке!
Фильтрация Todos¶
Последняя функция, которую нужно добавить, — это возможность фильтровать тодо.
Но прежде чем мы добавим ее, нам нужно провести небольшой рефакторинг.
Я хочу показать еще один способ, с помощью которого веб-компоненты могут взаимодействовать друг с другом: использование общего хранилища. Многие фреймворки, которые мы используем, имеют свои собственные реализации хранилищ, но нам нужно такое хранилище, которое мы могли бы использовать со всеми ними. Для этого мы будем использовать библиотеку под названием Nano Stores.
Сначала мы создадим новый файл store.js
с нашим состоянием todo, переписанным с помощью Nano Stores:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
Основная логика осталась прежней; большинство изменений — это просто перенос из API useState
в API Nano Stores. Мы добавили два новых вычисляемых хранилища, $done
и $left
, которые являются "производными" от хранилища $todos
и возвращают завершенные и незавершенные дела, соответственно. Мы также добавили новое хранилище, $filter
, которое будет хранить текущее значение фильтра.
Мы напишем наш компонент фильтра с помощью Vue.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
|
Синтаксис довольно похож на синтаксис Svelte: тег <script>
в верхней части запускается при инстанцировании компонента, а тег <template>
содержит разметку компонента.
Vue не делает компиляцию компонента в пользовательский элемент такой же простой, как Svelte. Нам нужно создать еще один файл, импортировать компонент Vue и вызвать для него defineCustomElement
:
1 2 3 4 5 6 7 8 9 |
|
Вернувшись в React Land, мы рефакторим наш компонент, чтобы использовать Nano Stores, а не useState
, и добавим компонент <todo-filters>
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
|
Мы сделали это! Теперь у нас есть полнофункциональное приложение todo, написанное с помощью четырех различных фреймворков — React, Solid, Svelte и Vue — плюс компонент, написанный на ванильном JavaScript.
Движение вперед¶
Суть этой статьи не в том, чтобы убедить вас в том, что это хороший способ написания веб-приложений. Мы хотим показать, что существуют способы создания веб-приложений, отличные от написания всего на одном JavaScript-фреймворке, и, более того, веб-компоненты значительно облегчают эту задачу.
Вы можете постепенно улучшать статический HTML. Можно создавать насыщенные интерактивные "острова" JavaScript, которые естественным образом взаимодействуют с библиотеками гипермедиа, такими как HTMX. Вы даже можете обернуть веб-компонент вокруг компонента фреймворка и использовать его с любым другим фреймворком.
Веб-компоненты радикально ослабляют связь между фреймворками JavaScript, предоставляя общий интерфейс, который могут использовать все фреймворки. С точки зрения потребителя, веб-компоненты — это просто HTML-теги — неважно, что происходит "под капотом".
Если вы хотите поиграть с этим сами, я создал CodeSandbox с нашим примером приложения todo.
Список литературы¶
Если вам интересно, вот несколько хороших статей, которые еще глубже погружаются в тему:
- Крис Фердинанди написал о том, как обернуть свою собственную библиотеку пользовательского интерфейса Reef веб-компонентом в статье Reactive Web Components and DOM Diffing.
- Андрико Карулла написал отличный обзор о том, как писать компоненты, не зависящие от фреймворка, под названием Writing Components That Work in Any Framework.
- Томас Уилберн показывает, как использовать веб-компоненты для создания "языков" внутри HTML в статье Chiaroscuro, или Выразительные деревья в веб-компонентах.
- Макси Феррейра написал замечательную статью под названием Sharing State with Islands Architecture, в которой подробно рассказывает о пользовательских событиях и хранилищах.
- В официальной документации Astro есть страница, посвященная обмену состоянием между островами с помощью Nano Stores.
- Хотя в ней нет прямого упоминания о веб-компонентах, в эссе HTMX о дружественных гипермедиа сценариях события и острова рассматриваются как способы взаимодействия клиентских сценариев с веб-приложениями, управляемыми гипермедиа.
Источник¶
-
Понимаете? Теневые деревья? Теневые элементы? Как теневой DOM? ↩
-
Технически, мы используем Preact в режиме совместимости, потому что я не смог понять, как заставить работать предустановку React от Vite. Оказывается, инструменты для сборки становятся сложными, когда вы пытаетесь использовать четыре разных фреймворка в одной кодовой базе! ↩
-
В других фреймворках этот процесс проще. Например, в Svelte мы можем использовать директиву
on:
для прослушивания произвольных событий, исходящих от любого HTML-элемента, включая веб-компоненты. ↩