Розробка бібліотеки Web Components (Stencil)
Stencil — компілятор компонентів від Ionic. Написані компоненти компілюються в нативні Web Components з опціональною підтримкою Angular, React, Vue через автогенеровані обгортки. Stencil — не runtime-бібліотека, а інструмент збірки: найбільший бандл містить тільки нативні API плюс мінімальний polyfill-шар.
Ключова різниця від Lit: Stencil генерує framework-специфічні пакети. Якщо бібліотека повинна працювати як native в Angular (з двостороннім біндингом, формами), як React-компоненти (з типізованими props) — Stencil робить це з коробки.
Встановлення та ініціалізація
npm init stencil@latest
# Вибрати: component library
cd my-library
npm install
Структура проекту:
src/
├── components/
│ ├── ui-button/
│ │ ├── ui-button.tsx ← компонент
│ │ ├── ui-button.css ← стилі
│ │ ├── ui-button.e2e.ts ← E2E тести
│ │ └── ui-button.spec.ts ← unit тести
│ └── ui-input/
├── utils/
├── index.ts
└── index.html
stencil.config.ts
Компонент на Stencil
Stencil використовує TSX (як React) та декоратори для розмітки компонента:
import {
Component,
Host,
h,
Prop,
State,
Event,
EventEmitter,
Method,
Watch,
Element,
Listen,
} from '@stencil/core'
@Component({
tag: 'ui-button',
styleUrl: 'ui-button.css',
shadow: true, // включити Shadow DOM
// scoped: true, // замість Shadow DOM — scoped CSS (немає slots, але працює з формами)
})
export class UiButton {
// Посилання на host елемент
@Element() el!: HTMLElement
// Props — публічні властивості/атрибути
@Prop() variant: 'primary' | 'secondary' | 'ghost' = 'primary'
@Prop() size: 'sm' | 'md' | 'lg' = 'md'
@Prop({ reflect: true }) disabled = false
@Prop({ mutable: true }) loading = false // mutable — компонент може змінювати
// Внутрішній стан
@State() private focused = false
// События
@Event({ eventName: 'uiClick', bubbles: true, composed: true })
uiClick!: EventEmitter<{ nativeEvent: MouseEvent }>
// Watch — реакція на зміну prop/state
@Watch('disabled')
onDisabledChange(newVal: boolean) {
this.el.setAttribute('aria-disabled', String(newVal))
}
// Listen — слухаємо події (на host або document)
@Listen('focus', { target: 'window' })
onWindowFocus(e: FocusEvent) {
// ...
}
// Публічний метод — викликається з JS
@Method()
async focusButton() {
this.el.shadowRoot?.querySelector('button')?.focus()
}
private handleClick = (e: MouseEvent) => {
if (this.disabled || this.loading) return
this.uiClick.emit({ nativeEvent: e })
}
render() {
return (
<Host
class={{
'is-disabled': this.disabled,
'is-loading': this.loading,
}}
aria-disabled={this.disabled ? 'true' : null}
>
<button
type="button"
disabled={this.disabled || this.loading}
class={`btn btn--${this.variant} btn--${this.size}`}
onClick={this.handleClick}
onFocus={() => (this.focused = true)}
onBlur={() => (this.focused = false)}
>
{this.loading && <span class="spinner" aria-hidden="true"></span>}
<slot name="icon-start"></slot>
<slot></slot>
<slot name="icon-end"></slot>
</button>
</Host>
)
}
}
/* ui-button.css */
:host {
display: inline-flex;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
cursor: pointer;
font-family: inherit;
font-weight: 600;
border-radius: var(--ui-radius, 8px);
transition: background 0.15s, transform 0.1s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn:active:not(:disabled) { transform: scale(0.97); }
.btn--primary { background: var(--ui-primary, #7000ff); color: #fff; }
.btn--secondary { background: transparent; border: 1.5px solid var(--ui-primary, #7000ff); color: var(--ui-primary, #7000ff); }
.btn--ghost { background: transparent; color: var(--ui-primary, #7000ff); }
.btn--sm { padding: 6px 14px; font-size: 13px; }
.btn--md { padding: 10px 22px; font-size: 15px; }
.btn--lg { padding: 14px 30px; font-size: 17px; }
Stencil Config: кілька output targets
Головна можливість Stencil — скомпілювати один компонент в кілька форматів:
// stencil.config.ts
import { Config } from '@stencil/core'
import { angularOutputTarget } from '@stencil/angular-output-target'
import { reactOutputTarget } from '@stencil/react-output-target'
import { vueOutputTarget } from '@stencil/vue-output-target'
export const config: Config = {
namespace: 'my-ui',
outputTargets: [
// 1. Нативні Web Components
{
type: 'dist',
esmLoaderPath: '../loader',
},
// 2. dist-custom-elements — tree-shakeable
{
type: 'dist-custom-elements',
customElementsExportBehavior: 'auto-define-custom-elements',
externalRuntime: false,
},
// 3. React обгортки (автогенерація)
reactOutputTarget({
componentCorePackage: 'my-ui-core',
proxiesFile: '../my-ui-react/src/components.ts',
includeDefineCustomElements: true,
}),
// 4. Vue обгортки
vueOutputTarget({
componentCorePackage: 'my-ui-core',
proxiesFile: '../my-ui-vue/src/components.ts',
}),
// 5. Angular обгортки з NgModule
angularOutputTarget({
componentCorePackage: 'my-ui-core',
outputType: 'standalone', // або 'component'
directivesProxyFile: '../my-ui-angular/src/directives/proxies.ts',
}),
// 6. Документація
{ type: 'docs-readme' },
{ type: 'docs-json', file: './dist/docs.json' },
// 7. Custom Elements Manifest для IDE-підсказок
{ type: 'docs-vscode', file: './dist/vscode.html-data.json' },
],
testing: {
browserHeadless: 'new',
},
}
Автогенеровані React-обгортки
Після npm run build в my-ui-react/src/components.ts:
// Автогенеровано Stencil — не редагувати вручну
import { createReactComponent } from './react-component-lib'
import { defineCustomElements } from 'my-ui-core/loader'
defineCustomElements()
export const UiButton = /*@__PURE__*/ createReactComponent<
JSX.UiButton,
HTMLUiButtonElement
>('ui-button')
export const UiInput = /*@__PURE__*/ createReactComponent<
JSX.UiInput,
HTMLUiInputElement
>('ui-input')
Використання в React:
import { UiButton, UiInput } from 'my-ui-react'
function Form() {
const handleClick = (e: CustomEvent<{ nativeEvent: MouseEvent }>) => {
console.log('clicked', e.detail)
}
return (
<form>
<UiInput label="Email" type="email" required />
<UiButton
variant="primary"
onUiClick={handleClick} // типізований event handler
loading={false}
>
Відправити
</UiButton>
</form>
)
}
Тестування
Stencil включає Stencil Testing Utils поверх Jest + Puppeteer:
// ui-button.spec.ts — unit тести
import { newSpecPage } from '@stencil/core/testing'
import { UiButton } from './ui-button'
describe('ui-button', () => {
it('renders with default props', async () => {
const page = await newSpecPage({
components: [UiButton],
html: '<ui-button>Click me</ui-button>',
})
expect(page.root).toEqualHtml(`
<ui-button>
<mock:shadow-root>
<button class="btn btn--primary btn--md" type="button">
<slot name="icon-start"></slot>
<slot></slot>
<slot name="icon-end"></slot>
</button>
</mock:shadow-root>
Click me
</ui-button>
`)
})
it('disables button when disabled prop is set', async () => {
const page = await newSpecPage({
components: [UiButton],
html: '<ui-button disabled></ui-button>',
})
const button = page.root?.shadowRoot?.querySelector('button')
expect(button?.disabled).toBe(true)
})
it('emits uiClick event', async () => {
const page = await newSpecPage({
components: [UiButton],
html: '<ui-button></ui-button>',
})
const clickSpy = jest.fn()
page.root?.addEventListener('uiClick', clickSpy)
page.root?.shadowRoot?.querySelector('button')?.click()
expect(clickSpy).toHaveBeenCalled()
})
})
Монорепозиторій: структура мульти-пакетного проекту
packages/
├── core/ ← Stencil компоненти (my-ui-core)
├── react/ ← React обгортки (my-ui-react)
├── vue/ ← Vue обгортки (my-ui-vue)
├── angular/ ← Angular обгортки (my-ui-angular)
└── docs/ ← Storybook
Терміни
5–8 компонентів з dist та React output — 2–3 тижні. Повна бібліотека з Angular/Vue обгортками, E2E-тестами, Storybook, CD-пайплайном та npm-публікацією — 6–10 тижнів.







