👌

とりあえず実装してみるWeb Components

2022/03/15に公開

なにかと話題になるWeb Components
個人的に意外と実装する機会が少ないので、とりあえず手を動かして触ってみようというのが今回の趣旨です。

Web Componentsとは

そもそもWeb Componentsとは?という話。

https://developer.mozilla.org/ja/docs/Web/Web_Components

ウェブコンポーネントは、再利用可能なカスタム要素を作成し、ウェブアプリの中で利用するための、一連のテクノロジーです。コードの他の部分から独立した、カプセル化された機能を使って実現します。

MDNからの引用です。
キーワードは再利用カプセル化ですね。

Web Componentsを構成する要素

Web Componentsは以下の3つの主要な技術からなります。

  • カスタム要素
  • Shadow DOM
  • HTMLテンプレート

これら3つの技術を組み合わせることによって、カプセル化された再利用可能なコンポーネントを作成することができます。では、順にみていきましょう。

カスタム要素

まずはカスタム要素について見ていきましょう。
その名の通りカスタムな要素、つまり独自のHTML要素を定義することができます。

html
<my-element></my-element>

好きな名前で上記のようなタグを定義できます。
注意点としてカスタム要素の名前にはハイフン(-)を1つ以上含む必要があります。
これはブラウザに組み込まれている標準のHTMLタグと区別するためです。

カスタム要素を作成するためにはHTMLElementを拡張したクラスを作成してカスタム要素の定義をする必要があります。

javascript
// HTMLElementを拡張したクラスの作成
class MyElement extends HTMLElement {
  constructor() {
    super();
  }
  //...
}

// カスタム要素の定義
customElements.define("my-element", MyElement);

カスタム要素には重要な概念としてライフサイクルコールバックと呼ばれるメソッドが存在します。
ライフサイクルとは、コンポーネントが作成されてから削除されるまでの過程のことで、その間にいくつかのメソッドが呼び出されます。

  • constructor
    → ライフサイクルコールバックではなくclassに備わっているものですが、一応ここに記載します。
    → クラスのインスタンスが作成されたタイミングで呼び出されます(1度だけ)。
  • connectedCallback
    → カスタム要素がDocumentに追加されるたびに呼び出されます。
  • disconnectedCallback
    → カスタム要素がDocumentから削除されるたびに呼び出されます。
  • attributeChangedCallback
    → カスタム要素の属性が追加・削除・変更されるたびに呼び出されます。
    → 初期化時に属性を持ってる場合にも呼び出されます。
    static get observedAttributes()で監視する属性を指定します。
  • adoptedCallback
    → カスタム要素が新しいDocumentに移動するたびに呼び出されます。
    → 使う場面が限定されるので、本記事では取り扱いません。

以下のコードで(UIとしての意味は持ちませんが)ライフサイクルコールバックのタイミングを確認できるかと思います。

ここまでの流れを踏まえて、文字列を渡すとリストを表示するカスタム要素を実装してみたいと思います。
例えば「football,baseball,basketball,golf,tennis」という文字列を渡すと、ul要素で表示してくれるようにします。

まず以下のようなタグであると想定しましょう。
list属性には「,」区切りで項目を渡します。
theme属性にはlightdarkが渡され、スタイルが変更されます(この値はオプショナルで空の時はlightとなる)。

html
<my-list list="football,baseball,basketball,golf,tennis" theme="light"></my-list>

タグの命名が決まったのでにHTMLElementを拡張したクラスの作成と、カスタム要素の定義をします。

javascript
// my-list要素を作りたいのでMyListクラスを作成
class MyList extends HTMLElement {
  constructor() {
    super();
  }
  //...
}

// my-listの定義
customElements.define("my-list", MyList);

ライフサイクルコールバックを踏まえながら、必要な処理を加えます。

javascript
class MyList extends HTMLElement {
  // 要素がDoumentに追加された時に属性の値を取得する
  connectedCallback() {
    this.theme = this.getAttribute("theme") || "light";
    this.list = this.getAttribute("list").split(",") || [];
    this.render();
  }
  // list属性とtheme属性の監視
  static get observedAttributes() {
    return ["list", "theme"];
  }
  // 監視している属性に更新があったときの処理
  attributeChangedCallback(name, oldValue, newValue) {
    // nameに更新された属性名が渡ってくるので、処理を分ける
    switch (name) {
      case "list":
        this.list = newValue.split(",");
        break;
      case "theme":
        this.theme = newValue;
      default:
        break;
    }
    this.render();
  }
  // ここではあまり気にしなくていいが、listとthemeの値もとに、this.innerHTMLで描画をしている
  render() {
    const items = this.list.map((item) => `<li>${item}</li>`).join("");
    this.innerHTML = `
      <ul class="${this.theme}">
        ${items}
      </ul>
    `;
  }
}

全体のコードはこちらです。

以上でカスタム要素を作成できました。
これだけでも使い勝手はそれなりにあるのですが、よくみるとカスタム要素の外部で指定したCSSで、リストの背景にスタイルを当てていますね。
Web Componentsのキーワードの一つであるカプセル化を実現するためには、次のShadow DOMも考慮する必要があります。

Shadow DOM

Shadow DOMはWeb Componentsにおけるカプセル化を担う技術です。
Shadow DOMを使うことで外部から隠蔽されたDOMツリー(Shadow DOMツリー)を作成することができます。
これはLight DOM(Shadow DOMに対して通常のDOMをLight DOMと呼ぶ)から切り離された環境であり、スコープ外からの不要なアクセスを防ぐことに役立ちます。

では具体的にどう実装するのかというとelement.attachShadow()メソッドを使います。

以下は至ってシンプルなShadow DOMのサンプルコードです。次の2点を確認できます。

  • 外部からスタイルの変更ができない
  • document.querySelector()で要素が取得できない
html
<div id="container"></div>
scss
// 外部からShadow DOMのスタイルを変更することはできないので、このスタイルは無視される
p {
  color: blue !important;
}
javascript
const container = document.getElementById("container");
// Shadow DOMツリーを作成し、Shadow Rootを返す
// JavaScriptで外部からShadow DOMにアクセスする時は { mode:open }
const shadowRoot = container.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
  <style>
    // 内部のスタイルは外部に影響を与えない
    p {
      color: red;
      font-size: 20px;
      font-weight: bold
    }
  </style>
  <p class="shadow">Shadow DOM</p>
`;

// document.querySelector()で要素が取得できない
console.log(document.querySelector(".shadow")); //null
// attachShadow({ mode: "open" })の時は下記でもShadowRootにアクセスできる
// attachShadow({ mode: "closed" })の時はnullが返る
console.log(document.getElementById("container").shadowRoot);

全体のコードはこちらです。

では、カスタム要素の箇所で実装したmy-listをShadow DOMに置き換えます。

javascript
class MyList extends HTMLElement {
  constructor() {
    super();
    this.theme = "";
    this.list = [];
    // Shadow DOMツリーを作成
    this.attachShadow({ mode: "open" });
  }

  render() {
    const items = this.list.map((item) => `<li>${item}</li>`).join("");
    // Shadow Rootに対してinnerHTMLで要素を追加
    this.shadowRoot.innerHTML = `
      <style>
        ul {
          padding: 0;
          list-style: none;
        }
        li {
          padding: 1em;
          background: rgb(250, 250, 250);
        }
        li:nth-child(2n - 1) {
          background: rgb(230, 230, 230);
        }
        .dark li {
          color: #fff;
          background: rgb(50, 50, 50);
        }
        .dark li:nth-child(2n - 1) {
          background: rgb(10, 10, 10);
        }
      </style>
      <ul class="${this.theme}">
        ${items}
      </ul>
    `;
  }
}

全体のコードはこちらです。

以上でカスタム要素とShadow DOMを組み合わせて使うことができましたね。

slotについてもここで少しだけ触れておきます。
slotタグはプレイスホルダーのようなもので、Light DOMをShadow DOMに挿入することができる技術です。
slotタグのname属性」と「Light DOMのslot属性」が一致した際に、Light DOMが挿入された形でレンダリングされます。
感覚的にはReactのchildrenやVue.jsのslotに近いものです。

my-listlist属性で渡していたリストを下記のように変更します。

html
<my-list id="my-list">
  <!-- slot属性にitemsを指定 -->
  <!-- Shadow DOM側に <slot name="items"></slot>があればそこに以下のLight DOMが挿入される -->
  <li slot="items">football</li>
  <li slot="items">baseball</li>
  <li slot="items">basketball</li>
  <li slot="items">golf</li>
  <li slot="items">tennis</li>
</my-list>
javascript
class MyList extends HTMLElement {
  //...
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        ul {
          padding: 0;
          list-style: none;
        }
        ::slotted(li) {
          padding: 1em !important;
          background: rgb(250, 250, 250);
        }
        ::slotted(li:nth-child(2n - 1)) {
          background: rgb(230, 230, 230);
        }
        .dark ::slotted(li) {
          color: #fff;
          background: rgb(50, 50, 50);
        }
        .dark ::slotted(li:nth-child(2n - 1)) {
          background: rgb(10, 10, 10);
        }
      </style>
      <ul class="${this.theme}">
        // Light DOM側にslot="items"をもつDOMがあれば挿入される
        <slot name="items"></slot>
      </ul>
    `;
  }
}

全体のコードはこちらです。

HTMLテンプレート

HTMLのtemplateタグを使うことによって、マークアップ構造のテンプレートを作成することができます。
templateタグとその内側に存在する要素は、単に記述しただけではブラウザに描写されず、JavaScriptと組み合わせて使用されます。

サンプルコードを以下に示します。

html
<template id="template">
  <p class="template-p">template paragraph</p>
</template>
<div id="container"></div>
const template = document.getElementById("template");
const container = document.getElementById("container");

// cloneNodeを使用してtemplateの中身を複製する
container.appendChild(template.content.cloneNode(true));

全体のコードはこちらです。

Web Componentsを実装してみる

最後に、ここまでに出てきた技術を組み合わせてWeb Componentsを実装してみます。

html
<!-- Shadow DOMにtemplateの中身を入れる -->
<template id="template">
  <style>
    ul {
      padding: 0;
      list-style: none;
    }

    ::slotted(li) {
      padding: 1em !important;
      background: rgb(250, 250, 250);
    }

    ::slotted(li:nth-child(2n - 1)) {
      background: rgb(230, 230, 230);
    }

    .dark ::slotted(li) {
      color: #fff;
      background: rgb(50, 50, 50);
    }

    .dark ::slotted(li:nth-child(2n - 1)) {
      background: rgb(10, 10, 10);
    }
  </style>
  <ul id="list">
    <!-- <li slot="items">〜〜</li>がここに挿入される -->
    <slot name="items"></slot>
  </ul>
</template>

<my-list id="my-list">
  <li slot="items">football</li>
  <li slot="items">baseball</li>
  <li slot="items">basketball</li>
  <li slot="items">golf</li>
  <li slot="items">tennis</li>
</my-list>
javascript
class MyList extends HTMLElement {
  constructor() {
    super();
    this.theme = "light";
    this.template;
    this.attachShadow({ mode: "open" });
  }

  connectedCallback() {
    this.theme = this.getAttribute("theme") || "light";
    this.render();
  }

  static get observedAttributes() {
    return ["theme"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    switch (name) {
      case "theme":
        this.theme = newValue;
      default:
        break;
    }
    this.render();
  }

  getTemplate() {
    // 初回取得の時だけDocumentからtemplateを取得
    const template = this.template
      ? this.template
      : document.getElementById("template");
    return template;
  }

  render() {
    // templateタグからHTML構造を取得してShadowツリーに挿入する
    this.tempate = this.getTemplate();
    const node = this.tempate.content.cloneNode(true);
    // theme属性の変更をDOMに反映
    const theme = {
      newValue: this.theme,
      oldValue: this.theme === "light" ? "dark" : "light"
    };
    node.getElementById("list").classList.remove(theme.oldValue);
    node.getElementById("list").classList.add(theme.newValue);
    this.shadowRoot.innerHTML = "";
    this.shadowRoot.appendChild(node);
  }
}

// my-listの定義
customElements.define("my-list", MyList);

全体のコードはこちら。

すこし長くなりましたが以上でWeb Componentsを実装できました!

まとめ

「とりあえず実装してみるWeb Components」ということで、初歩的なことをさらっと流しながら実装してみました。
使いこなせればかなり便利になるであろうWeb Componentsではありますが、慣れるまではなかなか癖の強い技術のように感じました。

正直な話、本記事では書ききれていないことも多々あります(というか触りの触りだけしか書けていないです)が、これからWeb Componentsの勉強をはじめる際に、わずがでも参考になれば幸いです。

参考

https://developer.mozilla.org/ja/docs/Web/Web_Components
https://ja.javascript.info/web-components
https://www.oreilly.co.jp/books/9784873119700/

Discussion