Реалізація кроссфреймворкових Web Components (React/Vue/Angular)
Головне обіцянняWeb Components — «напишіть один раз, використовуйте скрізь». Реалізація цього обіцяння вимагає вирішити ряд нетривіальних проблем: типізація в кожному фреймворку, обробка подій, двосторонній біндинг, SSR-сумісність.
Проблеми при використанні Web Components у фреймворках
React (до версії 19) не передає об'єкти та масиви через атрибути. Модель подій React не автоматично підхоплює кастомні DOM-события. З React 19 ситуація поліпшилася, але вимагає перевірки.
Angular вимагає схему CUSTOM_ELEMENTS_SCHEMA для роботи з нестандартними тегами.
Vue — найбільш дружній до Web Components фреймворк, обробляє більшість кейсів нативно.
SSR — customElements.define не існує в Node.js. Компоненти не можна рендерити на сервері без спеціальних рішень.
React: коректна інтеграція
// Проблема: React передає об'єкти як рядок "[object Object]"
// Рішення: ref + useEffect для встановлення властивостей напрямку
import { useRef, useEffect, forwardRef } from 'react'
// Обгортка-компонент для Web Component з об'єктними props
interface DataTableProps {
columns: Column[]
rows: Row[]
onRowSelect?: (row: Row) => void
onSort?: (column: string, direction: 'asc' | 'desc') => void
}
export const DataTable = forwardRef<HTMLElement, DataTableProps>(
({ columns, rows, onRowSelect, onSort }, forwardedRef) => {
const ref = useRef<HTMLElement>(null)
// Пробрасуємо forwardedRef
useEffect(() => {
if (typeof forwardedRef === 'function') forwardedRef(ref.current)
else if (forwardedRef) forwardedRef.current = ref.current
}, [forwardedRef])
// Об'єкти передаємо через властивості, не атрибути
useEffect(() => {
if (ref.current) {
(ref.current as any).columns = columns
}
}, [columns])
useEffect(() => {
if (ref.current) {
(ref.current as any).rows = rows
}
}, [rows])
// Кастомні события
useEffect(() => {
const el = ref.current
if (!el) return
const handleRowSelect = (e: Event) => {
onRowSelect?.((e as CustomEvent).detail)
}
const handleSort = (e: Event) => {
const { column, direction } = (e as CustomEvent).detail
onSort?.(column, direction)
}
el.addEventListener('row-select', handleRowSelect)
el.addEventListener('sort', handleSort)
return () => {
el.removeEventListener('row-select', handleRowSelect)
el.removeEventListener('sort', handleSort)
}
}, [onRowSelect, onSort])
return <data-table ref={ref} />
}
)
React 19: поліпшена підтримка
// React 19 нативно підтримує передачу об'єктів у Web Components
// та підписку на кастомні события через on* props
// Типізація для нової поведінки React 19
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'data-table': {
ref?: React.Ref<HTMLElement>
columns?: Column[] // React 19: об'єкт передається напрямку
rows?: Row[]
'on-row-select'?: (e: CustomEvent<Row>) => void
onRowSelect?: (e: CustomEvent<Row>) => void // React 19
}
}
}
}
Angular: схема та обгортки
// app.module.ts — дозволити невідомі елементи
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA], // ← обов'язково
bootstrap: [AppComponent],
})
export class AppModule {}
// Standalone компонент — без NgModule
@Component({
selector: 'app-page',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<ui-button
variant="primary"
[disabled]="isLoading"
(uiClick)="handleClick($event)"
>
Зберегти
</ui-button>
`,
})
export class PageComponent {
isLoading = false
handleClick(e: CustomEvent) {
this.isLoading = true
// ...
}
}
Angular директива-обгортка для двостороннього біндингу:
// Директива для <ui-input> з підтримкою [(ngModel)]
import { Directive, forwardRef, HostListener, ElementRef } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
@Directive({
selector: 'ui-input[formControlName], ui-input[ngModel]',
standalone: true,
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => UiInputValueAccessor),
multi: true,
}],
})
export class UiInputValueAccessor implements ControlValueAccessor {
private onChange: (value: string) => void = () => {}
private onTouched: () => void = () => {}
constructor(private el: ElementRef) {}
@HostListener('sl-input', ['$event.target.value'])
@HostListener('sl-change', ['$event.target.value'])
onInput(value: string) {
this.onChange(value)
}
@HostListener('sl-blur')
onBlur() { this.onTouched() }
writeValue(value: string) {
this.el.nativeElement.value = value ?? ''
}
registerOnChange(fn: (v: string) => void) { this.onChange = fn }
registerOnTouched(fn: () => void) { this.onTouched = fn }
setDisabledState(disabled: boolean) {
this.el.nativeElement.disabled = disabled
}
}
Vue: нативна підтримка
Vue 3 працює з Web Components без налаштування:
// vite.config.ts — не парсити кастомні теги як Vue компоненти
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// Елементи з '-' в імені — Web Components
isCustomElement: (tag) => tag.includes('-'),
},
},
}),
],
})
<template>
<!-- Props передаються як атрибути для примітивів -->
<ui-button variant="primary" :disabled="isLoading" @ui-click="handleClick">
Відправити
</ui-button>
<!-- Об'єкти через .prop модифікатор -->
<data-table
.columns="columns"
.rows="rows"
@row-select="handleRowSelect"
/>
<!-- v-model для кастомного інпута -->
<ui-input v-model="formValue" label="Email" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import '@company/ui-core/components/button.js'
import '@company/ui-core/components/input.js'
import '@company/ui-core/components/data-table.js'
const isLoading = ref(false)
const formValue = ref('')
// v-model для Web Component — потрібен defineCustomElement або вручну:
// v-model компілюється в :modelValue + @update:modelValue
// Web Component має емітити 'update:modelValue' событие
</script>
Svelte
<script lang="ts">
import '@company/ui-core/components/button.js'
let loading = false
function handleClick(e: CustomEvent) {
loading = true
// ...
}
</script>
<!-- Svelte: on:событие для кастомних подій -->
<ui-button
variant="primary"
disabled={loading}
on:ui-click={handleClick}
>
Відправити
</ui-button>
SSR: рішення server-side rendering
На сервері немає customElements, HTMLElement, window. Варіанти:
// 1. Lazy import тільки на клієнті (Next.js)
// components/UiButton.tsx
import dynamic from 'next/dynamic'
const UiButtonClient = dynamic(
() => import('./UiButtonClient').then(m => m.UiButtonClient),
{ ssr: false }
)
export function UiButton(props: ButtonProps) {
return <UiButtonClient {...props} />
}
// 2. Polyfil для SSR (experimental)
// @lit-labs/ssr — серверний рендер Lit компонентів
import { renderToString } from '@lit-labs/ssr'
import { html } from 'lit'
const result = renderToString(html`
<ui-button variant="primary">Click</ui-button>
`)
// Отдає declarative shadow DOM розмітку
<!-- Declarative Shadow DOM — SSR-compatible -->
<ui-button>
<template shadowrootmode="open">
<style>/* ... */</style>
<button class="btn btn--primary">Click</button>
</template>
Click
</ui-button>
Універсальні обгортки: @lit/react
Офіційне рішення від команди Lit для React-інтеграції:
import { createComponent } from '@lit/react'
import React from 'react'
import { UiButton } from '@company/ui-core'
export const Button = createComponent({
tagName: 'ui-button',
elementClass: UiButton,
react: React,
events: {
onUiClick: 'ui-click',
onUiFocus: 'ui-focus',
onUiBlur: 'ui-blur',
},
})
// Теперь Button працює як React компонент з типізацією
function App() {
return (
<Button
variant="primary"
onUiClick={(e) => console.log(e.detail)}
>
Click
</Button>
)
}
Чеклист сумісності
Перед публікацією кроссфреймворкової бібліотеки:
- Всі кастомні события використовують
composed: trueтаbubbles: true - Об'єктні властивості не дзеркалятися в атрибути (
reflect: falseдля об'єктів) - Компонент коректно працює з
disabledчерезElementInternals - Немає прямих звернень до
window,documentвconstructor— тільки вconnectedCallback - Експортуються типи для кожного фреймворку
- Додан
custom-elements.json(CEM) для IDE-автодополнення
Терміни
Інтеграція існуючої Web Components бібліотеки в один фреймворк з типами та обгортками — 3–5 днів. Підтримка React + Vue + Angular + SSR з повним набором типів та документації — 3–4 тижні.







