Web Components完全ガイド:フレームワーク不要の再利用可能コンポーネント
Web Components完全ガイド:フレームワーク不要の再利用可能コンポーネント
Web Componentsは、フレームワークに依存しない再利用可能なコンポーネントを作成するための標準技術です。このガイドでは、基本から実践的な開発パターンまで徹底解説します。
Web Componentsとは?
Web Componentsは、カスタム要素を作成するための3つのWeb標準技術の総称です。
3つの主要技術
- Custom Elements: 独自のHTML要素を定義
- Shadow DOM: カプセル化されたDOMツリー
- HTML Templates: 再利用可能なHTMLフラグメント
主な特徴
- フレームワーク独立: React、Vue、Svelteなど、どこでも使える
- 標準技術: ブラウザネイティブサポート(ポリフィル不要)
- カプセル化: スタイルとロジックの衝突を防ぐ
- 再利用性: 一度作れば、どのプロジェクトでも使える
ブラウザサポート
2026年現在、すべてのモダンブラウザが完全にサポート:
- Chrome 54+
- Firefox 63+
- Safari 10.1+
- Edge 79+
Custom Elements入門
基本的なカスタム要素
// シンプルなカスタム要素
class MyElement extends HTMLElement {
constructor() {
super();
this.innerHTML = '<p>Hello, Web Components!</p>';
}
}
// カスタム要素の登録
customElements.define('my-element', MyElement);
<!-- 使用 -->
<my-element></my-element>
ライフサイクルコールバック
class LifecycleElement extends HTMLElement {
constructor() {
super();
console.log('Constructor called');
}
// 要素がDOMに挿入されたとき
connectedCallback() {
console.log('Element added to page');
this.render();
}
// 要素がDOMから削除されたとき
disconnectedCallback() {
console.log('Element removed from page');
this.cleanup();
}
// 要素が別のドキュメントに移動したとき
adoptedCallback() {
console.log('Element moved to new page');
}
// 監視対象の属性が変更されたとき
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
this.render();
}
// 監視する属性を指定
static get observedAttributes() {
return ['name', 'age'];
}
render() {
const name = this.getAttribute('name') || 'World';
const age = this.getAttribute('age') || 'unknown';
this.innerHTML = `
<div>
<p>Name: ${name}</p>
<p>Age: ${age}</p>
</div>
`;
}
cleanup() {
// イベントリスナーの削除など
}
}
customElements.define('lifecycle-element', LifecycleElement);
<lifecycle-element name="Alice" age="30"></lifecycle-element>
プロパティとメソッド
class CounterElement extends HTMLElement {
constructor() {
super();
this._count = 0;
}
// Getter/Setter
get count() {
return this._count;
}
set count(value) {
this._count = parseInt(value, 10);
this.render();
}
connectedCallback() {
this.render();
this.querySelector('button').addEventListener('click', () => this.increment());
}
// パブリックメソッド
increment() {
this.count++;
}
decrement() {
this.count--;
}
reset() {
this.count = 0;
}
render() {
this.innerHTML = `
<div>
<p>Count: ${this.count}</p>
<button>Increment</button>
</div>
`;
}
}
customElements.define('counter-element', CounterElement);
// JavaScriptから操作
const counter = document.querySelector('counter-element');
counter.count = 10;
counter.increment();
console.log(counter.count); // 11
Shadow DOM入門
Shadow DOMは、カプセル化されたDOMツリーを作成します。
基本的なShadow DOM
class ShadowElement extends HTMLElement {
constructor() {
super();
// Shadow Rootを作成
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 1rem;
border: 2px solid #333;
}
p {
color: blue;
font-weight: bold;
}
</style>
<p>This is in the Shadow DOM</p>
`;
}
}
customElements.define('shadow-element', ShadowElement);
Shadow DOMのモード
// open: JavaScript から shadowRoot にアクセス可能
this.attachShadow({ mode: 'open' });
const root = element.shadowRoot; // アクセス可能
// closed: shadowRoot にアクセス不可
this.attachShadow({ mode: 'closed' });
const root = element.shadowRoot; // null
スタイルのカプセル化
class StyledCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
/* :host - ホスト要素自身 */
:host {
display: block;
max-width: 300px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
}
/* :host() - 条件付きスタイル */
:host(.featured) {
border: 2px solid gold;
}
/* :host-context() - 親要素の状態に応じたスタイル */
:host-context(.dark-theme) {
background: #333;
color: white;
}
/* Shadow DOM内のスタイル */
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem;
}
.card-body {
padding: 1rem;
}
/* ::slotted() - スロットされた要素のスタイル */
::slotted(h2) {
margin: 0;
font-size: 1.5rem;
}
::slotted(p) {
color: #666;
line-height: 1.6;
}
</style>
<div class="card-header">
<slot name="header">Default Header</slot>
</div>
<div class="card-body">
<slot>Default Content</slot>
</div>
`;
}
}
customElements.define('styled-card', StyledCard);
<!-- 使用例 -->
<styled-card class="featured">
<h2 slot="header">Custom Header</h2>
<p>This is the card content</p>
</styled-card>
CSS変数での外部制御
class ThemeableButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
--button-bg: #3b82f6;
--button-color: white;
--button-padding: 0.5rem 1rem;
--button-radius: 4px;
}
button {
background: var(--button-bg);
color: var(--button-color);
padding: var(--button-padding);
border: none;
border-radius: var(--button-radius);
cursor: pointer;
font-size: 1rem;
}
button:hover {
opacity: 0.9;
}
</style>
<button><slot>Click me</slot></button>
`;
}
}
customElements.define('themeable-button', ThemeableButton);
<style>
/* 外部からCSS変数で制御 */
themeable-button {
--button-bg: #ef4444;
--button-padding: 1rem 2rem;
}
</style>
<themeable-button>Custom Button</themeable-button>
HTML Templates
テンプレートの定義と使用
<!-- テンプレート定義 -->
<template id="user-card-template">
<style>
.user-card {
border: 1px solid #ddd;
padding: 1rem;
border-radius: 8px;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
}
</style>
<div class="user-card">
<img class="avatar" src="" alt="">
<h3 class="name"></h3>
<p class="bio"></p>
</div>
</template>
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// テンプレートをクローン
const template = document.getElementById('user-card-template');
const content = template.content.cloneNode(true);
this.shadowRoot.appendChild(content);
}
connectedCallback() {
this.updateContent();
}
attributeChangedCallback() {
this.updateContent();
}
static get observedAttributes() {
return ['name', 'avatar', 'bio'];
}
updateContent() {
const name = this.getAttribute('name');
const avatar = this.getAttribute('avatar');
const bio = this.getAttribute('bio');
this.shadowRoot.querySelector('.name').textContent = name;
this.shadowRoot.querySelector('.avatar').src = avatar;
this.shadowRoot.querySelector('.bio').textContent = bio;
}
}
customElements.define('user-card', UserCard);
<user-card
name="Alice"
avatar="https://i.pravatar.cc/64?img=1"
bio="Software Engineer"
></user-card>
実践的なコンポーネント例
モーダルダイアログ
class ModalDialog extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
}
:host([open]) {
display: flex;
align-items: center;
justify-content: center;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
}
.dialog {
position: relative;
background: white;
border-radius: 8px;
padding: 2rem;
max-width: 500px;
max-height: 80vh;
overflow: auto;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
}
</style>
<div class="overlay" part="overlay"></div>
<div class="dialog" part="dialog">
<button class="close" aria-label="Close">×</button>
<slot></slot>
</div>
`;
}
connectedCallback() {
const overlay = this.shadowRoot.querySelector('.overlay');
const closeBtn = this.shadowRoot.querySelector('.close');
overlay.addEventListener('click', () => this.close());
closeBtn.addEventListener('click', () => this.close());
// Escキーで閉じる
this._handleEscape = (e) => {
if (e.key === 'Escape' && this.hasAttribute('open')) {
this.close();
}
};
document.addEventListener('keydown', this._handleEscape);
}
disconnectedCallback() {
document.removeEventListener('keydown', this._handleEscape);
}
open() {
this.setAttribute('open', '');
this.dispatchEvent(new CustomEvent('modal-open'));
}
close() {
this.removeAttribute('open');
this.dispatchEvent(new CustomEvent('modal-close'));
}
}
customElements.define('modal-dialog', ModalDialog);
<button id="open-modal">Open Modal</button>
<modal-dialog id="my-modal">
<h2>Modal Title</h2>
<p>This is the modal content.</p>
</modal-dialog>
<script>
const modal = document.getElementById('my-modal');
document.getElementById('open-modal').addEventListener('click', () => {
modal.open();
});
modal.addEventListener('modal-close', () => {
console.log('Modal closed');
});
</script>
タブコンポーネント
class TabsElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._activeTab = 0;
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
render() {
const tabs = Array.from(this.querySelectorAll('[slot^="tab-"]'));
const panels = Array.from(this.querySelectorAll('[slot^="panel-"]'));
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.tab-list {
display: flex;
border-bottom: 2px solid #e5e7eb;
gap: 0.5rem;
}
.tab {
padding: 0.75rem 1.5rem;
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #6b7280;
position: relative;
}
.tab[aria-selected="true"] {
color: #3b82f6;
}
.tab[aria-selected="true"]::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: #3b82f6;
}
.panel {
padding: 1.5rem 0;
}
.panel[hidden] {
display: none;
}
</style>
<div class="tab-list" role="tablist">
${tabs.map((_, i) => `
<button
class="tab"
role="tab"
aria-selected="${i === this._activeTab}"
aria-controls="panel-${i}"
id="tab-${i}"
>
<slot name="tab-${i}"></slot>
</button>
`).join('')}
</div>
${panels.map((_, i) => `
<div
class="panel"
role="tabpanel"
id="panel-${i}"
aria-labelledby="tab-${i}"
${i !== this._activeTab ? 'hidden' : ''}
>
<slot name="panel-${i}"></slot>
</div>
`).join('')}
`;
}
setupEventListeners() {
this.shadowRoot.querySelectorAll('.tab').forEach((tab, index) => {
tab.addEventListener('click', () => this.setActiveTab(index));
});
}
setActiveTab(index) {
this._activeTab = index;
this.render();
this.setupEventListeners();
this.dispatchEvent(new CustomEvent('tab-change', { detail: { index } }));
}
}
customElements.define('tabs-element', TabsElement);
<tabs-element>
<span slot="tab-0">Tab 1</span>
<span slot="tab-1">Tab 2</span>
<span slot="tab-2">Tab 3</span>
<div slot="panel-0">
<h3>Panel 1</h3>
<p>Content for tab 1</p>
</div>
<div slot="panel-1">
<h3>Panel 2</h3>
<p>Content for tab 2</p>
</div>
<div slot="panel-2">
<h3>Panel 3</h3>
<p>Content for tab 3</p>
</div>
</tabs-element>
Litフレームワーク
Litは、Web Componentsを簡単に作成できる軽量ライブラリです。
セットアップ
npm install lit
基本的なLitコンポーネント
// my-element.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('my-element')
export class MyElement extends LitElement {
static styles = css`
:host {
display: block;
padding: 1rem;
border: 1px solid #ddd;
}
h1 {
color: var(--title-color, #333);
}
`;
@property({ type: String })
name = 'World';
@property({ type: Number })
count = 0;
render() {
return html`
<h1>Hello, ${this.name}!</h1>
<p>Count: ${this.count}</p>
<button @click=${this._increment}>Increment</button>
`;
}
private _increment() {
this.count++;
}
}
Litの高度な機能
import { LitElement, html, css } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { repeat } from 'lit/directives/repeat.js';
interface Todo {
id: number;
text: string;
completed: boolean;
}
@customElement('todo-list')
export class TodoList extends LitElement {
static styles = css`
.todo-item {
padding: 0.5rem;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
gap: 0.5rem;
}
.completed {
text-decoration: line-through;
color: #999;
}
`;
@property({ type: Array })
todos: Todo[] = [];
@state()
private _newTodoText = '';
@query('#new-todo')
private _input!: HTMLInputElement;
render() {
return html`
<div>
<input
id="new-todo"
.value=${this._newTodoText}
@input=${this._handleInput}
@keypress=${this._handleKeyPress}
placeholder="Add a todo"
/>
<button @click=${this._addTodo}>Add</button>
</div>
<div>
${repeat(
this.todos,
(todo) => todo.id,
(todo) => html`
<div
class=${classMap({
'todo-item': true,
'completed': todo.completed,
})}
style=${styleMap({
opacity: todo.completed ? '0.6' : '1',
})}
>
<input
type="checkbox"
.checked=${todo.completed}
@change=${() => this._toggleTodo(todo.id)}
/>
<span>${todo.text}</span>
<button @click=${() => this._deleteTodo(todo.id)}>Delete</button>
</div>
`
)}
</div>
`;
}
private _handleInput(e: Event) {
this._newTodoText = (e.target as HTMLInputElement).value;
}
private _handleKeyPress(e: KeyboardEvent) {
if (e.key === 'Enter') {
this._addTodo();
}
}
private _addTodo() {
if (!this._newTodoText.trim()) return;
this.todos = [
...this.todos,
{
id: Date.now(),
text: this._newTodoText,
completed: false,
},
];
this._newTodoText = '';
this._input.focus();
}
private _toggleTodo(id: number) {
this.todos = this.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
}
private _deleteTodo(id: number) {
this.todos = this.todos.filter((todo) => todo.id !== id);
}
}
フレームワークとの統合
React
// use-web-component.tsx
import { useEffect, useRef } from 'react';
interface CounterElementProps {
count?: number;
onIncrement?: () => void;
}
export function CounterElement({ count = 0, onIncrement }: CounterElementProps) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const handleIncrement = () => {
onIncrement?.();
};
element.addEventListener('increment', handleIncrement);
return () => element.removeEventListener('increment', handleIncrement);
}, [onIncrement]);
useEffect(() => {
if (ref.current) {
(ref.current as any).count = count;
}
}, [count]);
return <counter-element ref={ref} />;
}
Vue
<template>
<counter-element
ref="counter"
:count="count"
@increment="handleIncrement"
/>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
const counter = ref<HTMLElement>();
const count = ref(0);
const handleIncrement = () => {
count.value++;
};
onMounted(() => {
// Web Componentの初期化
});
</script>
Svelte
<script lang="ts">
import { onMount } from 'svelte';
let counter: HTMLElement;
let count = 0;
onMount(() => {
counter.addEventListener('increment', () => {
count++;
});
});
$: if (counter) {
counter.count = count;
}
</script>
<counter-element bind:this={counter} />
まとめ
Web Componentsは、フレームワークに依存しない再利用可能なコンポーネントを作成する強力な技術です。
主な利点
- フレームワーク独立: どこでも使える
- 標準技術: 長期的に安定
- カプセル化: スタイル衝突を防ぐ
- 軽量: ランタイムが不要
いつ使うべきか
- デザインシステム: 複数プロジェクトで共有するコンポーネント
- ウィジェット: サードパーティに提供するUI部品
- マイクロフロントエンド: 異なるフレームワーク間の統合
Web Componentsで、真にポータブルなコンポーネントライブラリを構築しましょう。