T-CREATOR

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

初心者でも分かる 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つの主要技術で構成されています。

#技術名役割
1Custom Elements独自のHTML要素を定義
2Shadow DOMDOM とスタイルをカプセル化
3HTML 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 が予期しない場所に影響を与えることもあります。これらの問題を解決するために、多くの手法が考案されてきました。

#手法特徴課題
1BEM命名規則による管理記述が冗長になりがち
2CSS Modulesビルド時のクラス名変換ビルドツールに依存
3CSS-in-JSJavaScript でスタイル管理パフォーマンス懸念

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 開発の未来を担う重要な技術です。ぜひ実際のプロジェクトで活用し、その威力を体感してください。

関連リンク