⚙️

Web Componentsを実装する時に役立った事

2025/04/15に公開

はじめに

Web Componentsを実際に使ってみた時に役に立ったことをTips的に書いていきます。
「Web Componentsとは」や「メリデメ」、「使い方」は素晴らしい記事がたくさんあるのでそちらを見ていただければと思います。

コンポーネント管理

Reactのプロジェクトっぽく管理したかったので、componentsディレクトリにコンポーネントを配置してfetchする方法としました。

index.html
components.js
components
├─ MyButton.html
└─ MyList.html

コンポーネントを使う側

<!-- index.html -->
<my-button>ボタン</my-button>

コンポーネントファイル

<!-- MyButton.html -->
<style>
  button {
    background-color: #9596f1;
    border: 2px solid #6d70ff;
    border-radius: 5px;
    color: white;
    padding: 15px 32px;
    text-align: center;
    display: inline-block;
    margin: 5px 0;
    cursor: pointer;
    min-width: 300px;
    max-width: 600px;
  }
</style>

<button>
  <slot></slot>
</button>

コンポーネントのローダー関数とカスタム要素を定義

/**
 * componentsディレクトリからHTMLを読み込む関数
 * @param {string} componentName 
 * @returns Node
 */
async function contentLoader(componentName) {
  const response = await fetch(`./components/${componentName}.html`);
  const html = await response.text();
  const template = document.createElement("template");
  template.innerHTML = html;
  const clone = template.content.cloneNode(true);
  return clone;
}

class MyButton extends HTMLElement {
  constructor() {
    super(); // 親クラスのコンストラクタを呼び出す
    this.attachShadow({ mode: "open" }); // Shadow DOMを作成
  }
  connectedCallback() {
    (async () => {
      const content = await contentLoader("MyButton"); // コンポーネントのHTMLを読み込む
      this.shadowRoot.appendChild(content); // Shadow DOMにコンテンツを追加
    })();
  }
}
customElements.define("my-button", MyButton); // カスタム要素を定義

カスタム要素で属性を使う

例)インラインスタイルでCSSを上書き

<!-- index.html -->
<my-button style="color: black">黒文字ボタン</my-button>
class MyButton extends HTMLElement {
  constructor() {
    super(); // 親クラスのコンストラクタを呼び出す
      this.attachShadow({ mode: "open" }); // Shadow DOMを作成
  }
  connectedCallback() {
    (async () => {
      const content = await contentLoader("MyButton"); // コンポーネントのHTMLを読み込む
      const button = this.shadowRoot.appendChild(content); // Shadow DOMにコンテンツを追加
      const inlineStyle = this.getAttribute("style"); // style属性からインラインスタイルを取得

      // インラインスタイルが存在する場合、適用
      if (inlineStyle) {
        button.setAttribute("style", inlineStyle);
      }
    })();
  }
}

なお、slotの中は普通に外部CSSが適用される模様

<!-- index.html -->
<style>
  .yellow {
    color: yellow;
  }
<style>
<my-button>
  <span class="yellow"></span>
  ボタン
</my-button>

●だけ黄色になる。

例)disableなボタンを作る

disabled属性は賛否あるようですがサンプルなので...

<!-- index.html -->
<my-button disabled>無効ボタン</my-button>
class MyButton extends HTMLElement {
  constructor() {
    super(); // 親クラスのコンストラクタを呼び出す
    this.attachShadow({ mode: "open" }); // Shadow DOMを作成
  }
  connectedCallback() {
    (async () => {
      const content = await contentLoader("MyButton"); // コンポーネントのHTMLを読み込む
      const button = this.shadowRoot.appendChild(content); // Shadow DOMにコンテンツを追加
      const isDisabled = this.hasAttribute("disabled"); // 属性の有無を確認

      // disabled属性が存在する場合、ボタンを無効化
      if (isDisabled) {
        button.setAttribute("disabled", "true");
        button.classList.add("disabled");
      }
    })();
  }
}

clickイベントを設定する

connectedCallback内でeventListenerを設定する

class MyButton extends HTMLElement {
  constructor() {
    super(); // 親クラスのコンストラクタを呼び出す
    this.attachShadow({ mode: "open" }); // Shadow DOMを作成
  }
  connectedCallback() {
    (async () => {
      const content = await contentLoader("MyButton"); // コンポーネントのHTMLを読み込む
      const button = this.shadowRoot.appendChild(content); // Shadow DOMにコンテンツを追加

      // クリックイベントを設定
      button.addEventListener("click", () => {
        alert("Button clicked!");
      });
    })();
  }
}

slotの中身を取得する

assignedNodesかassignedElementsでslotの中身を取得する
https://developer.mozilla.org/ja/docs/Web/API/HTMLSlotElement/assignedNodes
https://developer.mozilla.org/ja/docs/Web/API/HTMLSlotElement/assignedElements

class MyButton extends HTMLElement {
  constructor() {
    super(); // 親クラスのコンストラクタを呼び出す
    this.attachShadow({ mode: "open" }); // Shadow DOMを作成
  }
  connectedCallback() {
    (async () => {
      const content = await contentLoader("MyButton"); // コンポーネントのHTMLを読み込む
      const button = this.shadowRoot.appendChild(content); // Shadow DOMにコンテンツを追加

      // クリックしたボタンのslotのテキストを表示
      button.addEventListener("click", (e) => {
        const slot = e.currentTarget.querySelector("slot");
        const assignedNodes = slot.assignedNodes({ flatten: true });
        const buttonText = assignedNodes
          .map((el) => el.textContent.trim())
          .join("");
        alert(`${buttonText} Button clicked!`);
      });
    })();
  }
}

カスタム要素自体にCSSを設定する

例)横幅いっぱいのボタン

例えば、横幅いっぱいのボタンを作るとき

<!-- index.html -->
<section style="width: 100%">
    <my-button>横幅いっぱいボタン</my-button>
</section>

ダメな例

<!-- MyButton.html -->
<style>
  button {
    width: 100%;
  }
</style>
<button>
  <slot></slot>
</button>

親要素のsectionがwidth: 100%でも、<section> → <my-button> → <button> と孫要素になるので、buttonは横幅いっぱいにならない。
横幅いっぱいにならない

そこで、カスタム要素自体にwidth: 100%のCSSを設定したい。
このとき使う方法が:host

<!-- MyButton.html -->
<style>
  :host {
    width: 100%;
  }
  button {
    width: 100%;
  }
</style>
<button>
  <slot></slot>
</button>

横幅いっぱい

外部のCSSを一部、適用させたい

例)htmlやbodyに設定したフォント設定などを継承したいとき

html {
  font-size: 62.5%;
}
body {
  font-family: sans-serif;
}

これらをカスタム要素の中でも生かしたいとき、:hostを使って、カスタム要素自体にCSSを適用させる。

<!-- MyButton.html -->
<style>
  :host {
    font-family: inherit;
    font-size: inherit;
  }
  button {
    font-family: inherit;
    font-size: inherit;
  }
</style>
<button>
  <slot></slot>
</button>

※ このサンプルがbuttonタグなので、明示的にbuttonにもinheritを指定しないと適用されない。

:partで部分的に、外部CSSを適用

コンポーネント内の要素のpart属性に任意の名前を付けると、::part疑似要素を使って外部CSSを当てることができる。

<!-- index.html -->
<head>
  <title>Document</title>
  <script src="components.js" defer></script>
  <style>
    my-button::part(button-part) {
      box-shadow: 0 0 10px #000;
    }
  </style>
</head>

<body>
  <section>
    <my-button>ボタン</my-button>
  </section>
</body>
<!-- MyButton.html -->
<button part="button-part">
  <slot></slot>
</button>

CSS変数は使用可能

CSS変数はコンポーネントの中でも使用可能。

<!-- index.html -->
<head>
  <title>Document</title>
  <script src="components.js" defer></script>
  <style>
    :root {
        --primary-color: blue;
    }
  </style>
</head>

<body>
  <section>
    <my-button>ボタン</my-button>
  </section>
</body>
<!-- MyButton.html -->
<style>
  button {
    color: var(--primary-color);
  }
</style>
<button>
  <slot></slot>
</button>

配列をmapして、リストを作成

Reactでいう、こういう事がしたい。

const items = ['りんご', 'バナナ', 'ぶどう'];

return (
  <ul>
    {items.map(item => (
      <li key={item}>{item}</li>
    ))}
  </ul>
);

【パターン1】属性としてJSON文字列を渡す

<my-list items='["りんご", "バナナ", "ぶどう"]'></my-list>

observedAttributes()+ attributeChangedCallback()

observedAttributes()で指定した属性が変更された時、attributeChangedCallback()がブラウザによって呼び出される。
※ クラス内で関数を定義しないとブラウザに呼び出されても処理がないので何も起きない。

class MyList extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
  }
  connectedCallback() {
    (async () => {
      const content = await contentLoader("MyList");
      this.shadowRoot.appendChild(content);

      // 属性がすでにあればここで再レンダリング
      const itemsAttr = this.getAttribute("items");
      if (itemsAttr) {
         this.render(JSON.parse(itemsAttr));
      }
    })();
  }

  // 監視対象の属性を指定
  static get observedAttributes() {
    return ["items"];
  }

  // 属性が変更されたら発火する関数
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "items") {
    this.render(JSON.parse(newValue));
    }
  }

  // ulの中のliを生成
  render(items) {
    const ul = this.shadowRoot.querySelector("ul");
    if (!ul) return;
    ul.innerHTML = ""; // 一度、初期化

    for (const item of items) {
      const li = document.createElement("li");
      li.textContent = item;
      ul.appendChild(li);
    }
  }
}
customElements.define("my-list", MyList);

// => <my-list items="ここが変わったらattributeChagneCallback()が発火"></my-list>

【パターン2】JavaScriptからプロパティとして渡す

感想:色々できそうだけど、なかなかめんどくさい

今回、実際に使ってみたのは簡単なボタンをコンポーネント化してみただけだったから、CSSのclassでもいいかもって思っちゃいました。コンポーネント管理できるのはいいけど、結構記述量も必要だし状態管理と組み合わせて宣言的に書こうとしたら結構大変そう。状況的に標準APIが一番適しているって環境で、複雑な構造や仕組みの要素を多用するなら役に立ちそうな気はする。

Discussion