Разработка библиотеки Web Components (Stencil)
Stencil — компилятор компонентов от Ionic. Написанные компоненты компилируются в нативные Web Components с опциональной поддержкой Angular, React, Vue через автогенерируемые обёртки. Stencil — не runtime-библиотека, а инструмент сборки: итоговый бандл содержит только нативные APIs плюс минимальный полифилл-слой.
Ключевое отличие от 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
// Events
@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 Testng Utils поверх Jest + Puppeteer:
// ui-button.spec.ts — unit tests
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 недель.







