初心者でも分かる Web Components 入門 - Custom Elements、Shadow DOM、HTML Templates 完全ガイド

現代のWeb開発において、コンポーネント指向の開発手法が主流となっています。React や Vue.js といったフレームワークが注目される一方で、Web標準技術として策定された Web Components という技術があることをご存知でしょうか。
Web Components は、ブラウザが標準でサポートする技術であり、フレームワークに依存することなく再利用可能なコンポーネントを作成できます。今回は、初心者の方でも理解できるよう、Web Components の3つの主要技術について詳しく解説していきます。
Web Components とは何か
Webブラウザ標準技術の力
Web Components は、W3Cによって標準化された技術仕様の総称です。この技術により、開発者は独自のHTML要素を作成し、カプセル化されたスタイルと機能を持つコンポーネントを構築できます。
Web Components は以下の3つの主要技術で構成されています。
# | 技術名 | 役割 |
---|---|---|
1 | Custom Elements | 独自のHTML要素を定義 |
2 | Shadow DOM | DOM とスタイルをカプセル化 |
3 | HTML Templates | 再利用可能なマークアップテンプレート |
最大の特徴は、これらがすべてブラウザのネイティブ機能として提供されていることです。つまり、外部ライブラリやフレームワークに依存することなく、強力なコンポーネントシステムを構築できるのです。
React や Vue.js との違い
多くの開発者が React や Vue.js に慣れ親しんでいますが、Web Components はこれらとは根本的に異なるアプローチを取ります。
フレームワーク vs ブラウザ標準
javascript// React コンポーネントの例
function MyButton({ text, onClick }) {
return <button onClick={onClick}>{text}</button>;
}
React では JSX という独自の記法と、複雑なビルドプロセスが必要になります。一方、Web Components は純粋なJavaScriptとHTMLで動作します。
javascript// Web Components の例
class MyButton extends HTMLElement {
connectedCallback() {
this.innerHTML = `<button>${this.textContent}</button>`;
}
}
customElements.define('my-button', MyButton);
相互運用性の違い
React で作成したコンポーネントは、基本的にReactアプリケーション内でしか使用できません。しかし、Web Components で作成したコンポーネントは、どんなフレームワークでも、さらには従来のHTMLページでも使用可能です。
これにより、異なるフレームワークを使用するプロジェクト間でのコンポーネント共有や、レガシーシステムへの段階的な導入が容易になります。
Custom Elements の基礎
Custom Elements の役割と重要性
Custom Elements は、Web Components の中核となる技術です。この機能により、開発者は <my-component>
のような独自のHTML要素を定義し、ブラウザに認識させることができます。
HTML標準要素 <div>
や <span>
と同じように、カスタム要素も完全なDOM要素として機能します。つまり、属性の設定、イベントの追加、CSS によるスタイリングなど、すべての標準的なDOM操作が可能になります。
html<!-- このような独自要素を作成できます -->
<user-profile name="田中太郎" age="30" role="developer"></user-profile>
<product-card title="MacBook Pro" price="¥299,800"></product-card>
基本的な書き方とライフサイクル
Custom Elements を作成するための基本的なステップを見ていきましょう。
まず、HTMLElement クラスを継承したクラスを定義します。
javascriptclass UserProfile extends HTMLElement {
constructor() {
super(); // 必ず最初に親クラスのコンストラクタを呼び出す
console.log('UserProfile 要素が作成されました');
}
}
次に、作成したクラスをカスタム要素として登録します。
javascript// customElements.define(要素名, クラス)
customElements.define('user-profile', UserProfile);
要素名には必ずハイフンが含まれている必要があります。これは既存のHTML要素との競合を避けるためのルールです。
ライフサイクルコールバック
Custom Elements には、要素の状態変化に応じて自動的に呼び出される特別なメソッドがあります。
javascriptclass UserProfile extends HTMLElement {
// 要素がDOMに追加されたとき
connectedCallback() {
console.log('要素がページに追加されました');
this.render();
}
// 要素がDOMから削除されたとき
disconnectedCallback() {
console.log('要素がページから削除されました');
this.cleanup();
}
// 監視対象の属性が変更されたとき
attributeChangedCallback(name, oldValue, newValue) {
console.log(`属性 ${name} が ${oldValue} から ${newValue} に変更されました`);
this.render();
}
}
属性の変更を監視するには、監視対象の属性を指定する必要があります。
javascriptclass UserProfile extends HTMLElement {
// 監視したい属性のリストを返す静的メソッド
static get observedAttributes() {
return ['name', 'age', 'role'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
}
実用的なカスタム要素の作成
理論だけでは理解が困難ですので、実際に動作するカスタム要素を作成してみましょう。ユーザーの情報を表示するコンポーネントを例にします。
まず、基本的な構造を定義します。
javascriptclass UserProfile extends HTMLElement {
static get observedAttributes() {
return ['name', 'age', 'role', 'avatar'];
}
constructor() {
super();
this.render();
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
}
次に、レンダリングロジックを実装します。
javascriptclass UserProfile extends HTMLElement {
// ... 前のコードに続いて
render() {
const name = this.getAttribute('name') || '名前未設定';
const age = this.getAttribute('age') || '年齢未設定';
const role = this.getAttribute('role') || '職種未設定';
const avatar = this.getAttribute('avatar') || '/images/default-avatar.png';
this.innerHTML = `
<div class="user-profile">
<img class="avatar" src="${avatar}" alt="${name}のアバター">
<div class="info">
<h3 class="name">${name}</h3>
<p class="age">年齢: ${age}歳</p>
<p class="role">職種: ${role}</p>
</div>
</div>
<style>
.user-profile {
display: flex;
align-items: center;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
margin-right: 16px;
}
.name {
margin: 0 0 8px 0;
color: #333;
}
.age, .role {
margin: 4px 0;
color: #666;
font-size: 14px;
}
</style>
`;
}
}
customElements.define('user-profile', UserProfile);
この要素は以下のように使用できます。
html<!DOCTYPE html>
<html>
<head>
<title>Custom Elements デモ</title>
</head>
<body>
<user-profile
name="田中太郎"
age="30"
role="フロントエンドエンジニア"
avatar="/images/tanaka.jpg">
</user-profile>
<user-profile
name="佐藤花子"
age="28"
role="UIデザイナー">
</user-profile>
</body>
</html>
Shadow DOM の仕組み
Shadow DOM が解決する問題
Web開発において、CSS のスタイル競合は長年の課題でした。グローバルな名前空間を共有するため、異なるコンポーネント間でクラス名が衝突したり、意図しないスタイルが適用されたりする問題が頻繁に発生します。
css/* コンポーネントA のスタイル */
.button {
background-color: blue;
color: white;
}
/* コンポーネントB のスタイル(後から読み込まれる) */
.button {
background-color: red; /* A のスタイルを上書きしてしまう */
color: black;
}
また、外部ライブラリの CSS が予期しない場所に影響を与えることもあります。これらの問題を解決するために、多くの手法が考案されてきました。
# | 手法 | 特徴 | 課題 |
---|---|---|---|
1 | BEM | 命名規則による管理 | 記述が冗長になりがち |
2 | CSS Modules | ビルド時のクラス名変換 | ビルドツールに依存 |
3 | CSS-in-JS | JavaScript でスタイル管理 | パフォーマンス懸念 |
Shadow DOM は、これらの問題をブラウザレベルで根本的に解決します。
Shadow DOM の作成と操作
Shadow DOM は、要素に対して隠された DOM ツリーを作成する技術です。この隠された DOM ツリー内のスタイルは外部に影響を与えず、外部のスタイルからも完全に分離されます。
基本的な Shadow DOM の作成方法を見てみましょう。
javascriptclass IsolatedComponent extends HTMLElement {
constructor() {
super();
// Shadow DOM を作成(モードは 'open' または 'closed')
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<div class="container">
<h2>Shadow DOM 内のコンテンツ</h2>
<p>このスタイルは外部に影響しません</p>
</div>
<style>
.container {
background-color: lightblue;
padding: 20px;
border-radius: 10px;
}
h2 {
color: darkblue;
margin-top: 0;
}
</style>
`;
}
}
customElements.define('isolated-component', IsolatedComponent);
Shadow DOM には2つのモードがあります。
javascript// open モード: shadowRoot プロパティでアクセス可能
const shadow1 = element.attachShadow({ mode: 'open' });
console.log(element.shadowRoot); // Shadow DOM にアクセスできる
// closed モード: shadowRoot プロパティは null
const shadow2 = element.attachShadow({ mode: 'closed' });
console.log(element.shadowRoot); // null が返される
CSS のカプセル化
Shadow DOM 内のスタイルがどのように分離されるかを、具体的な例で確認してみましょう。
まず、ページ全体のスタイルを定義します。
html<!DOCTYPE html>
<html>
<head>
<style>
/* ページ全体のスタイル */
.container {
background-color: red;
color: white;
}
h2 {
font-size: 24px;
color: yellow;
}
p {
font-style: italic;
}
</style>
</head>
<body>
<div class="container">
<h2>通常の DOM 要素</h2>
<p>このスタイルはページのCSS が適用されます</p>
</div>
<isolated-component></isolated-component>
</body>
</html>
Shadow DOM 内のスタイルは、外部のスタイルと完全に分離されます。
javascriptclass IsolatedComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<div class="container">
<h2>Shadow DOM 内の要素</h2>
<p>外部のスタイルは一切適用されません</p>
</div>
<style>
/* Shadow DOM 内でのみ有効なスタイル */
.container {
background-color: lightgreen;
padding: 15px;
border: 2px solid green;
}
h2 {
color: darkgreen;
font-size: 18px;
}
p {
font-weight: bold;
}
</style>
`;
}
}
セレクタの活用
Shadow DOM には特別な CSS セレクタが用意されており、その中でも :host
は最も重要です。
javascriptclass HostSelectorDemo extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<div class="content">
<slot></slot>
</div>
<style>
/* ホスト要素自体にスタイルを適用 */
:host {
display: block;
padding: 16px;
border: 1px solid #ccc;
border-radius: 4px;
}
/* 特定の属性を持つホスト要素にスタイル適用 */
:host([type="primary"]) {
background-color: blue;
color: white;
}
/* 特定の状態のホスト要素にスタイル適用 */
:host(:hover) {
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
</style>
`;
}
}
customElements.define('host-demo', HostSelectorDemo);
このコンポーネントは以下のように使用できます。
html<host-demo>通常のコンポーネント</host-demo>
<host-demo type="primary">プライマリコンポーネント</host-demo>
HTML Templates の活用
template タグの基本
HTML Templates は、ページ読み込み時には表示されない再利用可能なマークアップを定義するための仕組みです。<template>
タグ内のコンテンツは、JavaScriptを使って明示的にクローンするまで DOM に追加されません。
html<template id="user-card-template">
<div class="user-card">
<img class="avatar" src="" alt="">
<div class="info">
<h3 class="name"></h3>
<p class="email"></p>
</div>
</div>
<style>
.user-card {
display: flex;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
margin: 8px 0;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
margin-right: 16px;
}
.name {
margin: 0 0 8px 0;
}
.email {
margin: 0;
color: #666;
font-size: 14px;
}
</style>
</template>
template タグの重要な特徴として、以下の点があります。
- ページロード時に内容が表示されない
- CSS は適用されない(template 内では無効)
- JavaScript で content プロパティからアクセス可能
- DocumentFragment として効率的に DOM 操作が可能
テンプレートの複製と利用
テンプレートを使用するには、その内容をクローンして DOM に挿入する必要があります。
javascript// テンプレート要素を取得
const template = document.getElementById('user-card-template');
// テンプレートの内容をクローン
const clone = template.content.cloneNode(true);
// クローンした内容にデータを設定
clone.querySelector('.avatar').src = '/images/user1.jpg';
clone.querySelector('.avatar').alt = '田中太郎のアバター';
clone.querySelector('.name').textContent = '田中太郎';
clone.querySelector('.email').textContent = 'tanaka@example.com';
// DOM に挿入
document.body.appendChild(clone);
この方法では、テンプレートを何度でも再利用できます。各クローンは独立したDOM要素となるため、それぞれに異なるデータを設定できます。
javascript// 複数のユーザーカードを作成
const users = [
{ name: '田中太郎', email: 'tanaka@example.com', avatar: '/images/tanaka.jpg' },
{ name: '佐藤花子', email: 'sato@example.com', avatar: '/images/sato.jpg' },
{ name: '鈴木次郎', email: 'suzuki@example.com', avatar: '/images/suzuki.jpg' }
];
users.forEach(user => {
const clone = template.content.cloneNode(true);
clone.querySelector('.avatar').src = user.avatar;
clone.querySelector('.avatar').alt = `${user.name}のアバター`;
clone.querySelector('.name').textContent = user.name;
clone.querySelector('.email').textContent = user.email;
document.getElementById('user-list').appendChild(clone);
});
動的なコンテンツの挿入
HTML Templates をより柔軟に活用するため、データバインディングのような仕組みを実装してみましょう。
まず、プレースホルダーを含むテンプレートを定義します。
html<template id="product-card-template">
<div class="product-card">
<img class="product-image" src="{{imageUrl}}" alt="{{name}}">
<div class="product-info">
<h3 class="product-name">{{name}}</h3>
<p class="product-description">{{description}}</p>
<div class="product-price">¥{{price}}</div>
<button class="add-to-cart" data-product-id="{{id}}">
カートに追加
</button>
</div>
</div>
<style>
.product-card {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
margin: 16px 0;
}
.product-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.product-info {
padding: 16px;
}
.product-name {
margin: 0 0 8px 0;
color: #333;
}
.product-description {
color: #666;
font-size: 14px;
margin: 8px 0;
}
.product-price {
font-size: 18px;
font-weight: bold;
color: #e91e63;
margin: 12px 0;
}
.add-to-cart {
background-color: #2196f3;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.add-to-cart:hover {
background-color: #1976d2;
}
</style>
</template>
テンプレート処理を行うヘルパー関数を作成します。
javascriptfunction createElementFromTemplate(templateId, data) {
const template = document.getElementById(templateId);
let htmlString = template.innerHTML;
// プレースホルダーを実際の値に置換
Object.keys(data).forEach(key => {
const placeholder = new RegExp(`{{${key}}}`, 'g');
htmlString = htmlString.replace(placeholder, data[key]);
});
// 一時的な div 要素を作成してHTMLを設定
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlString;
// 最初の子要素を返す(template の内容)
return tempDiv.firstElementChild;
}
この関数を使用して動的にコンテンツを生成できます。
javascript// 商品データ
const products = [
{
id: 1,
name: 'MacBook Pro',
description: '高性能なプロフェッショナル向けノートパソコン',
price: 299800,
imageUrl: '/images/macbook-pro.jpg'
},
{
id: 2,
name: 'iPhone 15',
description: '最新のスマートフォン技術を搭載',
price: 124800,
imageUrl: '/images/iphone-15.jpg'
}
];
// 商品カードを生成
products.forEach(product => {
const productCard = createElementFromTemplate('product-card-template', product);
document.getElementById('product-list').appendChild(productCard);
});
3つの技術を組み合わせた実践例
シンプルなコンポーネント作成
これまで学んだ Custom Elements、Shadow DOM、HTML Templates の3つの技術を組み合わせて、実用的なコンポーネントを作成してみましょう。
最初に、基本的な構造を持つコンポーネントを作成します。
javascriptclass SimpleCard extends HTMLElement {
constructor() {
super();
// Shadow DOM を作成
this.attachShadow({ mode: 'open' });
// テンプレートを定義
this.template = document.createElement('template');
this.template.innerHTML = `
<div class="card">
<div class="card-header">
<slot name="header">デフォルトヘッダー</slot>
</div>
<div class="card-content">
<slot>デフォルトコンテンツ</slot>
</div>
<div class="card-footer">
<slot name="footer"></slot>
</div>
</div>
<style>
:host {
display: block;
margin: 16px 0;
}
.card {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card-header {
background-color: #f5f5f5;
padding: 16px;
border-bottom: 1px solid #ddd;
font-weight: bold;
}
.card-content {
padding: 16px;
}
.card-footer {
background-color: #f9f9f9;
padding: 12px 16px;
border-top: 1px solid #ddd;
font-size: 14px;
color: #666;
}
.card-footer:empty {
display: none;
}
</style>
`;
}
connectedCallback() {
// テンプレートをShadow DOM にクローン
const content = this.template.content.cloneNode(true);
this.shadowRoot.appendChild(content);
}
}
customElements.define('simple-card', SimpleCard);
この基本的なカードコンポーネントは以下のように使用できます。
html<simple-card>
<span slot="header">お知らせ</span>
<p>新しい機能が追加されました!ぜひお試しください。</p>
<span slot="footer">2024年8月1日更新</span>
</simple-card>
<simple-card>
<p>ヘッダーなしのシンプルなカードです。</p>
</simple-card>
再利用可能なボタンコンポーネント
続いて、より実用的なボタンコンポーネントを作成してみましょう。このコンポーネントでは、属性による状態管理とイベント処理を実装します。
javascriptclass CustomButton extends HTMLElement {
static get observedAttributes() {
return ['variant', 'size', 'disabled', 'loading'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.setupTemplate();
// イベントリスナーをバインド
this.handleClick = this.handleClick.bind(this);
}
setupTemplate() {
this.template = document.createElement('template');
this.template.innerHTML = `
<button class="custom-button">
<span class="button-content">
<slot></slot>
</span>
<span class="loading-spinner" hidden>
<svg width="16" height="16" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="6" stroke="currentColor"
stroke-width="2" fill="none" opacity="0.3"/>
<circle cx="8" cy="8" r="6" stroke="currentColor"
stroke-width="2" fill="none" stroke-dasharray="15"
stroke-dashoffset="15" stroke-linecap="round">
<animateTransform attributeName="transform" type="rotate"
values="0 8 8;360 8 8" dur="1s"
repeatCount="indefinite"/>
</circle>
</svg>
</span>
</button>
<style>
:host {
display: inline-block;
}
.custom-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border: 1px solid transparent;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
position: relative;
outline: none;
min-width: 64px;
}
/* バリエーション */
.custom-button.primary {
background-color: #2196f3;
color: white;
}
.custom-button.primary:hover {
background-color: #1976d2;
}
.custom-button.secondary {
background-color: transparent;
color: #2196f3;
border-color: #2196f3;
}
.custom-button.secondary:hover {
background-color: rgba(33, 150, 243, 0.1);
}
.custom-button.danger {
background-color: #f44336;
color: white;
}
.custom-button.danger:hover {
background-color: #d32f2f;
}
/* サイズ */
.custom-button.small {
padding: 4px 8px;
font-size: 12px;
min-width: 48px;
}
.custom-button.large {
padding: 12px 24px;
font-size: 16px;
min-width: 80px;
}
/* 状態 */
.custom-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.custom-button.loading .button-content {
opacity: 0;
}
.custom-button.loading .loading-spinner {
position: absolute;
display: inline-block;
}
.loading-spinner svg {
display: block;
}
</style>
`;
}
connectedCallback() {
const content = this.template.content.cloneNode(true);
this.shadowRoot.appendChild(content);
this.button = this.shadowRoot.querySelector('.custom-button');
this.button.addEventListener('click', this.handleClick);
this.updateAppearance();
}
disconnectedCallback() {
if (this.button) {
this.button.removeEventListener('click', this.handleClick);
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (this.button) {
this.updateAppearance();
}
}
updateAppearance() {
if (!this.button) return;
// バリエーション適用
const variant = this.getAttribute('variant') || 'primary';
this.button.className = `custom-button ${variant}`;
// サイズ適用
const size = this.getAttribute('size');
if (size) {
this.button.classList.add(size);
}
// disabled 状態
const disabled = this.hasAttribute('disabled');
this.button.disabled = disabled;
// loading 状態
const loading = this.hasAttribute('loading');
this.button.classList.toggle('loading', loading);
this.shadowRoot.querySelector('.loading-spinner').hidden = !loading;
}
handleClick(event) {
if (this.hasAttribute('disabled') || this.hasAttribute('loading')) {
event.preventDefault();
event.stopPropagation();
return;
}
// カスタムイベントをディスパッチ
this.dispatchEvent(new CustomEvent('custom-click', {
bubbles: true,
detail: {
variant: this.getAttribute('variant'),
size: this.getAttribute('size')
}
}));
}
}
customElements.define('custom-button', CustomButton);
このボタンコンポーネントは以下のように使用できます。
html<!DOCTYPE html>
<html>
<head>
<title>カスタムボタンデモ</title>
</head>
<body>
<h2>ボタンのバリエーション</h2>
<custom-button variant="primary">プライマリボタン</custom-button>
<custom-button variant="secondary">セカンダリボタン</custom-button>
<custom-button variant="danger">デンジャーボタン</custom-button>
<h2>ボタンのサイズ</h2>
<custom-button size="small">小さいボタン</custom-button>
<custom-button>標準ボタン</custom-button>
<custom-button size="large">大きいボタン</custom-button>
<h2>ボタンの状態</h2>
<custom-button disabled>無効化ボタン</custom-button>
<custom-button loading>読み込み中ボタン</custom-button>
<script>
// ボタンクリックイベントの処理
document.addEventListener('custom-click', (event) => {
console.log('カスタムボタンがクリックされました', event.detail);
// 読み込み状態のデモ
if (event.target.textContent.includes('読み込み中')) {
return; // 既に読み込み中の場合は何もしない
}
const button = event.target;
button.setAttribute('loading', '');
setTimeout(() => {
button.removeAttribute('loading');
alert('処理が完了しました!');
}, 2000);
});
</script>
</body>
</html>
データ表示コンポーネント
最後に、データ表示に特化したコンポーネントを作成します。このコンポーネントは、JSON データを受け取って整形された表示を生成します。
javascriptclass DataDisplay extends HTMLElement {
static get observedAttributes() {
return ['data', 'format', 'title'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.setupTemplate();
}
setupTemplate() {
this.template = document.createElement('template');
this.template.innerHTML = `
<div class="data-display">
<header class="header">
<h3 class="title"></h3>
<div class="controls">
<button class="refresh-btn" title="データを更新">
<svg width="16" height="16" viewBox="0 0 16 16">
<path d="M13.65 2.35a8 8 0 1 1-11.31 0"
stroke="currentColor" stroke-width="1.5"
fill="none" stroke-linecap="round"/>
<path d="M13.65 2.35L10.65 2.35"
stroke="currentColor" stroke-width="1.5"
fill="none" stroke-linecap="round"/>
<path d="M13.65 2.35L13.65 5.35"
stroke="currentColor" stroke-width="1.5"
fill="none" stroke-linecap="round"/>
</svg>
</button>
</div>
</header>
<div class="content"></div>
<div class="loading" hidden>
データを読み込み中...
</div>
<div class="error" hidden>
<p>データの読み込みに失敗しました</p>
<button class="retry-btn">再試行</button>
</div>
</div>
<style>
:host {
display: block;
margin: 16px 0;
}
.data-display {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
background: white;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background-color: #f8f9fa;
border-bottom: 1px solid #ddd;
}
.title {
margin: 0;
color: #333;
font-size: 16px;
}
.controls button {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
padding: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.controls button:hover {
background-color: #e9ecef;
}
.content {
padding: 16px;
}
.loading {
padding: 32px;
text-align: center;
color: #666;
}
.error {
padding: 16px;
background-color: #fff5f5;
border-top: 1px solid #fed7d7;
color: #c53030;
text-align: center;
}
.error p {
margin: 0 0 12px 0;
}
.retry-btn {
background-color: #c53030;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.retry-btn:hover {
background-color: #9b2c2c;
}
/* データ表示形式 */
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
}
.data-table th,
.data-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
.data-table th {
background-color: #f7fafc;
font-weight: 600;
color: #2d3748;
}
.data-list {
list-style: none;
padding: 0;
margin: 8px 0 0 0;
}
.data-list li {
padding: 8px 0;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
}
.data-list li:last-child {
border-bottom: none;
}
.data-key {
font-weight: 600;
color: #4a5568;
}
.data-value {
color: #2d3748;
}
</style>
`;
}
connectedCallback() {
const content = this.template.content.cloneNode(true);
this.shadowRoot.appendChild(content);
this.titleElement = this.shadowRoot.querySelector('.title');
this.contentElement = this.shadowRoot.querySelector('.content');
this.loadingElement = this.shadowRoot.querySelector('.loading');
this.errorElement = this.shadowRoot.querySelector('.error');
// イベントリスナー設定
this.shadowRoot.querySelector('.refresh-btn')
.addEventListener('click', () => this.refreshData());
this.shadowRoot.querySelector('.retry-btn')
.addEventListener('click', () => this.refreshData());
this.updateDisplay();
}
attributeChangedCallback() {
if (this.contentElement) {
this.updateDisplay();
}
}
updateDisplay() {
// タイトル更新
const title = this.getAttribute('title') || 'データ表示';
this.titleElement.textContent = title;
// データ表示
this.showLoading(false);
this.showError(false);
const dataAttr = this.getAttribute('data');
if (dataAttr) {
try {
const data = JSON.parse(dataAttr);
this.renderData(data);
} catch (error) {
this.showError(true, 'データの形式が正しくありません');
}
} else {
this.contentElement.innerHTML = '<p style="color: #666; font-style: italic;">データがありません</p>';
}
}
renderData(data) {
const format = this.getAttribute('format') || 'auto';
if (Array.isArray(data)) {
if (format === 'list' || (format === 'auto' && data.length <= 10)) {
this.renderAsList(data);
} else {
this.renderAsTable(data);
}
} else if (typeof data === 'object') {
this.renderAsKeyValue(data);
} else {
this.contentElement.innerHTML = `<p class="data-value">${data}</p>`;
}
}
renderAsTable(data) {
if (data.length === 0) {
this.contentElement.innerHTML = '<p style="color: #666;">データがありません</p>';
return;
}
const keys = Object.keys(data[0]);
const table = document.createElement('table');
table.className = 'data-table';
// ヘッダー作成
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
keys.forEach(key => {
const th = document.createElement('th');
th.textContent = key;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
// データ行作成
const tbody = document.createElement('tbody');
data.forEach(item => {
const row = document.createElement('tr');
keys.forEach(key => {
const td = document.createElement('td');
td.textContent = item[key] ?? '-';
row.appendChild(td);
});
tbody.appendChild(row);
});
table.appendChild(tbody);
this.contentElement.innerHTML = '';
this.contentElement.appendChild(table);
}
renderAsList(data) {
const ul = document.createElement('ul');
ul.className = 'data-list';
data.forEach((item, index) => {
const li = document.createElement('li');
if (typeof item === 'object') {
li.innerHTML = `
<span class="data-key">${index + 1}</span>
<span class="data-value">${JSON.stringify(item)}</span>
`;
} else {
li.innerHTML = `
<span class="data-key">${index + 1}</span>
<span class="data-value">${item}</span>
`;
}
ul.appendChild(li);
});
this.contentElement.innerHTML = '';
this.contentElement.appendChild(ul);
}
renderAsKeyValue(data) {
const ul = document.createElement('ul');
ul.className = 'data-list';
Object.entries(data).forEach(([key, value]) => {
const li = document.createElement('li');
li.innerHTML = `
<span class="data-key">${key}</span>
<span class="data-value">${
typeof value === 'object' ? JSON.stringify(value) : value
}</span>
`;
ul.appendChild(li);
});
this.contentElement.innerHTML = '';
this.contentElement.appendChild(ul);
}
showLoading(show) {
this.loadingElement.hidden = !show;
this.contentElement.style.display = show ? 'none' : 'block';
}
showError(show, message = 'エラーが発生しました') {
this.errorElement.hidden = !show;
this.contentElement.style.display = show ? 'none' : 'block';
if (show) {
this.errorElement.querySelector('p').textContent = message;
}
}
refreshData() {
this.showLoading(true);
this.showError(false);
// カスタムイベントでデータ更新を通知
this.dispatchEvent(new CustomEvent('data-refresh', {
bubbles: true,
detail: { component: this }
}));
// デモ用の遅延
setTimeout(() => {
this.showLoading(false);
this.updateDisplay();
}, 1000);
}
}
customElements.define('data-display', DataDisplay);
このデータ表示コンポーネントの使用例です。
html<!DOCTYPE html>
<html>
<head>
<title>データ表示コンポーネントデモ</title>
</head>
<body>
<h2>オブジェクトデータの表示</h2>
<data-display
title="ユーザー情報"
data='{"name": "田中太郎", "age": 30, "email": "tanaka@example.com", "role": "engineer"}'>
</data-display>
<h2>配列データの表示(テーブル形式)</h2>
<data-display
title="ユーザーリスト"
format="table"
data='[
{"name": "田中太郎", "age": 30, "department": "開発部"},
{"name": "佐藤花子", "age": 28, "department": "デザイン部"},
{"name": "鈴木次郎", "age": 35, "department": "営業部"}
]'>
</data-display>
<h2>配列データの表示(リスト形式)</h2>
<data-display
title="お知らせ"
format="list"
data='[
"新機能が追加されました",
"メンテナンスのお知らせ",
"アップデート情報"
]'>
</data-display>
<script>
// データ更新イベントの処理
document.addEventListener('data-refresh', (event) => {
console.log('データ更新が要求されました', event.detail.component);
// 実際のアプリケーションでは、ここでAPIからデータを取得する
// 例: fetchDataFromAPI().then(data => updateComponent(data));
});
</script>
</body>
</html>
まとめ
Web Components は、現代のWeb開発において非常に重要な技術です。Custom Elements、Shadow DOM、HTML Templates の3つの技術を組み合わせることで、フレームワークに依存しない強力なコンポーネントシステムを構築できます。
Web Components の主な利点
Web Components を活用することで、以下のようなメリットを享受できます。
標準技術による安定性
ブラウザの標準機能として提供されているため、特定のフレームワークのアップデートや廃止に左右されません。長期的な保守性が確保されます。
優れた相互運用性
React、Vue.js、Angular など、どのようなフレームワークでも使用可能です。また、従来のHTMLページにも簡単に組み込めます。
完全なカプセル化
Shadow DOM により、スタイルやDOMが完全に分離されるため、他のコンポーネントとの競合を心配する必要がありません。
シンプルな学習コスト
標準のJavaScript、HTML、CSS の知識があれば習得できます。特別なビルドツールや複雑な設定は不要です。
実践での活用場面
Web Components は以下のような場面で特に有効です。
# | 活用場面 | 具体例 |
---|---|---|
1 | デザインシステム | 企業全体で共通利用するUI コンポーネント |
2 | レガシーシステム更新 | 既存システムに段階的にモダンなUIを導入 |
3 | マイクロフロントエンド | 異なるチームが開発するコンポーネントの統合 |
4 | サードパーティライブラリ | フレームワークに依存しない汎用的なライブラリ |
今後の学習推奨事項
Web Components をさらに効果的に活用するために、以下の技術も併せて学習することをお勧めします。
TypeScript での型定義
コンポーネントの属性やプロパティに型を付けることで、開発時のエラーを減らし、保守性を向上させられます。
テスト手法
Jest や Web Test Runner を使用したコンポーネントのユニットテスト手法を学ぶことで、品質の高いコンポーネントを作成できます。
パフォーマンス最適化
大量のコンポーネントを効率的に管理するための技術や、レンダリング性能の改善手法を習得しましょう。
アクセシビリティ対応
ARIA 属性の適切な使用方法や、キーボードナビゲーション対応など、すべてのユーザーが利用しやすいコンポーネント作成を心がけることが重要です。
Web Components は、Web 開発の未来を担う重要な技術です。ぜひ実際のプロジェクトで活用し、その威力を体感してください。
関連リンク
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来