📄

Web Componentsについて

2023/12/11に公開

はじめに

web componentsについてあまり深掘りしたことなかったので少し調べてみました。
アジェンダは以下の通りです

  • Web Componentsとは
  • メリット
  • デメリット
  • Web Componentsを採用しているサービス
  • Web Componentsの記述
  • まとめ

Web Componentsとは

Web Componentsは、Web開発において再利用可能でカスタムなHTML要素を作成するための技術の集合体です。主な構成要素として、「カスタム要素」「シャドウ DOM」「HTML テンプレート」が挙げられます。
これらを組み合わせることで、他のフレームワークやライブラリに依存せずにコンポーネントベースの開発が可能となります。

参考URL:
https://developer.mozilla.org/ja/docs/Web/API/Web_components

メリット

  1. 再利用性: Web Componentsは独自の要素を定義することで、これを他のプロジェクトやページで再利用できます。
  2. カプセル化: Shadow DOMを使用することで、コンポーネント内のスタイルや振る舞いを隠蔽し、外部からの影響を受けにくくします。
  3. フレームワーク非依存性: Web Componentsは標準のWeb技術で構築されており、他のフレームワークに依存せずに使用できます。(異なるフレームワーク間で再利用可能なコンポーネントが作成できる)

事例:
https://user-first.ikyu.co.jp/entry/2021/04/27/153000
Vue.js, Python テンプレート, ASP.NET 等のプラットフォームで 画面描画を行なっているため採用

デメリット

  1. ブラウザサポートのばらつき: 全ての主要ブラウザで完全にサポートされているわけではなく、特に古いブラウザでは一部の機能が利用できません。
    https://caniuse.com/?search=web components
  2. 学習コスト: 他の技術と比べて情報も少ないため、学習コストがかかる

Web Componentsを採用しているサービス

Googleの各種サービスで利用(Youtubeなど)、一休などの企業が採用しているようです。
※perplexityに質問すると、Google、Microsoft、GitHub、Salesforceなども利用していると情報が返ってきました。

Web Componentsの記述

<!-- src/index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Todo List</title>
</head>
<body>
  <todo-list></todo-list>
  <script type="module" src="./src/components/TodoInput.ts"></script>
  <script type="module" src="./src/components/TodoItem.ts"></script>
  <script type="module" src="./src/components/TodoList.ts"></script>
</body>
</html>
// Todo アイテムのリストを表示するためのコンポーネント。
// TodoInput コンポーネントと連携して新しい Todo アイテムをリストに追加できる。
class TodoList extends HTMLElement {
  private list: HTMLUListElement | null;

  constructor() {
    super();
    this.list = null;
  }

  connectedCallback() {
    this.render();
  }

  private render() {
    const shadow = this.attachShadow({ mode: 'open' });
    // :host は、Shadow DOM内のスタイル設定において、ルート要素(カスタムエレメント自体)に対するスタイルを指定するための擬似クラスです。
    shadow.innerHTML = `
      <style>
        :host {
          display: block;
          max-width: 400px;
          margin: auto;
          font-family: 'Arial', sans-serif;
        }

        h2 {
          color: #333;
        }

        ul {
          list-style: none;
          padding: 0;
        }
      </style>
      <h2>Todo List</h2>
      <todo-input></todo-input>
      <ul id="todoList"></ul>
    `;
    this.list = shadow.querySelector('#todoList');
    this.setupEventListeners();
  }

  private setupEventListeners() {
    const todoInput = this.shadowRoot?.querySelector('todo-input');
    if (todoInput) {
      todoInput.addEventListener('addTodo', (event: any) => this.addTodoItem(event.detail));
    }
  }

  private addTodoItem(todoText: string) {
    if (this.list) {
      const todoItem = document.createElement('todo-item');
      todoItem.text = todoText;
      // TodoItemの deleteTodo イベントが発生したときにTodoアイテムをリストから削除する。
      todoItem.addEventListener('deleteTodo', () => this.deleteTodoItem(todoItem));

      this.list.appendChild(todoItem);
    }
  }

  private deleteTodoItem(todoItem: HTMLElement) {
    if (this.list) {
      this.list.removeChild(todoItem);
    }
  }
}

customElements.define('todo-list', TodoList);
// src/components/TodoInput.ts
// 役割: 新しい Todo アイテムを追加するためのインプットフィールドとボタンを提供する入力コンポーネント。
class TodoInput extends HTMLElement {
  private input: HTMLInputElement | null;

  constructor() {
    super();
    this.input = null;
  }
  // connectedCallback() は、要素が DOM に追加されるたびに実行されます。
  connectedCallback() {
    this.render();
    this.setupEventListeners();
  }

  private render() {
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
        <style>
        input {
          padding: 8px;
          margin-right: 5px;
          border: 1px solid #ccc;
          border-radius: 4px;
          font-size: 14px;
        }

        button {
          padding: 8px 12px;
          background-color: #3498db;
          color: white;
          border: none;
          border-radius: 4px;
          cursor: pointer;
          font-size: 14px;
        }
      </style>
      <input type="text" id="todoInput" placeholder="Add a new todo" />
      <button id="addTodo">Add</button>
    `;
    this.input = shadow.querySelector('#todoInput');
  }

  private setupEventListeners() {
    const addButton = this.shadowRoot?.querySelector('#addTodo');
    if (addButton && this.input) {
      addButton.addEventListener('click', () => this.dispatchEvent(new CustomEvent('addTodo', { detail: this.input?.value })));
    }
  }
}

customElements.define('todo-input', TodoInput);
// 単一の Todo アイテムを表示するためのコンポーネント。
// Todo アイテムのテキストと Delete ボタンを含む。
class TodoItem extends HTMLElement {
  private todoText: string;

  set text(value: string) {
    this.todoText = value;
    this.render();
  }

  connectedCallback() {
    this.render();
  }

  private render() {
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host {
          display: block;
        }

        li {
          background-color: #f5f5f5;
          padding: 10px;
          margin-bottom: 5px;
          display: flex;
          justify-content: space-between;
          align-items: center;
        }

        button {
          background-color: #e74c3c;
          color: white;
          border: none;
          padding: 5px 10px;
          cursor: pointer;
        }
      </style>
      <li>${this.todoText} <button class="deleteButton">Delete</button></li>
    `;

    const deleteButton = shadow.querySelector('.deleteButton');
    if (deleteButton) {
      deleteButton.addEventListener('click', () => this.dispatchEvent(new CustomEvent('deleteTodo')));
    }
  }
}

customElements.define('todo-item', TodoItem);

画面表示

Shadow DOM を使用することで、各コンポーネントのスタイルがスコープ化され、外部のスタイルから影響を受けにくくなっている。

https://developer.mozilla.org/ja/docs/Web/API/Web_components/Using_custom_elements#ライフサイクルコールバックの使用

※開発環境については今回省略していますが、Parcelで実装してます。

まとめ

Web Componentsは柔軟性と再利用性を兼ね備えた開発アプローチです。
プロジェクトの要件に応じて適切に導入することができる可能性もあるため、一つの考えとして持っていくのが有効そうだなと思いました。

Discussion