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

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

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

# Использование пользовательских элементов во Vue

Vue безупречно получает 100% в тестах Custom Elements Everywhere (opens new window). Использование в приложении Vue пользовательских элементов в целом работает аналогично обычному использованию нативных HTML-элементов. Несколько моментов, о которых стоит помнить:

# Пропуск разрешения компонентов

По умолчанию Vue будет пытаться разрешить не-нативный HTML-тег зарегистрированным компонентом Vue, прежде чем вернуться к его отрисовке как пользовательского элемента. Это приводит к тому, что во время разработки Vue будет выбрасывать предупреждение «failed to resolve component» (не удалось разрешить компонент). Чтобы Vue понял, что некоторые элементы должны рассматриваться как пользовательские требуется указать опцию compilerOptions.isCustomElement.

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

# Пример конфигурации в браузере

// Работает только при использовании компиляции шаблонов в браузере.
// При использовании системы сборки, см. примеры ниже.
app.config.compilerOptions.isCustomElement = tag => tag.includes('-')
1
2
3

# Пример конфигурации Vite

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

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          // считать все теги с тире пользовательскими элементами
          isCustomElement: tag => tag.includes('-')
        }
      }
    })
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

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

// vue.config.js
module.exports = {
  chainWebpack: config => {
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => ({
        ...options,
        compilerOptions: {
          // считать все теги начинающиеся с ion- пользовательскими элементами
          isCustomElement: tag => tag.startsWith('ion-')
        }
      }))
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# Передача свойств DOM

Поскольку атрибуты DOM могут быть только строками, то появляется необходимость передавать сложные данные в пользовательские элементы через свойства DOM. При установке входных параметров для пользовательского элемента, Vue 3 автоматически проверяет наличие DOM-свойства с помощью оператора in и предпочтёт установить значение как DOM-свойство, если ключ присутствует. Чаще всего об этом и не придётся думать, если пользовательский элемент разработан следуя рекомендуемым практикам (opens new window).

Возможны крайние случаи, когда данные должны быть переданы как свойство DOM, но пользовательский элемент не объявляет/отражает свойство должным образом (приводя к неудаче при проверке с помощью in). В таких случаях, форсировать привязку v-bind для установки свойства DOM можно использованием модификатора .prop:

<my-element :user.prop="{ name: 'jack' }"></my-element>

<!-- сокращённая запись -->
<my-element .user="{ name: 'jack' }"></my-element>
1
2
3
4

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

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

# defineCustomElement

Vue позволяет создавать пользовательские элементы с точно таким же же API компонентов Vue с помощью метода defineCustomElement. Метод принимает такой же аргумент, как и defineComponent, но вместо этого будет возвращать конструктор пользовательского элемента, расширяющего HTMLElement:

<my-vue-element></my-vue-element>
1
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({
    // стартовые входные параметры (опционально)
  })
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

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

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

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

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

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

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

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

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

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

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

    props: {
      selected: Boolean,
      index: Number
    }
    
    1
    2
    3
    4

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

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

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

# События

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

# Слоты

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

# Provide / Inject

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

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

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

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

Для переключения в этот режим, требуется завершить имя файла компонента на .ce.vue:

import { defineCustomElement } from 'vue'
import Example from './Example.ce.vue'

console.log(Example.styles) // ["/* инлайн css */"]

// преобразование в конструктор пользовательского элемента
const ExampleElement = defineCustomElement(Example)

// регистрация
customElements.define('my-example', ExampleElement)
1
2
3
4
5
6
7
8
9
10

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

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

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

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

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

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)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Если компонентов слишком много, можно воспользоваться такими возможностями систем сборки, как глобальный импорт (opens new window) в Vite или require.context (opens new window) в webpack для загрузки всех компонентов из определённого каталога.

# Веб-компоненты vs. компоненты Vue

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

Между пользовательскими элементами и компонентами Vue действительно есть некоторый уровень совпадающих функций: они оба позволяют определять многократно используемые компоненты с передачей данных, генерацией событий и управлением жизненным циклом. Но API веб-компонентов относительно низкоуровневое и «сырое». Для создания реального приложения часто требуется довольно много дополнительных возможностей, которые эта платформа не охватывает:

  • Декларативная и эффективная система шаблонов;

  • Реактивная система управления состоянием, которая облегчает извлечение и переиспользование логики между компонентами;

  • Производительный способ отрисовки компонентов на сервере и гидратация на клиенте (SSR), что важно для SEO и метрик Web Vitals, таких как LCP (opens new window). Отрисовка на стороне сервера нативных пользовательских элементов обычно состоит из симулирования DOM в Node.js и последующую сериализацию изменённого DOM, в то время как SSR для Vue когда это возможно компилируется в конкатенацию строк, что гораздо эффективнее.

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

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

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

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

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

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

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