Разработка библиотеки Web Components (Lit)
Lit — библиотека от Google для создания Web Components. Весит ~5kb gzipped, строится поверх нативных API (Custom Elements, Shadow DOM, Templates), добавляя реактивные свойства, декларативные шаблоны через tagged template literals и автоматические обновления DOM.
Выбор Lit против «голых» Custom Elements оправдан когда компонентов больше 3–5: Lit убирает шаблонный код ручного обновления DOM, реактивных атрибутов и lifecycle-управления.
Установка и структура проекта
npm create vite@latest my-components -- --template lit-ts
cd my-components
npm install
Или в существующий проект:
npm install lit
Типовая структура библиотеки:
src/
├── components/
│ ├── button/
│ │ ├── button.ts
│ │ ├── button.styles.ts
│ │ └── button.test.ts
│ ├── modal/
│ └── tooltip/
├── styles/
│ └── tokens.css ← CSS custom properties
├── index.ts ← реэкспорт всех компонентов
└── types.ts
Базовый компонент
import { LitElement, html, css, PropertyValues } from 'lit'
import { customElement, property, state, query } from 'lit/decorators.js'
import { classMap } from 'lit/directives/class-map.js'
import { ifDefined } from 'lit/directives/if-defined.js'
@customElement('ui-button')
export class UiButton extends LitElement {
// Реактивные свойства — изменение вызывает перерендер
@property({ type: String })
variant: 'primary' | 'secondary' | 'ghost' = 'primary'
@property({ type: String })
size: 'sm' | 'md' | 'lg' = 'md'
@property({ type: Boolean, reflect: true })
disabled = false
@property({ type: Boolean })
loading = false
@property({ type: String })
type: 'button' | 'submit' | 'reset' = 'button'
// Внутреннее состояние — не атрибут, не публичное свойство
@state()
private _focused = false
// Доступ к DOM элементу внутри shadow
@query('button')
private _button!: HTMLButtonElement
static styles = css`
:host {
display: inline-flex;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
cursor: pointer;
font-family: inherit;
font-weight: 600;
border-radius: var(--ui-radius, 8px);
transition: background 0.15s, opacity 0.15s, transform 0.1s;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:active:not(:disabled) {
transform: scale(0.97);
}
/* Варианты */
:host([variant="primary"]) button,
button.variant--primary {
background: var(--ui-color-primary, #7000ff);
color: #fff;
}
:host([variant="secondary"]) button,
button.variant--secondary {
background: transparent;
border: 1.5px solid var(--ui-color-primary, #7000ff);
color: var(--ui-color-primary, #7000ff);
}
:host([variant="ghost"]) button,
button.variant--ghost {
background: transparent;
color: var(--ui-color-primary, #7000ff);
}
/* Размеры */
.size--sm { padding: 6px 14px; font-size: 13px; }
.size--md { padding: 10px 22px; font-size: 15px; }
.size--lg { padding: 14px 30px; font-size: 17px; }
/* Loading spinner */
.spinner {
width: 1em;
height: 1em;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`
render() {
const classes = classMap({
[`variant--${this.variant}`]: true,
[`size--${this.size}`]: true,
'is-loading': this.loading,
})
return html`
<button
class=${classes}
type=${this.type}
?disabled=${this.disabled || this.loading}
aria-disabled=${this.disabled || this.loading}
@focus=${() => { this._focused = true }}
@blur=${() => { this._focused = false }}
>
${this.loading ? html`<span class="spinner" aria-hidden="true"></span>` : ''}
<slot name="icon-start"></slot>
<slot></slot>
<slot name="icon-end"></slot>
</button>
`
}
// Lifecycle: после первого рендера
protected firstUpdated() {
this._button.addEventListener('click', this._handleClick)
}
// Lifecycle: после каждого обновления
protected updated(changedProps: PropertyValues) {
if (changedProps.has('disabled')) {
this.setAttribute('aria-disabled', String(this.disabled))
}
}
private _handleClick = (e: Event) => {
if (this.disabled || this.loading) {
e.preventDefault()
e.stopPropagation()
return
}
// Всплывающий кастомный ивент
this.dispatchEvent(new CustomEvent('ui-click', {
bubbles: true,
composed: true,
detail: { originalEvent: e },
}))
}
// Публичный метод (вызывается снаружи)
focus() {
this._button?.focus()
}
disconnectedCallback() {
super.disconnectedCallback()
this._button?.removeEventListener('click', this._handleClick)
}
}
declare global {
interface HTMLElementTagNameMap {
'ui-button': UiButton
}
}
Реактивные свойства подробнее
@customElement('ui-tabs')
export class UiTabs extends LitElement {
// reflect: true — синхронизирует JS свойство с HTML атрибутом
@property({ type: Number, reflect: true })
activeIndex = 0
// converter — кастомное преобразование строки атрибута в тип
@property({
converter: {
fromAttribute: (value: string | null) =>
value ? value.split(',').map(Number) : [],
toAttribute: (value: number[]) => value.join(','),
}
})
selectedIds: number[] = []
// hasChanged — пропуск лишних обновлений
@property({
hasChanged: (newVal: object, oldVal: object) =>
JSON.stringify(newVal) !== JSON.stringify(oldVal),
})
config: Record<string, unknown> = {}
}
Directives в шаблонах
import { repeat } from 'lit/directives/repeat.js'
import { cache } from 'lit/directives/cache.js'
import { asyncReplace } from 'lit/directives/async-replace.js'
import { ref } from 'lit/directives/ref.js'
render() {
return html`
<!-- repeat с key для эффективного diff -->
<ul>
${repeat(
this.items,
(item) => item.id, // key
(item) => html`<li>${item.name}</li>`
)}
</ul>
<!-- cache — не уничтожает DOM при переключении -->
${cache(this.showDetails
? html`<details-panel></details-panel>`
: html`<summary-view></summary-view>`
)}
<!-- ref — доступ к DOM элементу -->
<canvas ${ref(this._canvasRef)}></canvas>
`
}
Контроллеры (Reactive Controllers)
Переиспользуемая логика, независимая от компонента:
// controllers/mouse-controller.ts
import { ReactiveController, ReactiveControllerHost } from 'lit'
export class MouseController implements ReactiveController {
host: ReactiveControllerHost
x = 0
y = 0
constructor(host: ReactiveControllerHost) {
this.host = host
host.addController(this)
}
hostConnected() {
window.addEventListener('mousemove', this._onMouseMove)
}
hostDisconnected() {
window.removeEventListener('mousemove', this._onMouseMove)
}
private _onMouseMove = (e: MouseEvent) => {
this.x = e.clientX
this.y = e.clientY
this.host.requestUpdate() // триггерит перерендер компонента
}
}
// Использование в компоненте
@customElement('cursor-tracker')
class CursorTracker extends LitElement {
private mouse = new MouseController(this)
render() {
return html`
<p>Курсор: ${this.mouse.x}, ${this.mouse.y}</p>
`
}
}
Сборка библиотеки для публикации
// package.json
{
"name": "@myorg/ui-components",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./button": {
"import": "./dist/components/button/button.js",
"types": "./dist/components/button/button.d.ts"
}
},
"files": ["dist"],
"customElements": "custom-elements.json"
}
// vite.config.ts для библиотеки
export default defineConfig({
build: {
lib: {
entry: 'src/index.ts',
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
external: ['lit'], // не бандлить Lit — peer dependency
},
},
})
Сроки
3–5 компонентов с Lit, декораторами и базовой сборкой — 1 неделя. Полная библиотека из 10–15 компонентов с контроллерами, типами, storybook-документацией, тестами (Playwright) и npm-публикацией — 3–5 недель.







