1. ホーム
  2. HTML

【HTML】custom elements(カスタム要素)の作り方|Web Components の基本を解説

Share

同じマークアップを何度もコピーして貼り付けているうちに、修正が漏れてページごとに見た目が違ってしまった、という経験はないでしょうか。ブラウザには、<user-card> のような独自の HTML タグを自分で定義する仕組みが標準で用意されています。それが custom elements(カスタム要素)です。この記事では、タグ名の決まりごとから、クラスの書き方、ライフサイクルコールバック、Shadow DOM や template との組み合わせまで、動かせるデモを交えて解説します。

custom elements で独自の HTML タグを定義する

custom elements は、自分で決めた名前のタグをブラウザに登録し、そのタグが使われたときの振る舞いを JavaScript のクラスで定義する仕組みです。登録さえしてしまえば、あとは普通の HTML と同じように書けます。

index.html
<!-- 自分で定義したタグを、標準の要素と同じように書ける -->
<user-card name="佐藤" role="デザイナー"></user-card>
<user-card name="鈴木" role="エンジニア"></user-card>

React や Vue のようなライブラリを入れなくても、ブラウザの機能だけで再利用できる部品を作れるのが大きな利点です。作った部品は素の HTML でも、あとから別のフレームワークを導入した環境でも、同じように読み込んで使えます。

なお、よく耳にする「Web Components(ウェブコンポーネント)」は特定の API の名前ではなく、3つの技術をまとめた呼び方です。タグと振る舞いを結びつける custom elements、内側の DOM とスタイルを外から隔離する Shadow DOM、使い回すマークアップを書いておく template 要素の3つを指します。この記事の主役は1つ目の custom elements ですが、実際に部品を作るときは残りの2つも一緒に使うことが多いため、あとの章で触れます。

タグ名にはハイフンを1つ以上含める

custom elements の名前には決まりがあり、いちばん重要なのがハイフン(-)を1つ以上含めることです。<usercard><card> は使えず、<user-card> のように書く必要があります。

これは、将来 HTML に新しい標準要素が追加されたときに、自分が作ったタグと名前がぶつからないようにするための決まりです。標準の HTML 要素の名前にはハイフンが入らないと決められているため、ハイフンを含む名前は「独自に定義されたタグ」であることがひと目で分かり、衝突も起きません。

そのほか、名前は小文字の英字で始めること、大文字を含めないことが求められます。また font-faceannotation-xml など、ハイフンを含んでいても仕様上すでに使われている名前はいくつか予約されており、使えません。実際には「英小文字+ハイフン+英小文字」で、部品の役割が分かる名前を付けておけば問題ありません。プロジェクト内で app-headerapp-modal のように接頭辞をそろえておくと、他のライブラリが定義するタグとも混ざりにくくなります。

クラスを定義して customElements.define で登録する

作り方は2ステップです。まず HTMLElement を継承したクラスを書き、次にそのクラスをタグ名と結びつけて登録します。登録には customElements.define() を使います。

main.js
// 1. HTMLElement を継承したクラスを作る
class HelloBox extends HTMLElement {
  // 2. 文書に追加されたタイミングで中身を組み立てる
  connectedCallback() {
    this.textContent = 'こんにちは!';
  }
}

// 3. タグ名とクラスを結びつけて登録する
customElements.define('hello-box', HelloBox);

これだけで、HTML に <hello-box></hello-box> と書けば「こんにちは!」と表示されるようになります。customElements はブラウザが持つ登録簿(CustomElementRegistry)で、define() の第1引数がタグ名、第2引数がクラスです。

ひとつ注意したいのが、独自タグの初期表示は display: inline だという点です。標準の要素と違ってブラウザは既定のスタイルを持っていないので、ブロックとして幅いっぱいに並べたいときは、CSS で display: block を自分で指定します。

ちなみに custom elements には、<button is="fancy-button"> のように既存の要素を拡張する「カスタム組み込み要素」という書き方もありますが、対応していないブラウザがあるため(Safari は未実装です)、この記事では扱いません。ふだん使うのは、ここで紹介した独立したタグを作る形(自律型カスタム要素)です。

ライフサイクルコールバックで処理を書き分ける

custom elements のクラスには、決まった名前のメソッドを定義しておくと、要素の状態が変わったタイミングでブラウザが自動的に呼んでくれる仕組みがあります。これをライフサイクルコールバックと呼びます。使えるものは次の5つです。

コールバック呼ばれるタイミング
constructor()要素のインスタンスが作られたとき。初期化の準備だけを行う
connectedCallback()要素が文書に追加されたとき。中身の生成やイベント登録はここで行う
disconnectedCallback()要素が文書から取り除かれたとき。後片付けを行う
attributeChangedCallback(name, oldValue, newValue)observedAttributes に挙げた属性が追加・変更・削除されたとき
adoptedCallback()要素が別の文書へ移動したとき(document.adoptNode() など)

ふだん書くのは connectedCallbackattributeChangedCallback の2つがほとんどで、後片付けが必要な場合に disconnectedCallback を足す、という使い分けになります。adoptedCallbackiframe 間で要素を移すような場面でしか出番がありません。

constructor でやってはいけないこと

constructor は「要素が作られた瞬間」に呼ばれます。この時点では、まだ属性も子要素も読み込まれていない可能性があります。たとえば HTML の解析中に <user-card name="佐藤"> が現れた場合、constructor が走るのは開始タグを読んだ直後で、name 属性の値をここで取りに行っても取得できるとは限りません。

そのため仕様では、constructor の中で属性を読み書きすること、子要素を調べたり追加したりすることが禁じられています。違反しても必ずエラーになるわけではありませんが、状況によって動いたり動かなかったりする、原因の分かりにくい不具合につながります。

main.js
class UserCard extends HTMLElement {
  constructor() {
    // 継承したときは必ず最初に super() を呼ぶ
    super();

    // NG: この時点で属性は読めるとは限らない
    // this.textContent = this.getAttribute('name');

    // OK: 自分のプロパティを用意するだけならよい
    this.count = 0;
  }

  connectedCallback() {
    // 属性の読み取りと DOM の生成はこちらで行う
    this.textContent = this.getAttribute('name') ?? 'ゲスト';
  }
}

クラスを継承しているので、constructor を書く場合は先頭で super() を呼ぶ必要があります。呼び忘れると this を参照した時点でエラーになります。constructor でやることが何もないなら、そもそも書かなくてかまいません。

もうひとつ気を付けたいのが、connectedCallback一度きりとは限らないことです。要素を appendChild() で別の場所へ移動すると、いったん取り外されてから追加されるため、disconnectedCallbackconnectedCallback がもう一度呼ばれます。中身を組み立て直すと二重になってしまう処理は、あとで紹介するように「すでに組み立て済みかどうか」を確認してから実行します。

属性の変化を受け取る observedAttributes

部品の中身を属性で変えられるようにすると、使い回しがぐっと楽になります。属性の変化を検知するには、監視したい属性の名前を static observedAttributes に配列で並べ、attributeChangedCallback を定義します。この2つはセットで、配列に挙げていない属性が変わってもコールバックは呼ばれません。

main.js
class UserCard extends HTMLElement {
  // 監視したい属性の名前を並べる
  static observedAttributes = ['name'];

  attributeChangedCallback(name, oldValue, newValue) {
    // name: 変化した属性名 / oldValue: 変化前の値 / newValue: 変化後の値
    console.log(name, oldValue, newValue);
  }
}

属性が設定されていなかった場合、oldValuenewValuenull になります。つまり属性の追加は null から値へ、削除は値から null への変化として届きます。監視対象が複数あるときは、第1引数の name を見て処理を振り分けます。

HTML に最初から書かれていた属性についても、要素が使えるようになる時点で attributeChangedCallback が呼ばれます。この初回の呼び出しは connectedCallback よりに起きるため、コールバックの中で組み立て済みの DOM を触るコードを書くときは、まだ何も無い状態を考慮しておくと安全です。

次のデモは、namerole の2つの属性で表示が変わるカード部品です。ボタンを押すと1枚目の name 属性を書き換えます。JavaScript から表示を直接いじってはおらず、属性を変えた結果として attributeChangedCallback が呼ばれ、表示が更新されています。

<greeting-card name="佐藤" role="デザイナー"></greeting-card>
<greeting-card name="鈴木" role="エンジニア"></greeting-card>

<button id="rename" type="button">1枚目の name 属性を書き換える</button>
body {
  font-family: sans-serif;
  line-height: 1.7;
  padding: 12px;
}

/* 独自タグは既定で display: inline なので、自分で指定する */
greeting-card {
  display: block;
  border: 1px solid #d5dbe3;
  border-left: 4px solid #0a58ca;
  border-radius: 6px;
  background: #f5f7fa;
  padding: 10px 14px;
  margin-bottom: 10px;
}

greeting-card .name {
  font-weight: bold;
}

greeting-card .role {
  color: #5b6472;
  font-size: 13px;
}

button {
  font: inherit;
  padding: 6px 12px;
}
class GreetingCard extends HTMLElement {
  // 変化を監視したい属性の名前を並べる
  static observedAttributes = ["name", "role"];

  // 要素が文書に追加されたときに呼ばれる
  connectedCallback() {
    this.render();
  }

  // observedAttributes に挙げた属性が変わるたびに呼ばれる
  attributeChangedCallback(name, oldValue, newValue) {
    this.render();
  }

  render() {
    const name = this.getAttribute("name") ?? "ゲスト";
    const role = this.getAttribute("role") ?? "";
    this.innerHTML = `<span class="name">${name} さん</span>
      <span class="role">${role}</span>`;
  }
}

// タグ名とクラスを結びつける(タグ名にはハイフンが必須)
customElements.define("greeting-card", GreetingCard);

const names = ["田中", "高橋", "佐藤"];
let i = 0;

document.getElementById("rename").addEventListener("click", () => {
  i = (i + 1) % names.length;
  // 属性を変えると attributeChangedCallback 経由で表示が更新される
  document.querySelector("greeting-card").setAttribute("name", names[i]);
});
Preview

クラスの中では this がその要素自身を指すので、this.getAttribute()this.innerHTML のように、いつもの DOM 操作がそのまま使えます。属性が無いときの既定値(この例では「ゲスト」)を用意しておくと、書き忘れても壊れません。

Shadow DOM でスタイルを閉じ込める

上のデモのように innerHTML で中身を作る方法は手軽ですが、部品用に書いた CSS がページ全体に影響したり、逆にページ側の CSS で部品の見た目が崩れたりします。これを防ぐのが Web Components の2つ目の技術、Shadow DOM(シャドウ DOM)です。

要素の中に attachShadow() で「影の DOM ツリー」を作ると、その中のマークアップとスタイルは外から切り離されます。中に書いた CSS セレクターは外の要素にマッチせず、ページ側のセレクターも中には届きません。

main.js
class UserCard extends HTMLElement {
  connectedCallback() {
    // シャドウルートを作る(open なら外から this.shadowRoot で参照できる)
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        /* この .box はこの要素の中だけに効く */
        .box { border: 1px solid #ccc; padding: 8px; }
      </style>
      <div class="box"><slot></slot></div>
    `;
  }
}

mode には 'open''closed' があります。'open' にすると外部のスクリプトから element.shadowRoot で中を参照でき、'closed' では null が返って触れなくなります。特別な理由がなければ 'open' を使うのが一般的です。

コードに出てきた <slot> は、タグの中に書かれた内容を差し込む場所を表す目印です。<user-card>こんにちは</user-card> と書いたときの「こんにちは」が、<slot> の位置に表示されます。名前を付けた <slot name="title"> を用意すれば、使う側は slot="title" を付けた要素をそこへ流し込めます。差し込み先を指定しない <slot> は、残りすべての中身を受け取ります。

ここで少しややこしいのが、差し込まれた中身自体はシャドウ DOM の外に置かれたままだという点です。表示位置がシャドウ DOM の中に移るだけなので、ページ側の CSS はそのまま適用されます。シャドウ DOM の側から差し込まれた内容にスタイルを当てたいときは ::slotted() を、要素自身(ホスト)に当てたいときは :host を使います。

template と組み合わせてアコーディオンを作る

マークアップが少し複雑になってくると、innerHTML にテンプレート文字列で書き続けるのはつらくなります。そこで Web Components の3つ目の技術、template 要素の出番です。<template> の中身はページに表示されず、スクリプトも実行されない「型紙」として保持され、必要になったときに複製して使います。

custom elements と組み合わせるときの流れは単純で、部品の見た目を <template> に HTML として書いておき、connectedCallback の中で tpl.content.cloneNode(true) で複製し、シャドウルートに追加します。文字列を組み立てないぶん、エディタの補完やシンタックスハイライトが効くのが利点です。

次のデモは、この形で作った開閉するアコーディオン部品です。見出しをクリックすると開閉し、状態は open 属性で表しています。HTML に <my-accordion open> と書けば最初から開いた状態になり、JavaScript から toggleAttribute('open') で切り替えれば表示も追従します。CSS タブの .panel に赤い破線を指定していますが、シャドウ DOM の中には届かないため適用されていない点にも注目してください。

<!-- 使い回すマークアップを template にまとめておく -->
<template id="accordion-tpl">
  <style>
    /* ここに書いたスタイルはシャドウ DOM の中だけに効く */
    .panel {
      border: 1px solid #d5dbe3;
      border-radius: 6px;
      overflow: hidden;
    }
    .head {
      display: flex;
      justify-content: space-between;
      align-items: center;
      width: 100%;
      font: inherit;
      text-align: left;
      padding: 10px 12px;
      background: #f5f7fa;
      border: 0;
      cursor: pointer;
    }
    .body {
      padding: 10px 12px;
    }
    [hidden] {
      display: none;
    }
    .mark {
      color: #0a58ca;
    }
  </style>
  <div class="panel">
    <button class="head" type="button" aria-expanded="false">
      <slot name="title">タイトル</slot>
      <span class="mark">+</span>
    </button>
    <div class="body" hidden><slot></slot></div>
  </div>
</template>

<my-accordion open>
  <span slot="title">custom elements とは?</span>
  <p>独自の HTML タグを定義して、フレームワークなしで再利用できる部品を作る仕組みです。</p>
</my-accordion>

<my-accordion>
  <span slot="title">タグ名のルールは?</span>
  <p>ハイフンを1つ以上含める必要があります。<code>my-accordion</code> は使えますが、<code>accordion</code> は使えません。</p>
</my-accordion>
body {
  font-family: sans-serif;
  line-height: 1.7;
  padding: 12px;
}

my-accordion {
  display: block;
  margin-bottom: 10px;
}

/* シャドウ DOM の中の .panel には届かないので、この枠線は付かない */
.panel {
  border: 4px dashed #e0426a;
}

/* スロットに差し込んだ中身は元の文書側に残るので、こちらは効く */
my-accordion p {
  margin: 0;
  font-size: 14px;
}
class MyAccordion extends HTMLElement {
  static observedAttributes = ["open"];

  connectedCallback() {
    // 一度組み立て済みなら作り直さない
    if (!this.shadowRoot) {
      const shadow = this.attachShadow({ mode: "open" });

      // template の中身を複製してシャドウ DOM に入れる
      const tpl = document.getElementById("accordion-tpl");
      shadow.append(tpl.content.cloneNode(true));

      shadow.querySelector(".head").addEventListener("click", () => {
        // 見た目の更新は attributeChangedCallback にまかせる
        this.toggleAttribute("open");
      });
    }
    this.sync();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    this.sync();
  }

  // open 属性の有無を表示に反映する
  sync() {
    // 接続前は shadowRoot がまだない
    if (!this.shadowRoot) return;

    const isOpen = this.hasAttribute("open");
    this.shadowRoot.querySelector(".body").hidden = !isOpen;
    this.shadowRoot.querySelector(".head").setAttribute("aria-expanded", String(isOpen));
    this.shadowRoot.querySelector(".mark").textContent = isOpen ? "−" : "+";
  }
}

customElements.define("my-accordion", MyAccordion);
Preview

ポイントは、クリックしたときに直接 DOM を書き換えるのではなく、open 属性を切り替えるだけにしていることです。表示の更新は attributeChangedCallback から呼ばれる sync() に集約されているので、クリックでも、HTML の初期状態でも、外部のスクリプトからの操作でも、同じ処理が使われます。属性を「状態の置き場所」として扱うと、部品の中がすっきりします。

connectedCallback の先頭で this.shadowRoot の有無を調べているのは、要素が移動して再び接続されたときに、シャドウルートを二重に作ろうとしてエラーになるのを防ぐためです。attachShadow() は同じ要素に対して2回呼べません。また sync() でも shadowRoot を確認しています。前の章で触れたとおり、HTML に書かれた open 属性による attributeChangedCallbackconnectedCallback より先に呼ばれるため、まだ中身が無い状態で走ることがあるからです。

独自タグが動かないときに確認すること

custom elements は、うまくいかないときにエラーが出ないまま「ただのタグ」として静かに無視されることがあります。つまずきやすいところを順に見ていきます。

タグ名にハイフンが入っていない

最初に疑うのはここです。customElements.define('usercard', UserCard) のようにハイフンの無い名前を渡すと、その場で例外が投げられます。ブラウザのコンソールに SyntaxError として「有効なカスタム要素名ではない」といった内容のメッセージが出ているはずです。

やっかいなのは、例外によってそのスクリプトの残りが実行されなくなることです。同じファイルで複数の部品を登録していると、1つ目の名前が不正なだけで、以降の部品がすべて動かなくなります。「なぜか全部動かない」ときは、コンソールの最初のエラーを確認してください。

スクリプトの読み込み順とアップグレード

「タグを書いた場所より後で define() しているから動かないのでは」と考えがちですが、そこは心配いりません。ブラウザは、まだ定義されていないハイフン付きのタグも要素として保持しておき、あとから define() が呼ばれた時点で、ページ上にある該当の要素をまとめてアップグレードします。このときクラスの constructorconnectedCallback が改めて実行されるので、順序が逆でも中身は組み立てられます。

問題になるのは、登録より前の「まだ空のタグ」が一瞬表示されてしまうことです。この状態を狙い撃ちできる :defined という CSS の擬似クラスがあり、次のように書くと、定義が済むまで隠しておけます。

style.css
/* 定義が済んでいない間は表示しない(ちらつき防止) */
user-card:not(:defined) {
  visibility: hidden;
}

一方、スクリプト側で document.getElementById() のようにページの要素を探している場合は、読み込み順が影響します。<head> の中で属性なしの <script> を書くと、body が読み込まれる前に実行されて null になってしまいます。<script src="main.js" defer> または <script src="main.js" type="module"> と書けば、HTML の解析が終わってから実行されるので確実です。

constructor の中で中身を組み立てている

属性が空で表示される、子要素が読めない、といった症状のときは、処理を constructor に書いていないか確認します。前述のとおり、この時点では属性も子要素もそろっていないことがあり、HTML に直接書いた場合と JavaScript で createElement() した場合とで結果が変わってしまいます。属性の読み取りと DOM の生成は connectedCallback に移してください。

シャドウ DOM の中に外側の CSS が届かない

「スタイルシートに書いたはずの指定が効かない」という場合、その要素がシャドウ DOM の中にあるなら、それは仕様どおりの動作です。外側のセレクターはシャドウ DOM の中の要素にマッチしません。スタイルはシャドウルートの中に <style> として入れるか、<template> の中に一緒に書いておきます。

ただし、colorfont-family のような継承するプロパティは境界を越えて中まで伝わります。また CSS カスタムプロパティ(--main-color のような変数)も中から参照できるので、外から色やサイズを調整できる余地を残したいときは、部品側で変数を使って書いておくと扱いやすくなります。

同じ名前で2回登録している

同じスクリプトを2回読み込んでしまったときなど、すでに登録済みの名前をもう一度 define() するとエラーになります。1つの名前に登録できるクラスは1つだけです。読み込みの重複を直すのが本筋ですが、どうしても防げない場合は customElements.get('user-card') で登録済みかどうかを調べ、未登録のときだけ define() を呼ぶという書き方もできます。

まとめ

custom elements を使うと、ライブラリに頼らずブラウザの標準機能だけで、独自の HTML タグとして再利用できる部品を作れます。作り方は HTMLElement を継承したクラスを書き、customElements.define() でハイフンを含むタグ名と結びつけるだけです。中身の生成は connectedCallback、属性による表示の切り替えは static observedAttributesattributeChangedCallback の組み合わせで書き、constructor では属性や子要素を触らない、という役割分担を覚えておけば、たいていの部品は作れます。見た目を外部から守りたいときは attachShadow() でシャドウ DOM を作り、マークアップが増えてきたら template 要素に切り出す。この3つを組み合わせたものが Web Components です。まずは繰り返し使っている小さなパーツを1つ、独自タグに置き換えるところから試してみてください。

参考ページ