Web Components モダン開発ガイド 2025年版 - ブラウザネイティブからライブラリ活用まで
Web Componentsの基本からカスタムエレメント、Shadow DOM、ライフサイクル、パフォーマンス最適化、ライブラリとの統合まで実践的に解説。モダンWebアプリケーション開発の新しいアプローチを紹介します。
約5分で読めます
技術記事
実践的
この記事のポイント
Web Componentsの基本からカスタムエレメント、Shadow DOM、ライフサイクル、パフォーマンス最適化、ライブラリとの統合まで実践的に解説。モダンWebアプリケーション開発の新しいアプローチを紹介します。
この記事では、実践的なアプローチで技術的な課題を解決する方法を詳しく解説します。具体的なコード例とともに、ベストプラクティスを学ぶことができます。
はじめに
Web Componentsは、再利用可能なカスタムエレメントを作成するためのブラウザネイティブな技術群です。2025年現在、主要ブラウザでの対応が充実し、フレームワークに依存しない真の意味でのコンポーネント化が実現可能になっています。
Web Componentsの基本技術
技術スタックの概要
graph TB subgraph "Web Components API" CE[Custom Elements] SD[Shadow DOM] HT[HTML Templates] ES[ES Modules] end subgraph "Custom Element Features" CE --> LCH[Lifecycle Hooks] CE --> AR[Attribute Reflection] CE --> EV[Event Handling] end subgraph "Shadow DOM Features" SD --> SS[Style Scoping] SD --> SL[Slot Distribution] SD --> EN[Encapsulation] end subgraph "Integration" WC[Web Component] --> Framework[Any Framework] WC --> Vanilla[Vanilla JS] WC --> CDN[CDN Distribution] end
カスタムエレメントの基本実装
// basic-button.ts
class BasicButton extends HTMLElement {
private _disabled: boolean = false;
private _variant: 'primary' | 'secondary' | 'outline' = 'primary';
// 監視する属性を定義
static get observedAttributes() {
return ['disabled', 'variant', 'size'];
}
constructor() {
super();
// Shadow DOM の作成
this.attachShadow({ mode: 'open' });
this.render();
this.setupEventListeners();
}
// 属性変更時のコールバック
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (oldValue !== newValue) {
this.handleAttributeChange(name, newValue);
}
}
// DOM挿入時のコールバック
connectedCallback() {
this.setAttribute('role', 'button');
this.setAttribute('tabindex', '0');
// アクセシビリティ属性の設定
if (!this.hasAttribute('aria-label') && !this.textContent?.trim()) {
console.warn('Button should have aria-label or text content');
}
}
// DOM削除時のコールバック
disconnectedCallback() {
this.cleanup();
}
private render() {
const styles = `
<style>
:host {
display: inline-block;
font-family: system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
--primary-color: #007bff;
--secondary-color: #6c757d;
--outline-color: #007bff;
}
:host([disabled]) {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
:host([size="small"]) {
font-size: 12px;
padding: 4px 8px;
}
:host([size="large"]) {
font-size: 16px;
padding: 12px 24px;
}
.button {
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: inherit;
font-family: inherit;
cursor: inherit;
transition: inherit;
background: transparent;
color: inherit;
width: 100%;
}
:host([variant="primary"]) .button {
background-color: var(--primary-color);
color: white;
}
:host([variant="primary"]) .button:hover {
background-color: color-mix(in srgb, var(--primary-color) 90%, black);
}
:host([variant="secondary"]) .button {
background-color: var(--secondary-color);
color: white;
}
:host([variant="outline"]) .button {
border: 1px solid var(--outline-color);
color: var(--outline-color);
background-color: transparent;
}
:host([variant="outline"]) .button:hover {
background-color: var(--outline-color);
color: white;
}
.button:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
::slotted(.icon) {
margin-right: 8px;
vertical-align: middle;
}
</style>
`;
const template = `
${styles}
<button class="button" part="button">
<slot name="icon"></slot>
<slot></slot>
</button>
`;
this.shadowRoot!.innerHTML = template;
}
private setupEventListeners() {
const button = this.shadowRoot!.querySelector('.button');
button?.addEventListener('click', (e) => {
if (this.disabled) {
e.preventDefault();
e.stopPropagation();
return;
}
// カスタムイベントの発火
this.dispatchEvent(new CustomEvent('button-click', {
detail: {
variant: this.variant,
timestamp: Date.now()
},
bubbles: true
}));
});
// キーボードサポート
this.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
button?.click();
}
});
}
private handleAttributeChange(name: string, value: string) {
switch (name) {
case 'disabled':
this._disabled = value !== null;
break;
case 'variant':
this._variant = value as typeof this._variant;
break;
case 'size':
// CSSカスタムプロパティで対応済み
break;
}
// 必要に応じて再レンダリング
this.updateStyles();
}
private updateStyles() {
// 動的スタイル更新
const button = this.shadowRoot!.querySelector('.button');
if (button) {
button.classList.toggle('loading', this.hasAttribute('loading'));
}
}
private cleanup() {
// イベントリスナーのクリーンアップ
// メモリリークを防ぐため
}
// Public API
get disabled(): boolean {
return this._disabled;
}
set disabled(value: boolean) {
if (value) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
get variant() {
return this._variant;
}
set variant(value: typeof this._variant) {
this.setAttribute('variant', value);
}
// フォーカス管理
focus() {
this.shadowRoot!.querySelector('.button')?.focus();
}
blur() {
this.shadowRoot!.querySelector('.button')?.blur();
}
}
// カスタムエレメントの登録
customElements.define('basic-button', BasicButton);
// TypeScript型定義の拡張
declare global {
interface HTMLElementTagNameMap {
'basic-button': BasicButton;
}
}
高度なコンポーネント実装
複雑なUIコンポーネント
// data-table.ts
interface TableColumn {
key: string;
title: string;
sortable?: boolean;
width?: string;
align?: 'left' | 'center' | 'right';
formatter?: (value: any, row: any) => string;
}
interface TableData {
[key: string]: any;
}
class DataTable extends HTMLElement {
private _data: TableData[] = [];
private _columns: TableColumn[] = [];
private _sortColumn: string | null = null;
private _sortDirection: 'asc' | 'desc' = 'asc';
private _currentPage: number = 1;
private _pageSize: number = 10;
private _loading: boolean = false;
private resizeObserver: ResizeObserver;
private intersectionObserver: IntersectionObserver;
static get observedAttributes() {
return ['loading', 'page-size', 'virtual-scroll'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.setupObservers();
this.render();
}
connectedCallback() {
this.setAttribute('role', 'table');
this.setupEventListeners();
this.setupVirtualScrolling();
}
disconnectedCallback() {
this.resizeObserver?.disconnect();
this.intersectionObserver?.disconnect();
}
private setupObservers() {
// リサイズ監視
this.resizeObserver = new ResizeObserver(() => {
this.handleResize();
});
// 可視性監視(仮想スクロール用)
this.intersectionObserver = new IntersectionObserver(
(entries) => {
this.handleIntersection(entries);
},
{ threshold: 0.1 }
);
}
private render() {
const styles = `
<style>
:host {
display: block;
width: 100%;
overflow: hidden;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-family: system-ui, sans-serif;
--header-bg: #f5f5f5;
--border-color: #e0e0e0;
--hover-bg: #f9f9f9;
--selected-bg: #e3f2fd;
}
.table-container {
overflow: auto;
max-height: 400px;
}
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.header {
background-color: var(--header-bg);
position: sticky;
top: 0;
z-index: 1;
}
.header th {
padding: 12px 8px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid var(--border-color);
cursor: pointer;
user-select: none;
position: relative;
}
.header th.sortable:hover {
background-color: #e8e8e8;
}
.sort-indicator {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
opacity: 0.5;
}
.sort-indicator.active {
opacity: 1;
}
.body tr {
border-bottom: 1px solid var(--border-color);
}
.body tr:hover {
background-color: var(--hover-bg);
}
.body tr.selected {
background-color: var(--selected-bg);
}
.body td {
padding: 12px 8px;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #f3f3f3;
border-top: 2px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #fafafa;
border-top: 1px solid var(--border-color);
}
.virtual-row {
height: 48px;
display: flex;
align-items: center;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.table-container {
overflow-x: auto;
}
.header th,
.body td {
padding: 8px 4px;
font-size: 14px;
}
}
</style>
`;
const template = `
${styles}
<div class="table-wrapper" style="position: relative;">
<div class="table-container" id="tableContainer">
<table class="table">
<thead class="header" id="tableHeader"></thead>
<tbody class="body" id="tableBody"></tbody>
</table>
</div>
<div class="loading-overlay" id="loadingOverlay" style="display: none;">
<div class="loading-spinner"></div>
</div>
<div class="pagination" id="pagination" style="display: none;">
<div class="page-info">
<span id="pageInfo"></span>
</div>
<div class="page-controls">
<button id="prevPage">前へ</button>
<button id="nextPage">次へ</button>
</div>
</div>
</div>
`;
this.shadowRoot!.innerHTML = template;
}
private setupEventListeners() {
const header = this.shadowRoot!.getElementById('tableHeader');
const body = this.shadowRoot!.getElementById('tableBody');
// ソート機能
header?.addEventListener('click', (e) => {
const th = (e.target as Element).closest('th');
if (th && th.classList.contains('sortable')) {
const column = th.dataset.column;
if (column) {
this.handleSort(column);
}
}
});
// 行選択
body?.addEventListener('click', (e) => {
const tr = (e.target as Element).closest('tr');
if (tr) {
this.handleRowSelect(tr);
}
});
// ページネーション
this.shadowRoot!.getElementById('prevPage')?.addEventListener('click', () => {
if (this._currentPage > 1) {
this.goToPage(this._currentPage - 1);
}
});
this.shadowRoot!.getElementById('nextPage')?.addEventListener('click', () => {
const totalPages = Math.ceil(this._data.length / this._pageSize);
if (this._currentPage < totalPages) {
this.goToPage(this._currentPage + 1);
}
});
}
private setupVirtualScrolling() {
if (!this.hasAttribute('virtual-scroll')) return;
const container = this.shadowRoot!.getElementById('tableContainer');
const itemHeight = 48;
let startIndex = 0;
let endIndex = Math.min(this._data.length, Math.ceil(container!.clientHeight / itemHeight) + 5);
container?.addEventListener('scroll', () => {
const scrollTop = container.scrollTop;
startIndex = Math.floor(scrollTop / itemHeight);
endIndex = Math.min(this._data.length, startIndex + Math.ceil(container.clientHeight / itemHeight) + 5);
this.renderVirtualRows(startIndex, endIndex);
});
}
private renderVirtualRows(startIndex: number, endIndex: number) {
const tbody = this.shadowRoot!.getElementById('tableBody');
if (!tbody) return;
// 仮想化されたコンテンツの描画
const visibleData = this._data.slice(startIndex, endIndex);
tbody.innerHTML = '';
// 上部のスペーサー
if (startIndex > 0) {
const spacer = document.createElement('tr');
spacer.style.height = `${startIndex * 48}px`;
tbody.appendChild(spacer);
}
// 可視範囲のデータ
visibleData.forEach((row, index) => {
const tr = this.createTableRow(row, startIndex + index);
tbody.appendChild(tr);
});
// 下部のスペーサー
if (endIndex < this._data.length) {
const spacer = document.createElement('tr');
spacer.style.height = `${(this._data.length - endIndex) * 48}px`;
tbody.appendChild(spacer);
}
}
private createTableRow(rowData: TableData, index: number): HTMLTableRowElement {
const tr = document.createElement('tr');
tr.dataset.index = index.toString();
this._columns.forEach(column => {
const td = document.createElement('td');
td.style.width = column.width || 'auto';
td.style.textAlign = column.align || 'left';
let cellContent = rowData[column.key];
if (column.formatter) {
cellContent = column.formatter(cellContent, rowData);
}
td.innerHTML = cellContent || '';
tr.appendChild(td);
});
return tr;
}
private handleSort(column: string) {
if (this._sortColumn === column) {
this._sortDirection = this._sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this._sortColumn = column;
this._sortDirection = 'asc';
}
this.sortData();
this.updateSortIndicators();
this.renderTable();
// ソートイベントの発火
this.dispatchEvent(new CustomEvent('table-sort', {
detail: {
column: this._sortColumn,
direction: this._sortDirection
}
}));
}
private sortData() {
if (!this._sortColumn) return;
this._data.sort((a, b) => {
const aVal = a[this._sortColumn!];
const bVal = b[this._sortColumn!];
let comparison = 0;
if (aVal > bVal) comparison = 1;
if (aVal < bVal) comparison = -1;
return this._sortDirection === 'desc' ? -comparison : comparison;
});
}
private handleRowSelect(row: HTMLTableRowElement) {
const index = parseInt(row.dataset.index || '0');
const wasSelected = row.classList.contains('selected');
// 単一選択の場合、他の選択を解除
this.shadowRoot!.querySelectorAll('tr.selected').forEach(tr => {
tr.classList.remove('selected');
});
if (!wasSelected) {
row.classList.add('selected');
}
this.dispatchEvent(new CustomEvent('row-select', {
detail: {
index,
data: this._data[index],
selected: !wasSelected
}
}));
}
// Public API
setData(data: TableData[]) {
this._data = [...data];
this.renderTable();
}
setColumns(columns: TableColumn[]) {
this._columns = [...columns];
this.renderHeader();
this.renderTable();
}
setLoading(loading: boolean) {
this._loading = loading;
const overlay = this.shadowRoot!.getElementById('loadingOverlay');
if (overlay) {
overlay.style.display = loading ? 'flex' : 'none';
}
}
goToPage(page: number) {
this._currentPage = page;
this.renderTable();
this.updatePagination();
}
private renderHeader() {
const thead = this.shadowRoot!.getElementById('tableHeader');
if (!thead) return;
const headerRow = document.createElement('tr');
this._columns.forEach(column => {
const th = document.createElement('th');
th.textContent = column.title;
th.style.width = column.width || 'auto';
th.dataset.column = column.key;
if (column.sortable) {
th.classList.add('sortable');
th.innerHTML += '<span class="sort-indicator">↕</span>';
}
headerRow.appendChild(th);
});
thead.innerHTML = '';
thead.appendChild(headerRow);
}
private renderTable() {
const tbody = this.shadowRoot!.getElementById('tableBody');
if (!tbody) return;
// ページネーション対応
const startIndex = (this._currentPage - 1) * this._pageSize;
const endIndex = startIndex + this._pageSize;
const pageData = this._data.slice(startIndex, endIndex);
tbody.innerHTML = '';
pageData.forEach((row, index) => {
const tr = this.createTableRow(row, startIndex + index);
tbody.appendChild(tr);
});
this.updatePagination();
}
private updatePagination() {
const totalPages = Math.ceil(this._data.length / this._pageSize);
const pagination = this.shadowRoot!.getElementById('pagination');
const pageInfo = this.shadowRoot!.getElementById('pageInfo');
if (pagination && totalPages > 1) {
pagination.style.display = 'flex';
if (pageInfo) {
pageInfo.textContent = `${this._currentPage} / ${totalPages} ページ`;
}
} else if (pagination) {
pagination.style.display = 'none';
}
}
private updateSortIndicators() {
this.shadowRoot!.querySelectorAll('.sort-indicator').forEach(indicator => {
indicator.classList.remove('active');
indicator.textContent = '↕';
});
if (this._sortColumn) {
const th = this.shadowRoot!.querySelector(`th[data-column="${this._sortColumn}"]`);
const indicator = th?.querySelector('.sort-indicator');
if (indicator) {
indicator.classList.add('active');
indicator.textContent = this._sortDirection === 'asc' ? '↑' : '↓';
}
}
}
private handleResize() {
// レスポンシブ対応
this.updateColumnWidths();
}
private updateColumnWidths() {
const container = this.shadowRoot!.getElementById('tableContainer');
if (!container) return;
const containerWidth = container.clientWidth;
// 動的な列幅調整ロジック
}
private handleIntersection(entries: IntersectionObserverEntry[]) {
// 仮想スクロール最適化
entries.forEach(entry => {
if (entry.isIntersecting) {
// 可視範囲に入った行の処理
}
});
}
}
customElements.define('data-table', DataTable);
declare global {
interface HTMLElementTagNameMap {
'data-table': DataTable;
}
}
ライブラリとの統合
Lit Library との統合
// with-lit.ts
import { LitElement, html, css, property } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('enhanced-card')
export class EnhancedCard extends LitElement {
@property({ type: String })
title = '';
@property({ type: String })
description = '';
@property({ type: Boolean })
elevated = false;
@property({ type: String })
variant: 'default' | 'primary' | 'success' | 'warning' | 'danger' = 'default';
static styles = css`
:host {
display: block;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
--card-padding: 16px;
--card-gap: 12px;
}
:host([elevated]) {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
:host([variant="primary"]) {
border-left: 4px solid #007bff;
}
:host([variant="success"]) {
border-left: 4px solid #28a745;
}
:host([variant="warning"]) {
border-left: 4px solid #ffc107;
}
:host([variant="danger"]) {
border-left: 4px solid #dc3545;
}
.card {
background: white;
border: 1px solid #e0e0e0;
height: 100%;
display: flex;
flex-direction: column;
}
.header {
padding: var(--card-padding);
border-bottom: 1px solid #f0f0f0;
}
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.description {
margin: 8px 0 0 0;
color: #666;
font-size: 14px;
}
.content {
padding: var(--card-padding);
flex: 1;
}
.actions {
padding: var(--card-padding);
border-top: 1px solid #f0f0f0;
display: flex;
gap: var(--card-gap);
justify-content: flex-end;
}
::slotted([slot="action"]) {
margin-left: 8px;
}
`;
render() {
return html`
<div class="card">
${this.title || this.description ? html`
<div class="header">
${this.title ? html`<h3 class="title">${this.title}</h3>` : ''}
${this.description ? html`<p class="description">${this.description}</p>` : ''}
</div>
` : ''}
<div class="content">
<slot></slot>
</div>
<div class="actions">
<slot name="actions"></slot>
</div>
</div>
`;
}
// ライフサイクルメソッド
firstUpdated() {
// 初回レンダリング後の処理
this.setupInteractions();
}
updated(changedProperties: Map<string, any>) {
// プロパティ変更時の処理
if (changedProperties.has('variant')) {
this.updateVariantStyles();
}
}
private setupInteractions() {
// インタラクション機能の設定
this.addEventListener('click', this.handleCardClick);
}
private handleCardClick = (e: Event) => {
// カード全体のクリックイベント
this.dispatchEvent(new CustomEvent('card-click', {
detail: {
title: this.title,
variant: this.variant
},
bubbles: true
}));
}
private updateVariantStyles() {
// バリアント変更時のスタイル更新
this.requestUpdate();
}
}
React との統合
// react-integration.tsx
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
// Web Component のラッパー
interface DataTableProps {
data: any[];
columns: any[];
loading?: boolean;
pageSize?: number;
virtualScroll?: boolean;
onRowSelect?: (detail: any) => void;
onTableSort?: (detail: any) => void;
}
interface DataTableRef {
setData: (data: any[]) => void;
setColumns: (columns: any[]) => void;
setLoading: (loading: boolean) => void;
goToPage: (page: number) => void;
}
const DataTable = forwardRef<DataTableRef, DataTableProps>(({
data = [],
columns = [],
loading = false,
pageSize = 10,
virtualScroll = false,
onRowSelect,
onTableSort
}, ref) => {
const tableRef = useRef<any>(null);
useImperativeHandle(ref, () => ({
setData: (newData: any[]) => {
if (tableRef.current) {
tableRef.current.setData(newData);
}
},
setColumns: (newColumns: any[]) => {
if (tableRef.current) {
tableRef.current.setColumns(newColumns);
}
},
setLoading: (isLoading: boolean) => {
if (tableRef.current) {
tableRef.current.setLoading(isLoading);
}
},
goToPage: (page: number) => {
if (tableRef.current) {
tableRef.current.goToPage(page);
}
}
}));
useEffect(() => {
const table = tableRef.current;
if (!table) return;
// イベントリスナーの設定
const handleRowSelect = (e: CustomEvent) => {
onRowSelect?.(e.detail);
};
const handleTableSort = (e: CustomEvent) => {
onTableSort?.(e.detail);
};
table.addEventListener('row-select', handleRowSelect);
table.addEventListener('table-sort', handleTableSort);
return () => {
table.removeEventListener('row-select', handleRowSelect);
table.removeEventListener('table-sort', handleTableSort);
};
}, [onRowSelect, onTableSort]);
useEffect(() => {
if (tableRef.current) {
tableRef.current.setData(data);
}
}, [data]);
useEffect(() => {
if (tableRef.current) {
tableRef.current.setColumns(columns);
}
}, [columns]);
useEffect(() => {
if (tableRef.current) {
tableRef.current.setLoading(loading);
}
}, [loading]);
return (
<data-table
ref={tableRef}
page-size={pageSize}
virtual-scroll={virtualScroll ? '' : undefined}
/>
);
});
DataTable.displayName = 'DataTable';
// 使用例
const App: React.FC = () => {
const tableRef = useRef<DataTableRef>(null);
const [tableData, setTableData] = useState([]);
const [tableColumns] = useState([
{ key: 'id', title: 'ID', sortable: true, width: '80px' },
{ key: 'name', title: '名前', sortable: true },
{ key: 'email', title: 'メール', sortable: true },
{ key: 'createdAt', title: '作成日', sortable: true, formatter: (value: string) => new Date(value).toLocaleDateString() }
]);
const handleRowSelect = (detail: any) => {
console.log('選択された行:', detail);
};
const handleTableSort = (detail: any) => {
console.log('ソート:', detail);
};
const loadData = async () => {
// データの読み込み
const response = await fetch('/api/users');
const data = await response.json();
setTableData(data);
};
useEffect(() => {
loadData();
}, []);
return (
<div className="app">
<h1>ユーザー一覧</h1>
<DataTable
ref={tableRef}
data={tableData}
columns={tableColumns}
pageSize={20}
virtualScroll
onRowSelect={handleRowSelect}
onTableSort={handleTableSort}
/>
</div>
);
};
export default App;
パフォーマンス最適化
遅延読み込みと分割
// lazy-loading.ts
class LazyComponentLoader {
private static loadedComponents = new Set<string>();
private static loadingPromises = new Map<string, Promise<void>>();
static async loadComponent(tagName: string, moduleUrl: string): Promise<void> {
// 既に読み込み済みの場合は何もしない
if (this.loadedComponents.has(tagName)) {
return;
}
// 読み込み中の場合は既存のPromiseを返す
if (this.loadingPromises.has(tagName)) {
return this.loadingPromises.get(tagName)!;
}
// 新しい読み込みを開始
const loadPromise = this.loadComponentModule(tagName, moduleUrl);
this.loadingPromises.set(tagName, loadPromise);
try {
await loadPromise;
this.loadedComponents.add(tagName);
} finally {
this.loadingPromises.delete(tagName);
}
}
private static async loadComponentModule(tagName: string, moduleUrl: string): Promise<void> {
// 動的インポート
await import(moduleUrl);
// コンポーネントが登録されるまで待機
await customElements.whenDefined(tagName);
}
static async loadComponentsOnDemand() {
// Intersection Observer を使用してビューポートに入ったときに読み込み
const observer = new IntersectionObserver(async (entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const element = entry.target as HTMLElement;
const componentName = element.tagName.toLowerCase();
const moduleUrl = element.dataset.moduleUrl;
if (moduleUrl && !this.loadedComponents.has(componentName)) {
// プレースホルダーを表示
this.showPlaceholder(element);
try {
await this.loadComponent(componentName, moduleUrl);
// プレースホルダーを削除
this.hidePlaceholder(element);
} catch (error) {
console.error(`Failed to load component ${componentName}:`, error);
this.showError(element);
}
}
observer.unobserve(element);
}
}
}, { threshold: 0.1 });
// data-module-url属性を持つ要素を監視
document.querySelectorAll('[data-module-url]').forEach(el => {
observer.observe(el);
});
}
private static showPlaceholder(element: HTMLElement) {
const placeholder = document.createElement('div');
placeholder.className = 'component-placeholder';
placeholder.innerHTML = `
<div class="placeholder-content">
<div class="placeholder-spinner"></div>
<p>Loading component...</p>
</div>
`;
element.style.display = 'none';
element.parentNode?.insertBefore(placeholder, element);
}
private static hidePlaceholder(element: HTMLElement) {
const placeholder = element.parentNode?.querySelector('.component-placeholder');
if (placeholder) {
placeholder.remove();
}
element.style.display = '';
}
private static showError(element: HTMLElement) {
const errorDiv = document.createElement('div');
errorDiv.className = 'component-error';
errorDiv.textContent = 'Failed to load component';
const placeholder = element.parentNode?.querySelector('.component-placeholder');
if (placeholder) {
placeholder.replaceWith(errorDiv);
}
}
}
// コンポーネントバンドルの分割
class ComponentBundleManager {
private static bundles: Map<string, string[]> = new Map([
['ui-basic', ['basic-button', 'basic-input', 'basic-card']],
['ui-advanced', ['data-table', 'enhanced-card', 'modal-dialog']],
['data-viz', ['chart-component', 'graph-component', 'dashboard-widget']],
]);
static async loadBundle(bundleName: string): Promise<void> {
const components = this.bundles.get(bundleName);
if (!components) {
throw new Error(`Bundle ${bundleName} not found`);
}
// 並列読み込み
const loadPromises = components.map(componentName =>
LazyComponentLoader.loadComponent(
componentName,
`/components/${componentName}.js`
)
);
await Promise.all(loadPromises);
}
static preloadBundle(bundleName: string): void {
// Service Worker で事前読み込み
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.active?.postMessage({
type: 'PRELOAD_BUNDLE',
bundleName
});
});
}
}
}
// 使用例
document.addEventListener('DOMContentLoaded', () => {
// 遅延読み込みの開始
LazyComponentLoader.loadComponentsOnDemand();
// 重要なバンドルの事前読み込み
ComponentBundleManager.preloadBundle('ui-basic');
});
メモリ管理とクリーンアップ
// memory-management.ts
class ComponentMemoryManager {
private static activeComponents = new WeakMap<HTMLElement, ComponentCleanup>();
private static mutationObserver: MutationObserver;
static initialize() {
// DOM変更の監視
this.mutationObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
// 削除されたノードのクリーンアップ
mutation.removedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.cleanupRemovedElements(node as Element);
}
});
});
});
this.mutationObserver.observe(document.body, {
childList: true,
subtree: true
});
}
static registerComponent(element: HTMLElement, cleanup: ComponentCleanup) {
this.activeComponents.set(element, cleanup);
}
private static cleanupRemovedElements(element: Element) {
// Web Components のクリーンアップ
if (element.tagName.includes('-')) {
const cleanup = this.activeComponents.get(element as HTMLElement);
if (cleanup) {
cleanup.destroy();
}
}
// 子要素のクリーンアップ
element.querySelectorAll('*').forEach(child => {
if (child.tagName.includes('-')) {
const cleanup = this.activeComponents.get(child as HTMLElement);
if (cleanup) {
cleanup.destroy();
}
}
});
}
static destroy() {
this.mutationObserver?.disconnect();
}
}
interface ComponentCleanup {
destroy(): void;
}
// メモリリーク対策を組み込んだベースクラス
abstract class ManagedWebComponent extends HTMLElement implements ComponentCleanup {
private eventListeners: Array<{
element: Element | Window | Document;
event: string;
handler: EventListener;
options?: boolean | AddEventListenerOptions;
}> = [];
private timers: Set<number> = new Set();
private observers: Set<IntersectionObserver | ResizeObserver | MutationObserver> = new Set();
private animationFrames: Set<number> = new Set();
constructor() {
super();
ComponentMemoryManager.registerComponent(this, this);
}
// 安全なイベントリスナー追加
protected addEventListenerSafe(
element: Element | Window | Document,
event: string,
handler: EventListener,
options?: boolean | AddEventListenerOptions
) {
element.addEventListener(event, handler, options);
this.eventListeners.push({ element, event, handler, options });
}
// 安全なタイマー作成
protected setTimeoutSafe(callback: () => void, delay: number): number {
const id = window.setTimeout(() => {
this.timers.delete(id);
callback();
}, delay);
this.timers.add(id);
return id;
}
protected setIntervalSafe(callback: () => void, interval: number): number {
const id = window.setInterval(callback, interval);
this.timers.add(id);
return id;
}
// 安全なObserver作成
protected createIntersectionObserverSafe(
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit
): IntersectionObserver {
const observer = new IntersectionObserver(callback, options);
this.observers.add(observer);
return observer;
}
protected createResizeObserverSafe(
callback: ResizeObserverCallback
): ResizeObserver {
const observer = new ResizeObserver(callback);
this.observers.add(observer);
return observer;
}
// 安全なアニメーションフレーム
protected requestAnimationFrameSafe(callback: FrameRequestCallback): number {
const id = requestAnimationFrame((time) => {
this.animationFrames.delete(id);
callback(time);
});
this.animationFrames.add(id);
return id;
}
// クリーンアップの実装
destroy() {
// イベントリスナーの削除
this.eventListeners.forEach(({ element, event, handler, options }) => {
element.removeEventListener(event, handler, options);
});
this.eventListeners.length = 0;
// タイマーのクリア
this.timers.forEach(id => {
clearTimeout(id);
clearInterval(id);
});
this.timers.clear();
// Observer の切断
this.observers.forEach(observer => {
observer.disconnect();
});
this.observers.clear();
// アニメーションフレームのキャンセル
this.animationFrames.forEach(id => {
cancelAnimationFrame(id);
});
this.animationFrames.clear();
// 子クラスでのクリーンアップ
this.onDestroy();
}
protected abstract onDestroy(): void;
disconnectedCallback() {
// DOM から削除された時の自動クリーンアップ
this.destroy();
}
}
// 使用例
class SafeDataTable extends ManagedWebComponent {
private resizeObserver?: ResizeObserver;
connectedCallback() {
this.setupComponent();
}
private setupComponent() {
// 安全なイベントリスナー
this.addEventListenerSafe(window, 'resize', this.handleResize.bind(this));
// 安全なObserver
this.resizeObserver = this.createResizeObserverSafe(() => {
this.updateLayout();
});
this.resizeObserver.observe(this);
// 安全なタイマー
this.setIntervalSafe(() => {
this.updateData();
}, 30000);
}
private handleResize() {
// リサイズ処理
}
private updateLayout() {
// レイアウト更新
}
private updateData() {
// データ更新
}
protected onDestroy() {
// 追加のクリーンアップ処理
console.log('SafeDataTable destroyed');
}
}
customElements.define('safe-data-table', SafeDataTable);
// 初期化
ComponentMemoryManager.initialize();
テストとデバッグ
Web Components のテスト
// testing/component-test-utils.ts
import { fixture, html, expect, oneEvent } from '@open-wc/testing';
export class WebComponentTestUtils {
static async createFixture<T extends HTMLElement>(
template: string,
tagName: string
): Promise<T> {
const element = await fixture(html`${template}`);
return element.querySelector(tagName) as T;
}
static async waitForElement(selector: string, timeout = 5000): Promise<HTMLElement> {
return new Promise((resolve, reject) => {
const element = document.querySelector(selector);
if (element) {
resolve(element as HTMLElement);
return;
}
const observer = new MutationObserver(() => {
const foundElement = document.querySelector(selector);
if (foundElement) {
observer.disconnect();
clearTimeout(timeoutId);
resolve(foundElement as HTMLElement);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
const timeoutId = setTimeout(() => {
observer.disconnect();
reject(new Error(`Element ${selector} not found within ${timeout}ms`));
}, timeout);
});
}
static async waitForCustomElement(tagName: string, timeout = 5000): Promise<void> {
if (customElements.get(tagName)) {
return;
}
return Promise.race([
customElements.whenDefined(tagName),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Custom element ${tagName} not defined within ${timeout}ms`)), timeout)
)
]) as Promise<void>;
}
static simulateEvent(element: HTMLElement, eventType: string, eventData?: any) {
const event = new CustomEvent(eventType, {
detail: eventData,
bubbles: true,
cancelable: true
});
element.dispatchEvent(event);
return event;
}
static async expectEvent(
element: HTMLElement,
eventType: string,
trigger: () => void
): Promise<CustomEvent> {
const eventPromise = oneEvent(element, eventType);
trigger();
return await eventPromise as CustomEvent;
}
}
// テストケースの例
describe('BasicButton Component', () => {
let element: BasicButton;
beforeEach(async () => {
element = await WebComponentTestUtils.createFixture<BasicButton>(
'<basic-button>Click me</basic-button>',
'basic-button'
);
});
it('should render with default properties', () => {
expect(element.variant).to.equal('primary');
expect(element.disabled).to.be.false;
expect(element.textContent?.trim()).to.equal('Click me');
});
it('should handle attribute changes', async () => {
element.setAttribute('variant', 'secondary');
await element.updateComplete;
expect(element.variant).to.equal('secondary');
expect(element.getAttribute('variant')).to.equal('secondary');
});
it('should emit custom events on click', async () => {
const event = await WebComponentTestUtils.expectEvent(
element,
'button-click',
() => element.click()
);
expect(event.detail.variant).to.equal('primary');
expect(event.detail.timestamp).to.be.a('number');
});
it('should handle disabled state', async () => {
element.disabled = true;
await element.updateComplete;
expect(element.hasAttribute('disabled')).to.be.true;
// disabled状態でのクリックイベントが発火しないことを確認
let eventFired = false;
element.addEventListener('button-click', () => {
eventFired = true;
});
element.click();
await new Promise(resolve => setTimeout(resolve, 0));
expect(eventFired).to.be.false;
});
it('should support keyboard interaction', async () => {
const event = await WebComponentTestUtils.expectEvent(
element,
'button-click',
() => {
const keyEvent = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true
});
element.dispatchEvent(keyEvent);
}
);
expect(event).to.exist;
});
it('should have proper accessibility attributes', () => {
expect(element.getAttribute('role')).to.equal('button');
expect(element.getAttribute('tabindex')).to.equal('0');
});
});
// Visual Regression Testing
describe('BasicButton Visual Tests', () => {
it('should match visual snapshots', async () => {
const variants = ['primary', 'secondary', 'outline'];
const sizes = ['small', 'medium', 'large'];
for (const variant of variants) {
for (const size of sizes) {
const element = await WebComponentTestUtils.createFixture<BasicButton>(
`<basic-button variant="${variant}" size="${size}">Test Button</basic-button>`,
'basic-button'
);
// Visual regression test (実際のツールに応じて実装)
await expect(element).to.matchSnapshot(`button-${variant}-${size}.png`);
}
}
});
});
まとめ
Web Componentsは、2025年現在、モダンWebアプリケーション開発における重要な選択肢となっています。
主な利点
- フレームワーク非依存 - どのフレームワークでも使用可能
- 真のカプセル化 - Shadow DOMによるスタイルとDOMの分離
- 標準技術 - ブラウザネイティブサポート
- 再利用性 - 一度作成すれば様々なプロジェクトで活用
- 長期安定性 - Web標準に基づく安定した API
使用を検討すべきシーン
- デザインシステム - 組織全体で共有するUIコンポーネント
- マイクロフロントエンド - 異なるフレームワーク間での共有
- サードパーティウィジェット - 外部提供するコンポーネント
- 長期運用プロジェクト - フレームワーク移行を見据えたアプリケーション
Web Componentsを適切に活用することで、保守性が高く、再利用可能なWebアプリケーションの構築が可能になります。