Skip to content

Vue и Веб-компоненты

Веб-компоненты (Web Components) — общий термин набора нативных API, которые позволяют веб-разработчикам создавать переиспользуемые пользовательские элементы.

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

vite.config.js
js
import vue from '@vitejs/plugin-vue'

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          // считать все теги с тире как пользовательские элементы
          isCustomElement: (tag) => tag.includes('-')
        }
      }
    })
  ]
}

Пример конфигурации Vue CLI

vue.config.js
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, внедряемый в shadow root
  styles: [`/* inlined css */`]
})

// регистрация пользовательского элемента.
// после регистрации все теги `<my-vue-element>`
// на странице будут обновлены.
customElements.define('my-vue-element', MyVueElement)

// Вы также можете программно создать экземпляр элемента:
// (это может быть сделано только после регистрации)
document.body.appendChild(
  new MyVueElement({
    // начальные входные параметры (опционально)
  })
)

Жизненный цикл

  • Пользовательский элемент Vue будет монтировать внутренний экземпляр компонента Vue внутри своего shadow root при первом вызове connectedCallback на элементе.

  • При вызове disconnectedCallback на элементе Vue будет проверять, отсоединён ли элемент от документа после microtask тика.

    • Если элемент будет всё ещё находиться в документе, то это считается перемещением и экземпляр компонента будет сохранён;

    • Если элемент будет отсоединён от документа, то это считается удалением и экземпляр компонента будет размонтирован.

Входные параметры

  • Все входные параметры для пользовательского элемента, объявленные в опции props, будут определены в пользовательском элементе как свойства. Vue автоматически обработает соответствие между атрибутами / свойствами, где это необходимо.

    • Атрибуты всегда отражаются в соответствующих свойствах

    • Свойства с примитивными значениями (string, boolean or number) отражаются как атрибуты.

  • Vue автоматически приведёт входные параметры, объявленные с типами Boolean или Number, к нужному типу, когда они устанавливаются в качестве атрибутов (которые всегда являются строками). К примеру, рассмотрим объявление входных параметров::

    js
    props: {
      selected: Boolean,
      index: Number
    }

    Использование пользовательского элемента:

    template
    <my-element selected index="1"></my-element>

    В компоненте значение selected будет приведено к true (boolean), а index - к 1 (number).

События

События, генерируемые через this.$emit или setup emit,будут генерироваться на пользовательском элементе как нативные CustomEvents. Дополнительные аргументы события (payload / данные передаваемые с событием) будут содержаться в объекте CustomEvent в свойстве details в виде массива.

Слоты

Внутри компонента слоты могут указываться как обычно, с помощью элемента <slot/>. Но при получении результирующего элемента должен быть только нативный синтаксис слотов:

Provide / Inject

Provide / Inject API и его эквивалент в Composition API также работают между пользовательскими элементами, определяемыми Vue. Однако обратите внимание, что это работает только между пользовательскими элементами. То есть пользовательский элемент, определяемый Vue, не сможет инжектировать свойства, предоставляемые компонентом Vue, не являющимся пользовательским элементом.

Конфигурация на уровне приложения

Экземпляр приложения пользовательского элемента Vue можно настроить с помощью опции configureApp:

js
defineCustomElement(MyComponent, {
  configureApp(app) {
    app.config.errorHandler = (err) => {
      /* ... */
    }
  }
})

Однофайловые компоненты как пользовательские элементы

Метод defineCustomElement также работает с однофайловыми компонентами Vue (SFC). Однако при стандартной настройке инструментов <style> внутри SFC все равно будут извлечены и объединены в один CSS-файл при production сборке. При использовании SFC в качестве пользовательского элемента часто более желательным вариантом будет внедрение тегов <style> в shadow root пользовательского элемента.

Официальные инструменты для однофайловых компонентов поддерживают их импорт в "режиме пользовательского элемента" (требуется @vitejs/plugin-vue@^1.4.0 или vue-loader@^16.5.0). Однофайловый компонент, загруженный в режиме пользовательского элемента, вставляет свои теги <style> как строки CSS и раскрывает их в параметре компонента styles. Их использует defineCustomElement и при инициализации внедрено в shadow root элемента.

Для переключения в этот режим, требуется завершить имя файла компонента на .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)

Если нужно настроить, какие файлы должны импортироваться в режиме пользовательских элементов (например, чтобы пользовательскими элементами считались все однофайловые компоненты), можно передать параметр customElement соответствующим плагинам системы сборки:

Советы по созданию библиотеки пользовательских элементов на Vue

При создании пользовательских элементов с помощью Vue элементы будут опираться на среду выполнения Vue. В зависимости от количества используемых функций, базовый размер будет составлять ~16 кб. Это означает, что использование Vue не является идеальным, если вы поставляете один пользовательский элемент - возможно, вам лучше использовать ванильный JavaScript, petite-vue или фреймворки, которые специализируются на небольшом размере среды выполнения. Но такой базовый размер более чем оправдан, если поставлять коллекцию пользовательских элементов со сложной логикой, так как Vue позволит создавать каждый компонент с гораздо меньшим количеством кода. Чем больше элементов будет поставляться, тем выгоднее окажется этот компромисс.

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

Рекомендуется экспортировать отдельные конструкторы элементов, чтобы позволить их импортировать по необходимости или регистрировать с нужными именами тегов. Также можно экспортировать удобную функцию для автоматической регистрации всех элементов. Вот пример точки входа пользовательской библиотеки пользовательских элементов на Vue:

elements.js
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>
  </>
}

Пользовательские элементы на Vue и 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 в класс Custom Element.
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:

some-lib/src/SomeElement.ts
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:

some-lib/src/DefineCustomElement.ts
ts
// Этот вспомогательный тип можно переиспользовать для каждого элемента.
type DefineCustomElement<
  ElementType extends HTMLElement,
  Events extends EventMap = {},
  SelectedAttributes extends keyof ElementType = keyof ElementType
> = new () => ElementType & {
  // $props задаёт свойства, доступные для проверки типов в шаблоне. Vue
  // читает определения пропсов из типа `$props`. Мы объединяем пропсы
  // элемента с глобальными HTML-пропсами и специальными пропсами Vue.
  /** @deprecated Не используйте свойство $props на ref пользовательского элемента —
    оно только для типов пропсов в шаблоне. */
  $props: HTMLAttributes &
    Partial<Pick<ElementType, SelectedAttributes>> &
    PublicProps

  // $emit задаёт типы событий. Vue читает типы событий из типа `$emit`.
  // Формат $emit особый, мы маппим в него `Events`.
  /** @deprecated Не используйте свойство $emit на ref пользовательского элемента —
    оно только для типов в шаблоне. */
  $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:

some-lib/src/SomeElement.vue.ts
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 так:

SomeElementImpl.vue
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()
  )

  // Do not use these props, they are `undefined`
  // IDE will show them crossed out
  el.$props
  el.$emit
})
</script>

<template>
  <!-- Now we can use the element, with type checking: -->
  <some-element
    ref="el"
    :foo="456"
    :blah="'hello'"
    @apple-fell="
      (event) => {
        // тип `event` здесь выводится как `AppleFellEvent`
      }
    "
  ></some-element>
</template>

Если у элемента нет готовых определений типов, типы свойств и событий можно задать вручную:

SomeElementImpl.vue
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. Отрисовка на стороне сервера нативных пользовательских элементов обычно состоит из симулирования DOM в Node.js и последующую сериализацию изменённого DOM, в то время как SSR для Vue когда это возможно компилируется в конкатенацию строк, что гораздо эффективнее.

Компонентная модель Vue разработана как целостная система, с учётом этих потребностей.

При наличии компетентной команды инженеров, вероятно, возможно построить эквивалент всего этого поверх родных пользовательских элементов — но это значит, что берёте на себя долгосрочное бремя поддержки собственного фреймворка, теряя при этом преимущества зрелой экосистемы фреймворка Vue и его сообщества.

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

Также есть некоторые области, в которых пользовательские элементы могут быть ограничивающим фактором:

  • Нетерпеливая оценка слотов не позволяет компоновать компоненты. Слоты с ограниченной областью видимости во Vue — это мощный механизм для композиции компонентов, который не может поддерживаться пользовательскими элементами из-за нетерпеливой природы нативных слотов. Нетерпеливые слоты также означают, что принимающий компонент не может контролировать, когда и нужно ли выводить содержимое слота.

  • Доставка пользовательских элементов с shadow DOM и локальным (scoped) CSS сейчас требует встраивания CSS в JavaScript, чтобы их можно было внедрить в shadow root в runtime. Это также приводит к дублированию стилей в разметке в сценариях с SSR. В этой области работают над новыми возможностями платформы, но на данный момент они ещё не поддерживаются повсеместно, и всё ещё есть проблемы с производительностью в production и SSR, которые требуется решить. В тоже время, однофайловые компоненты Vue предоставляют механизм локализации CSS, который поддерживает извлечение стилей в обычные CSS-файлы.

Vue всегда будет идти в ногу с последними стандартами веб-платформ, и использовать всё, что предоставляет платформа, если это может облегчить работу. Но текущая цель — предоставлять решения, которые работают хорошо и работают уже сегодня. Это значит, что новые возможности платформы следует включать лишь критически поразмыслив — и это подразумевает заполнение пробелов, где стандарты ещё не соответствуют требованиям.

Vue и Веб-компонентыУже загружено