Vue и веб-компоненты
Веб-компоненты это общий термин для набора нативных веб-интерфейсов, позволяющих разработчикам создавать многократно используемые пользовательские элементы.
Мы считаем Vue и веб-компоненты в первую очередь взаимодополняющими технологиями. Vue отлично поддерживает как потребление, так и создание пользовательских элементов. Вне зависимости от того, интегрируете ли вы пользовательские элементы в существующее приложение Vue или используете Vue для создания и распространения пользовательских элементов, вы находитесь в хорошей компании.
Использование пользовательских элементов во Vue
Vue набирает 100% в тестах Custom Elements Everywhere. Потребление пользовательских элементов в приложении Vue в основном работает так же, как и использование нативных HTML-элементов, но есть несколько моментов, о которых следует помнить:
Пропуск разрешения компонентов
По умолчанию Vue будет пытаться разрешить неродной HTML-тег как зарегистрированный компонент Vue, прежде чем вернуться к его отрисовке как пользовательского элемента. Это приведёт к тому, что Vue выдаст предупреждение «failed to resolve component» во время разработки. Чтобы сообщить Vue, что определённые элементы должны рассматриваться как пользовательские и пропускать разрешение компонентов, мы можем указать свойство compilerOptions.isCustomElement
.
Если вы используете Vue с настройками сборки, опция должна передаваться через конфигурацию сборки, поскольку это опция времени компиляции.
Пример конфигурации в браузере
js
// Работает только при использовании компиляции в браузере.
// Если вы используете инструменты сборки, смотрите примеры конфигурации ниже.
app.config.compilerOptions.isCustomElement = (tag) => tag.includes('-')
Пример конфигурации Vite
js
// vite.config.js
import vue from '@vitejs/plugin-vue'
export default {
plugins: [
vue({
template: {
compilerOptions: {
// Рассматривать все теги с тире как пользовательские элементы
isCustomElement: (tag) => tag.includes('-')
}
}
})
]
}
Пример конфигурации Vue CLI
js
// vue.config.js
module.exports = {
chainWebpack: (config) => {
config.module
.rule('vue')
.use('vue-loader')
.tap((options) => ({
...options,
compilerOptions: {
// Рассматривать все теги, начинающиеся с `ion-`, как пользовательские элементы
isCustomElement: (tag) => tag.startsWith('ion-')
}
}))
}
}
Передача свойств DOM
Поскольку атрибуты DOM могут быть только строками, нам нужно передавать сложные данные в пользовательские элементы как свойства DOM. При установке параметров для пользовательского элемента Vue 3 автоматически проверяет наличие DOM-свойства с помощью оператора in
и предпочитает устанавливать значение как DOM-свойство, если ключ присутствует. Это означает, что в большинстве случаев вам не придется думать об этом, если пользовательский элемент соответствует рекомендуемым лучшим практикам.
Однако могут быть редкие случаи, когда данные должны быть переданы как свойство DOM, но пользовательский элемент не определяет/отражает свойство должным образом (что приводит к неудачной проверке in
). В этом случае вы можете заставить привязку v-bind
быть установленной в качестве свойства DOM с помощью модификатора .prop
:
template
<my-element :user.prop="{ name: 'jack' }"></my-element>
<!-- сокращённый эквивалент -->
<my-element .user="{ name: 'jack' }"></my-element>
Создание пользовательских элементов с помощью Vue
Главное преимущество пользовательских элементов в том, что их можно использовать с любым фреймворком или даже без него. Это делает их идеальными для распространения компонентов, где конечный потребитель может не использовать тот же стек фронтендов, или когда вы хотите изолировать конечное приложение от деталей реализации компонентов, которые оно использует.
defineCustomElement
Vue поддерживает создание пользовательских элементов, используя точно такие же API компонентов Vue, с помощью метода defineCustomElement
. Метод принимает тот же аргумент, что и defineComponent
, но вместо него возвращает пользовательский конструктор элемента, который расширяет HTMLElement
:
template
<my-vue-element></my-vue-element>
js
import { defineCustomElement } from 'vue'
const MyVueElement = defineCustomElement({
// обычные опции компонентов Vue здесь
props: {},
emits: {},
template: `...`,
// только defineCustomElement: CSS будет внедрен в теневой корневой узел
styles: [`/* встроенный CSS */`]
})
// Регистрируем пользовательский элемент.
// После регистрации все теги `<my-vue-element>`
// на странице будут обновлены.
customElements.define('my-vue-element', MyVueElement)
// Вы также можете программно создать элемент:
// (можно сделать только после регистрации)
document.body.appendChild(
new MyVueElement({
// начальные параметры (необязательно)
})
)
Жизненный цикл
Пользовательский элемент Vue монтирует внутренний экземпляр компонента Vue внутри своего теневого корневого узла при первом вызове
connectedCallback
элемента.Когда вызывается
disconnectedCallback
элемента, Vue проверяет, отсоединен ли элемент от документа после тика микрозадачи.Если элемент всё ещё находится в документе, это перемещение, и экземпляр компонента будет сохранен;
Если элемент отсоединяется от документа, это будет удаление, и экземпляр компонента будет размонтирован.
Пропсы
Все параметры, объявленные с помощью свойства
props
, будут определены для пользовательского элемента как свойства. Vue автоматически обрабатывает отражение между атрибутами/свойствами, где это необходимо.Атрибуты всегда отражаются в соответствующих свойствах.
Свойства с примитивными значениями (
строка
,булево
иличисло
) отражаются как атрибуты.
Vue также автоматически приводит параметры, объявленные с типами
Boolean
илиNumber
, к нужному типу, когда они устанавливаются в качестве атрибутов (которые всегда являются строками). Например, приведённое ниже объявление параметра:jsprops: { selected: Boolean, index: Number }
И использование пользовательских элементов:
template<my-element selected index="1"></my-element>
В компоненте
selected
будет приведен кtrue
(булевому значению), аindex
будет приведен к1
(числу).
События
События, вызванные через this.$emit
или setup emit
, отправляются как собственные CustomEvents на пользовательский элемент. Дополнительные аргументы события (полезная нагрузка) будут отображаться в виде массива на объекте CustomEvent в качестве его свойства detail
.
Слоты
Внутри компонента слоты могут быть отображены с помощью элемента <slot/>
, как обычно. Однако при потреблении результирующего элемента он принимает только синтаксис родных слотов:
Слоты с ограниченной областью видимости не поддерживаются.
При передаче именованных слотов используйте атрибут
slot
вместо директивыv-slot
:template<my-element> <div slot="named">привет</div> </my-element>
Provide / Inject
API Provide / Inject и его эквивалент Composition API также работают между пользовательскими элементами, определяемыми Vue. Однако обратите внимание, что это работает только между пользовательскими элементами, т. е. пользовательский элемент, определённый Vue, не сможет внедрить свойства, предоставляемые компонентом Vue, не являющимся пользовательским элементом.
Настройка уровня приложения
Вы можете настроить экземпляр приложения для пользовательского элемента Vue с помощью опции configureApp
:
js
defineCustomElement(MyComponent, {
configureApp(app) {
app.config.errorHandler = (err) => {
/* ... */
}
}
})
SFC как пользовательский элемент
defineCustomElement
также работает с однофайловыми компонентами Vue (SFC). Однако при стандартной настройке инструментария <style>
внутри SFC все равно будут извлечены и объединены в один CSS-файл во время сборки. При использовании SFC в качестве пользовательского элемента часто желательно внедрить теги <style>
в теневой корневой узел пользовательского элемента.
Официальные инструменты SFC поддерживают импорт SFC в «режиме пользовательского элемента» (требуется @vitejs/plugin-vue@^1.4.0
или vue-loader@^16.5.0
). SFC, загруженный в режиме пользовательского элемента, вставляет свои теги <style>
в виде строк CSS и раскрывает их в опции компонента styles
. Это будет подхвачено defineCustomElement
и вставлено в теневой корневой узел элемента при его инстанцировании.
Чтобы перейти в этот режим, просто завершите имя файла компонента символом .ce.vue
:
js
import { defineCustomElement } from 'vue'
import Example from './Example.ce.vue'
console.log(Example.styles) // ["/* встроенный CSS */"]
// преобразовываем в конструктор пользовательского элемента
const ExampleElement = defineCustomElement(Example)
// регистрируем
customElements.define('my-example', ExampleElement)
Если вы хотите настроить, какие файлы должны быть импортированы в режиме пользовательских элементов (например, рассматривать все SFC как пользовательские элементы), вы можете передать свойство customElement
соответствующим плагинам сборки:
Советы по созданию библиотеки пользовательских элементов Vue
При создании пользовательских элементов с помощью Vue, элементы будут полагаться на время выполнения Vue. В зависимости от того, сколько функций используется, базовый размер составляет ~16 Кб. Это означает, что не стоит использовать Vue, если вы отправляете один пользовательский элемент — возможно, вам стоит использовать ванильный JavaScript, petite-vue или фреймворки, которые специализируются на небольших размерах во время выполнения. Однако базовый размер более чем оправдан, если вы поставляете коллекцию пользовательских элементов со сложной логикой, поскольку Vue позволяет создавать каждый компонент с гораздо меньшим количеством кода. Чем больше элементов вы транспортируете вместе, тем лучше компромисс.
Если пользовательские элементы будут использоваться в приложении, которое также использует Vue, вы можете выбрать внешнее внедрение Vue из собранного пакета, чтобы элементы использовали ту же копию Vue, что и в хост-приложении.
Рекомендуется экспортировать отдельные конструкторы элементов, чтобы ваши пользователи могли импортировать их по требованию и регистрировать их с нужными именами тегов. Вы также можете экспортировать удобную функцию для автоматической регистрации всех элементов. Вот пример точки входа в библиотеку пользовательских элементов Vue:
js
// elements.js
import { defineCustomElement } from 'vue'
import Foo from './MyFoo.ce.vue'
import Bar from './MyBar.ce.vue'
const MyFoo = defineCustomElement(Foo)
const MyBar = defineCustomElement(Bar)
// экспортируем отдельные элементы
export { MyFoo, MyBar }
export function register() {
customElements.define('my-foo', MyFoo)
customElements.define('my-bar', MyBar)
}
Можно использовать элементы в файле Vue:
vue
<script setup>
import { register } from 'path/to/elements.js'
register()
</script>
<template>
<my-foo ...>
<my-bar ...></my-bar>
</my-foo>
</template>
Или в любом другом фреймворке, таком как фреймворк с JSX, и с пользовательскими именами:
jsx
import { MyFoo, MyBar } from 'path/to/elements.js'
customElements.define('some-foo', MyFoo)
customElements.define('some-bar', MyBar)
export function MyComponent() {
return <>
<some-foo ... >
<some-bar ... ></some-bar>
</some-foo>
</>
}
Веб-компоненты и TypeScript
При написании шаблонов Vue SFC вам может потребоваться проверка типа ваших компонентов Vue, включая те, которые определены как пользовательские элементы.
Пользовательские элементы регистрируются глобально в браузерах с помощью их собственных API, поэтому по умолчанию они не будут иметь определения типа при использовании в шаблонах Vue. Чтобы обеспечить поддержку типов для компонентов Vue, зарегистрированных как пользовательские элементы, мы можем зарегистрировать глобальные типы компонентов с помощью интерфейса GlobalComponents
для проверки типов в шаблонах Vue (вместо этого пользователи JSX могут дополнить тип JSX.IntrinsicElements, который здесь не показан).
Вот как определить тип для пользовательского элемента, созданного с помощью Vue:
typescript
import { defineCustomElement } from 'vue'
// Импортируем компонент Vue.
import SomeComponent from './src/components/SomeComponent.ce.vue'
// Преобразовываем компонент Vue в класс пользовательского элемента.
export const SomeElement = defineCustomElement(SomeComponent)
// Регистрируем класс элемента в браузере.
customElements.define('some-element', SomeElement)
// Добавляем новый тип элемента в тип GlobalComponents Vue.
declare module 'vue' {
interface GlobalComponents {
// Обязательно передайте здесь тип компонента Vue
// (SomeComponent, *а не* SomeElement).
// Пользовательские элементы требуют дефис в своем имени,
// поэтому используйте здесь имя элемента с дефисом.
'some-element': typeof SomeComponent
}
}
Веб-компоненты, не относящиеся к Vue, и TypeScript
Вот рекомендуемый способ включения проверки типов в шаблонах SFC пользовательских элементов, которые не созданы с помощью Vue.
Примечание
Этот подход является одним из возможных способов сделать это, но он может варьироваться в зависимости от фреймворка, используемого для создания пользовательских элементов.
Предположим, у нас есть пользовательский элемент с некоторыми определёнными JS-свойствами и событиями, и он поставляется в библиотеке под названием some-lib
:
ts
// file: some-lib/src/SomeElement.ts
// Определяем класс с типизированными JS-свойствами.
export class SomeElement extends HTMLElement {
foo: number = 123
bar: string = 'blah'
lorem: boolean = false
// Этот метод не должен быть доступен для типов шаблонов.
someMethod() {
/* ... */
}
// ... детали реализации опущены ...
// ... предположим, что элемент генерирует события с именем "apple-fell" ...
}
customElements.define('some-element', SomeElement)
// Это список свойств SomeElement, которые будут выбраны для проверки типов
// в шаблонах фреймворка (например, в шаблонах Vue SFC). Любые другие
// свойства не будут доступны.
export type SomeElementAttributes = 'foo' | 'bar'
// Определяем типы событий, которые генерирует SomeElement.
export type SomeElementEvents = {
'apple-fell': AppleFellEvent
}
export class AppleFellEvent extends Event {
/* ... детали опущены ... */
}
Детали реализации опущены, но важная часть заключается в том, что у нас есть определения типов для двух вещей: типов свойств и типов событий.
Давайте создадим вспомогательный тип для удобной регистрации определений типов пользовательских элементов в Vue:
ts
// file: some-lib/src/DefineCustomElement.ts
// Мы можем повторно использовать этот вспомогательный тип для каждого элемента, который нам нужно определить.
type DefineCustomElement<
ElementType extends HTMLElement,
Events extends EventMap = {},
SelectedAttributes extends keyof ElementType = keyof ElementType
> = new () => ElementType & {
// Используйте $props для определения свойств, доступных для проверки типов в шаблонах. Vue
// специально считывает определения свойств из типа `$props`. Обратите внимание, что мы
// комбинируем свойства элемента с глобальными HTML-свойствами и специальными
// свойствами Vue.
/** @deprecated Не используйте свойство $props на ссылке на пользовательский элемент,
это предназначено только для типов свойств шаблона. */
$props: HTMLAttributes &
Partial<Pick<ElementType, SelectedAttributes>> &
PublicProps
// Используйте $emit для конкретного определения типов событий. Vue специально считывает типы событий
// из типа `$emit`. Обратите внимание, что `$emit` ожидает определённый формат,
// который мы сопоставляем с `Events`.
/** @deprecated Не используйте свойство $emit на ссылке на пользовательский элемент,
это предназначено только для типов свойств шаблона. */
$emit: VueEmit<Events>
}
type EventMap = {
[event: string]: Event
}
// Это сопоставляет EventMap с форматом, который ожидает тип $emit Vue.
type VueEmit<T extends EventMap> = EmitFn<{
[K in keyof T]: (event: T[K]) => void
}>
Примечание
Мы пометили $props
и $emit
как устаревшие, чтобы при получении ref
на пользовательский элемент у нас не было соблазна использовать эти свойства, поскольку они предназначены только для проверки типов в случае с пользовательскими элементами. Эти свойства фактически не существуют в экземплярах пользовательских элементов.
Используя вспомогательный тип, мы теперь можем выбрать JS-свойства, которые должны быть экспонированы для проверки типов в шаблонах Vue:
ts
// file: some-lib/src/SomeElement.vue.ts
import {
SomeElement,
SomeElementAttributes,
SomeElementEvents
} from './SomeElement.js'
import type { Component } from 'vue'
import type { DefineCustomElement } from './DefineCustomElement'
// Добавляем новый тип элемента в тип GlobalComponents Vue.
declare module 'vue' {
interface GlobalComponents {
'some-element': DefineCustomElement<
SomeElement,
SomeElementAttributes,
SomeElementEvents
>
}
}
Предположим, что some-lib
компилирует свои исходные файлы TypeScript в папку dist/
. Пользователь some-lib
может затем импортировать SomeElement
и использовать его в Vue SFC следующим образом:
vue
<script setup lang="ts">
// Это создаст и зарегистрирует элемент в браузере.
import 'some-lib/dist/SomeElement.js'
// Пользователь, который использует TypeScript и Vue, должен дополнительно импортировать
// определения типов, специфичные для Vue (пользователи других фреймворков могут импортировать
// другие определения типов, специфичные для фреймворка).
import type {} from 'some-lib/dist/SomeElement.vue.js'
import { useTemplateRef, onMounted } from 'vue'
const el = useTemplateRef('el')
onMounted(() => {
console.log(
el.value!.foo,
el.value!.bar,
el.value!.lorem,
el.value!.someMethod()
)
// Не используйте эти пропсы, они `неопределены`
// IDE покажет их перечёркнутыми
el.$props
el.$emit
})
</script>
<template>
<!-- Теперь мы можем использовать элемент с проверкой типа: -->
<some-element
ref="el"
:foo="456"
:blah="'hello'"
@apple-fell="
(event) => {
// Здесь предполагается, что типом события является `AppleFellEvent`
}
"
></some-element>
</template>
Если у элемента нет определений типов, типы свойств и событий можно указать вручную:
vue
<script setup lang="ts">
// Предположим, что `some-lib` — это простой JS без определений типов,
// а TypeScript не может вывести типы:
import { SomeElement } from 'some-lib'
// Мы будем использовать хелпер того же типа, что и раньше.
import { DefineCustomElement } from './DefineCustomElement'
type SomeElementProps = { foo?: number; bar?: string }
type SomeElementEvents = { 'apple-fell': AppleFellEvent }
interface AppleFellEvent extends Event {
/* ... */
}
// Добавляем новый тип элемента в тип GlobalComponents Vue.
declare module 'vue' {
interface GlobalComponents {
'some-element': DefineCustomElement<
SomeElementProps,
SomeElementEvents
>
}
}
// ... так же, как и раньше, используйте ссылку на элемент ...
</script>
<template>
<!-- ... так же, как и раньше, используйте элемент в шаблоне ... -->
</template>
Авторы пользовательских элементов не должны автоматически экспортировать определения типов, специфичных для фреймворка, из своих библиотек. Например, они не должны экспортировать их из файла index.ts
, который также экспортирует остальную часть библиотеки, иначе у пользователей возникнут неожиданные ошибки увеличения модуля. Пользователи должны импортировать файл определения типов, специфичный для фреймворка, который им нужен.
Веб-компоненты и компоненты Vue
Некоторые разработчики считают, что следует избегать проприетарных моделей компонентов, и что использование только пользовательских элементов делает приложение «защищенным от будущего». Здесь мы попытаемся объяснить, почему мы считаем, что это слишком упрощённый взгляд на проблему.
Между пользовательскими компонентами и компонентами Vue действительно есть определённый уровень совпадения функций: и те, и другие позволяют нам определять многократно используемые компоненты с передачей данных, эмуляцией событий и управлением жизненным циклом. Однако API веб-компонентов относительно низкоуровневые и «голые». Чтобы создать реальное приложение, нам нужно довольно много дополнительных возможностей, которые платформа не охватывает:
Декларативная и эффективная система шаблонов;
Реактивная система управления состоянием, облегчающая извлечение и повторное использование межкомпонентной логики;
Производительный способ рендеринга компонентов на сервере и их гидратации на клиенте (SSR), что важно для SEO и показателей Web Vitals, таких как LCP. Нативные пользовательские элементы SSR обычно включают в себя имитацию DOM в Node.js и последующую сериализацию изменённого DOM, в то время как Vue SSR компилируется в конкатенацию строк, когда это возможно, что гораздо более эффективно.
Компонентная модель Vue разработана с учётом этих потребностей как целостная система.
При наличии компетентной команды инженеров вы, вероятно, сможете создать эквивалент поверх родных пользовательских элементов — но это также означает, что вы берете на себя долгосрочное бремя поддержки собственного фреймворка, теряя при этом преимущества экосистемы и сообщества, присущие таким зрелым фреймворкам, как Vue.
Существуют также фреймворки, использующие пользовательские компоненты в качестве основы своей компонентной модели, но все они неизбежно вынуждены внедрять свои собственные решения перечисленных выше проблем. Использование этих фреймворков подразумевает принятие технических решений по решению этих проблем, что, несмотря на рекламу, автоматически не страхует вас от потенциальных отказов в будущем.
Есть также некоторые области, в которых пользовательские элементы, на наш взгляд, являются ограничивающими:
Стремление оценить слот мешает составлению компонентов. Слоты с ограниченной областью видимости во Vue — это мощный механизм для компоновки компонентов, который не может быть поддержан пользовательскими элементами из-за нетерпеливой природы родных слотов. Нетерпеливые слоты также означают, что принимающий компонент не может контролировать, когда и нужно ли отображать часть содержимого слота.
Доставка пользовательских элементов с теневым DOM с помощью CSS сегодня требует встраивания CSS в JavaScript, чтобы они могли быть инжектированы в теневые корни во время выполнения. Они также приводят к дублированию стилей в разметке в сценариях SSR. В этой области ведется работа над возможностями платформы — но на данный момент они ещё не поддерживаются повсеместно, и ещё предстоит решить проблемы с производительностью и SSR. Между тем, однофайловые компоненты Vue предоставляют механизмы CSS с ограниченной областью действия, которые поддерживают извлечение стилей в обычные CSS-файлы.
Vue всегда будет в курсе последних стандартов веб-платформы, и мы с радостью будем использовать всё, что предоставляет платформа, если это облегчит нашу работу. Однако наша цель — предоставить решения, которые хорошо работают и работают сегодня. Это означает, что мы должны внедрять новые функции платформы с критическим подходом — и это предполагает заполнение пробелов, в которых стандарты пока не справляются.