最終更新:
Lit + Web Components実践: フレームワーク非依存のコンポーネント開発
はじめに
Litは、GoogleによってメンテナンスされているWeb Componentsライブラリです。わずか5KBの軽量さでありながら、リアクティビティ、宣言的テンプレート、そしてモダンな開発体験を提供します。
この記事では、Litを使った実践的なWeb Components開発を、基礎から応用まで包括的に解説します。
LitとWeb Componentsの関係
Web Componentsの基本
Web Componentsは、3つのブラウザ標準技術で構成されています。
- Custom Elements: 独自のHTML要素を定義
- Shadow DOM: カプセル化されたDOM
- HTML Templates: 再利用可能なマークアップ
Litが提供する価値
- リアクティブなプロパティとステート管理
- 宣言的なテンプレート記法
- 効率的な差分更新
- TypeScriptファーストの開発体験
- 最小限のボイラープレート
セットアップ
プロジェクトの作成
npm create vite@latest my-components -- --template lit-ts
cd my-components
npm install
基本的なコンポーネント
// src/components/simple-greeting.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('simple-greeting')
export class SimpleGreeting extends LitElement {
static styles = css`
:host {
display: block;
padding: 16px;
font-family: sans-serif;
}
.greeting {
color: #1e40af;
font-size: 24px;
font-weight: bold;
}
`;
@property()
name = 'World';
render() {
return html`
<div class="greeting">
Hello, ${this.name}!
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'simple-greeting': SimpleGreeting;
}
}
使用例:
<!DOCTYPE html>
<html>
<head>
<script type="module" src="/src/components/simple-greeting.ts"></script>
</head>
<body>
<simple-greeting name="Alice"></simple-greeting>
<simple-greeting name="Bob"></simple-greeting>
</body>
</html>
リアクティブプロパティとステート
プロパティの種類
// src/components/reactive-demo.ts
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('reactive-demo')
export class ReactiveDemo extends LitElement {
// 外部から設定可能なリアクティブプロパティ
@property({ type: String })
title = '';
// 数値型
@property({ type: Number })
count = 0;
// 真偽値型
@property({ type: Boolean })
disabled = false;
// オブジェクト/配列(hasChangedを使って比較)
@property({ type: Array })
items: string[] = [];
// 内部ステート(外部から設定不可)
@state()
private isExpanded = false;
render() {
return html`
<div>
<h2>${this.title}</h2>
<p>Count: ${this.count}</p>
<button ?disabled=${this.disabled} @click=${this.increment}>
Increment
</button>
<button @click=${this.toggleExpanded}>
${this.isExpanded ? 'Collapse' : 'Expand'}
</button>
${this.isExpanded
? html`
<ul>
${this.items.map((item) => html`<li>${item}</li>`)}
</ul>
`
: null}
</div>
`;
}
increment() {
this.count++;
}
toggleExpanded() {
this.isExpanded = !this.isExpanded;
}
}
カスタム変更検出
// src/components/custom-changed.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
interface User {
id: string;
name: string;
}
@customElement('user-card')
export class UserCard extends LitElement {
@property({
type: Object,
// カスタム変更検出: IDが変わったときのみ再レンダリング
hasChanged(newVal: User | undefined, oldVal: User | undefined) {
return newVal?.id !== oldVal?.id;
},
})
user?: User;
render() {
return html`
<div class="card">
${this.user ? html`<h3>${this.user.name}</h3>` : html`<p>No user</p>`}
</div>
`;
}
}
テンプレート記法
条件分岐
// src/components/conditional-render.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('status-badge')
export class StatusBadge extends LitElement {
@property()
status: 'success' | 'warning' | 'error' = 'success';
render() {
// 三項演算子
return html`
<div class="badge">
${this.status === 'success'
? html`<span class="icon">✓</span>`
: this.status === 'warning'
? html`<span class="icon">⚠</span>`
: html`<span class="icon">✗</span>`}
</div>
`;
}
}
ループ処理
// src/components/todo-list.ts
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
interface Todo {
id: string;
text: string;
completed: boolean;
}
@customElement('todo-list')
export class TodoList extends LitElement {
static styles = css`
.todo-item {
display: flex;
gap: 8px;
padding: 8px;
border-bottom: 1px solid #e5e7eb;
}
.completed {
text-decoration: line-through;
opacity: 0.6;
}
`;
@state()
private todos: Todo[] = [
{ id: '1', text: 'Learn Lit', completed: false },
{ id: '2', text: 'Build components', completed: false },
{ id: '3', text: 'Ship to production', completed: false },
];
render() {
return html`
<div class="todo-list">
${repeat(
this.todos,
(todo) => todo.id, // キー(パフォーマンス最適化)
(todo) => html`
<div class="todo-item">
<input
type="checkbox"
.checked=${todo.completed}
@change=${() => this.toggleTodo(todo.id)}
/>
<span class=${todo.completed ? 'completed' : ''}>
${todo.text}
</span>
<button @click=${() => this.removeTodo(todo.id)}>Delete</button>
</div>
`
)}
</div>
`;
}
toggleTodo(id: string) {
this.todos = this.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
}
removeTodo(id: string) {
this.todos = this.todos.filter((todo) => todo.id !== id);
}
}
クラスとスタイルのバインディング
// src/components/dynamic-styles.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
@customElement('dynamic-button')
export class DynamicButton extends LitElement {
static styles = css`
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.primary {
background: #3b82f6;
color: white;
}
.secondary {
background: #6b7280;
color: white;
}
.large {
font-size: 18px;
padding: 12px 24px;
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
@property()
variant: 'primary' | 'secondary' = 'primary';
@property({ type: Boolean })
large = false;
@property({ type: Boolean })
disabled = false;
@property()
color?: string;
render() {
const classes = {
primary: this.variant === 'primary',
secondary: this.variant === 'secondary',
large: this.large,
disabled: this.disabled,
};
const styles = {
backgroundColor: this.color || '',
};
return html`
<button class=${classMap(classes)} style=${styleMap(styles)}>
<slot></slot>
</button>
`;
}
}
イベント処理
イベントリスナー
// src/components/event-demo.ts
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
@customElement('event-demo')
export class EventDemo extends LitElement {
@state()
private clickCount = 0;
@state()
private inputValue = '';
render() {
return html`
<div>
<p>Clicks: ${this.clickCount}</p>
<button @click=${this.handleClick}>Click me</button>
<input
type="text"
.value=${this.inputValue}
@input=${this.handleInput}
placeholder="Type something..."
/>
<p>You typed: ${this.inputValue}</p>
<!-- イベント修飾子 -->
<form @submit=${this.handleSubmit}>
<input type="text" />
<button type="submit">Submit</button>
</form>
</div>
`;
}
handleClick() {
this.clickCount++;
}
handleInput(e: Event) {
this.inputValue = (e.target as HTMLInputElement).value;
}
handleSubmit(e: Event) {
e.preventDefault();
console.log('Form submitted');
}
}
カスタムイベントの発火
// src/components/custom-event.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('star-rating')
export class StarRating extends LitElement {
@property({ type: Number })
rating = 0;
@property({ type: Number })
maxStars = 5;
render() {
return html`
<div class="star-rating">
${Array.from({ length: this.maxStars }, (_, i) => i + 1).map(
(star) => html`
<button @click=${() => this.setRating(star)}>
${star <= this.rating ? '★' : '☆'}
</button>
`
)}
</div>
`;
}
setRating(rating: number) {
this.rating = rating;
// カスタムイベントを発火
this.dispatchEvent(
new CustomEvent('rating-changed', {
detail: { rating },
bubbles: true,
composed: true, // Shadow DOMの境界を超える
})
);
}
}
// 使用例
@customElement('rating-container')
export class RatingContainer extends LitElement {
@state()
private currentRating = 0;
render() {
return html`
<div>
<star-rating
.rating=${this.currentRating}
@rating-changed=${this.handleRatingChange}
></star-rating>
<p>Current rating: ${this.currentRating}</p>
</div>
`;
}
handleRatingChange(e: CustomEvent) {
this.currentRating = e.detail.rating;
}
}
ライフサイクル
// src/components/lifecycle-demo.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('lifecycle-demo')
export class LifecycleDemo extends LitElement {
@property()
message = '';
// 1. コンストラクタ
constructor() {
super();
console.log('1. constructor');
}
// 2. 接続された
connectedCallback() {
super.connectedCallback();
console.log('2. connectedCallback');
}
// 3. プロパティが変更される前
willUpdate(changedProperties: Map<PropertyKey, unknown>) {
console.log('3. willUpdate', changedProperties);
if (changedProperties.has('message')) {
console.log('message changed:', this.message);
}
}
// 4. レンダリング
render() {
console.log('4. render');
return html`<div>${this.message}</div>`;
}
// 5. 最初のレンダリング完了後(1回のみ)
firstUpdated() {
console.log('5. firstUpdated');
}
// 6. 更新完了後
updated(changedProperties: Map<PropertyKey, unknown>) {
console.log('6. updated', changedProperties);
}
// 7. 切断された
disconnectedCallback() {
super.disconnectedCallback();
console.log('7. disconnectedCallback');
}
}
スタイリング
Shadow DOMのスタイリング
// src/components/styled-card.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('styled-card')
export class StyledCard extends LitElement {
static styles = css`
/* :host - コンポーネント自身 */
:host {
display: block;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
/* :host()関数 - 特定の状態 */
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
/* :host-context() - 親要素の状態 */
:host-context(.dark-mode) {
background: #1f2937;
color: white;
}
.header {
padding: 16px;
background: #f3f4f6;
border-bottom: 1px solid #e5e7eb;
}
.content {
padding: 16px;
}
/* ::slotted() - スロットコンテンツ */
::slotted(h2) {
margin: 0;
font-size: 20px;
}
::slotted(p) {
margin: 8px 0 0;
color: #6b7280;
}
`;
@property({ type: Boolean, reflect: true })
disabled = false;
render() {
return html`
<div class="header">
<slot name="header"></slot>
</div>
<div class="content">
<slot></slot>
</div>
`;
}
}
使用例:
<styled-card>
<h2 slot="header">Card Title</h2>
<p>This is the card content.</p>
</styled-card>
CSS変数での外部カスタマイズ
// src/components/themeable-button.ts
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('themeable-button')
export class ThemeableButton extends LitElement {
static styles = css`
button {
/* CSS変数でカスタマイズ可能 */
background: var(--button-bg, #3b82f6);
color: var(--button-color, white);
padding: var(--button-padding, 8px 16px);
border: none;
border-radius: var(--button-radius, 4px);
font-size: var(--button-font-size, 16px);
cursor: pointer;
}
button:hover {
background: var(--button-hover-bg, #2563eb);
}
`;
render() {
return html` <button><slot></slot></button> `;
}
}
使用例:
<style>
themeable-button {
--button-bg: #10b981;
--button-hover-bg: #059669;
--button-radius: 8px;
}
</style>
<themeable-button>Custom Styled Button</themeable-button>
実践例: データテーブルコンポーネント
// src/components/data-table.ts
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
interface Column<T> {
key: keyof T;
header: string;
sortable?: boolean;
render?: (value: any, row: T) => unknown;
}
type SortDirection = 'asc' | 'desc' | null;
@customElement('data-table')
export class DataTable<T extends Record<string, any>> extends LitElement {
static styles = css`
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
th {
background: #f3f4f6;
font-weight: 600;
cursor: pointer;
user-select: none;
}
th:hover {
background: #e5e7eb;
}
.sort-indicator {
margin-left: 4px;
opacity: 0.5;
}
tr:hover {
background: #f9fafb;
}
`;
@property({ type: Array })
data: T[] = [];
@property({ type: Array })
columns: Column<T>[] = [];
@state()
private sortKey: keyof T | null = null;
@state()
private sortDirection: SortDirection = null;
render() {
const sortedData = this.getSortedData();
return html`
<table>
<thead>
<tr>
${this.columns.map(
(col) => html`
<th @click=${() => this.handleSort(col.key)}>
${col.header}
${this.sortKey === col.key
? html`<span class="sort-indicator">
${this.sortDirection === 'asc' ? '↑' : '↓'}
</span>`
: null}
</th>
`
)}
</tr>
</thead>
<tbody>
${repeat(
sortedData,
(row, index) => index,
(row) => html`
<tr>
${this.columns.map((col) => html`<td>${this.renderCell(col, row)}</td>`)}
</tr>
`
)}
</tbody>
</table>
`;
}
renderCell(col: Column<T>, row: T) {
const value = row[col.key];
return col.render ? col.render(value, row) : value;
}
handleSort(key: keyof T) {
if (this.sortKey === key) {
this.sortDirection =
this.sortDirection === 'asc'
? 'desc'
: this.sortDirection === 'desc'
? null
: 'asc';
} else {
this.sortKey = key;
this.sortDirection = 'asc';
}
if (this.sortDirection === null) {
this.sortKey = null;
}
}
getSortedData(): T[] {
if (!this.sortKey || !this.sortDirection) {
return this.data;
}
return [...this.data].sort((a, b) => {
const aVal = a[this.sortKey!];
const bVal = b[this.sortKey!];
if (aVal < bVal) return this.sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return this.sortDirection === 'asc' ? 1 : -1;
return 0;
});
}
}
// 使用例
@customElement('table-demo')
export class TableDemo extends LitElement {
@state()
private users = [
{ id: 1, name: 'Alice', age: 28, email: 'alice@example.com' },
{ id: 2, name: 'Bob', age: 34, email: 'bob@example.com' },
{ id: 3, name: 'Charlie', age: 22, email: 'charlie@example.com' },
];
private columns = [
{ key: 'id' as const, header: 'ID', sortable: true },
{ key: 'name' as const, header: 'Name', sortable: true },
{ key: 'age' as const, header: 'Age', sortable: true },
{
key: 'email' as const,
header: 'Email',
render: (email: string) => html`<a href="mailto:${email}">${email}</a>`,
},
];
render() {
return html`
<data-table .data=${this.users} .columns=${this.columns}></data-table>
`;
}
}
まとめ
Litを使ったWeb Components開発は、軽量でありながらモダンな開発体験を提供します。フレームワーク非依存であるため、React、Vue、Angularなど、どのプロジェクトでも利用できる再利用可能なコンポーネントを構築できます。
主なメリット:
- わずか5KBの軽量ライブラリ
- リアクティブなプロパティとステート管理
- TypeScriptファーストの開発体験
- Shadow DOMによるカプセル化
- フレームワーク非依存
デザインシステム、UIライブラリ、そしてマイクロフロントエンドなど、様々な用途でLitとWeb Componentsを活用できます。