ここ最近、Web Components が再び注目を集めています。
Eric Meyer 氏による「Blinded By the Light DOM」や、Jeremy Keith 氏による「HTML web components」をきっかけに、これまでの Web Components とは考え方や使用方法の異なる HTML Web Components という手法に関する記事をいくつも目にしました。
本記事では、この HTML Web Components とは何か。一般的な Web Components とは何が違うのか。そして、その特徴と使用方法について考えてみたいと思います。
Web Components とは
見出し「Web Components とは」まず、HTML Web Components に触れる前に、その前提となる Web Components の基本について説明します。
Web Components は以下の 3 つの技術で成り立っています。
- カスタム要素
- Shadow DOM
- HTML テンプレート
これらの技術を組み合わせることで、自己完結型の再利用可能なコンポーネントを実装することが可能になります。ウェブ標準の技術であり、各種ブラウザのサポートも十分です。
カスタム要素
見出し「カスタム要素」カスタム要素は、JS のクラスによって HTML 要素を拡張させて作成したコンポーネントを、独自の HTML タグで使えるようにする機能です。
以下の例では、<my-lightdom>
というカスタム要素を定義しています。
このコンポーネントは Shadow DOM ではないので、スタイルがコンポーネントの外側に影響してしまいます。そのため、CSS セレクタは my-lightdom > div
として、コンポーネントの内側にのみ適用されるように指定しています。
このコードで作成したカスタム要素は以下のようになります。クリックすると window.alert
でメッセージが表示されるだけのコンポーネントです。
なお、このコンポーネントは説明向けに簡略化するために、<div>
要素に対してクリックイベントを設定しており、キーボードでは操作できないアクセシビリティ上の問題があります。
カスタム要素のおもなポイントは以下のとおりです。
HTMLElement
を拡張したクラスを作成し、customElements.define()
で定義する- コンストラクタの先頭で
super()
を指定する - 要素名はすべて小文字で、必ずハイフン(
-
)を含める(将来的に追加される HTML タグとの干渉を避けるため) - 要素名に日本語や絵文字(非 ASCII 文字)は使用不可1
- カスタム要素は開始タグと終了タグの両方を指定する
- カスタム要素には属性を指定できる
- カスタム要素には子孫要素を含めることができる
加えて、ライフサイクルコールバックもカスタム要素の重要な概念ですが、本記事では割愛します。
Shadow DOM
見出し「Shadow DOM」続いて Shadow DOM ですが、カスタム要素のクラスを指定する過程で、作成した DOM 要素を Shadow root に追加することで、コンポーネントをカプセル化することができます。
なお、Shadow DOM の外側にある DOM 要素は、Light DOM と呼ばれます。通常の HTML 要素や、Shadow DOM を追加していないカスタム要素は Light DOM に該当します。
以下の例では、<my-shadowdom>
というカスタム要素を定義しています。
Shadow DOM のおもなポイントは以下のとおりです。
attachShadow()
メソッドで Shadow root を追加- このとき、
mode
オプションをopen
かclosed
を選択する - クラスで作成した DOM 要素やスタイルやスクリプトを Shadow root に追加する
- Shadow DOM のスタイルは、Light DOM に影響しない
- Light DOM のスタイルは、Shadow DOM にほぼ影響しない
上記のコードでは、div
要素にスタイルを適用していますが、Shadow DOM の外側(Light DOM)には影響しません。
先ほどのコンポーネントと並べると以下のようになります。
ブラウザのデベロッパーツールで確認すると、<my-lightdom>
には直接 DOM ツリーが展開されていますが、<my-shadowdom>
では、Shadow root の下に格納されていることがわかります。
注意点としては、Light DOM のスタイルは、Shadow DOM に「ほぼ影響しない」と記述したように、継承可能なプロパティや CSS 変数(カスタムプロパティ)については、Light DOM から Shadow DOM へ影響を及ぼします。
試しに、以下のグローバルスタイルを追加すると、Shadow DOM と Light DOM それぞれの影響の違いを確認できます。
以下はライブデモですが、Light DOM では両方のスタイルが適用されますが、Shadow DOM は body
セレクタに指定された、継承プロパティの値のみ反映されているのがわかります。
つまり、Shadow DOM では祖先要素にあたる body
からのスタイルは継承されますが、div
要素セレクタの指定は影響を受けません。
このように、Shadow DOM ではカプセル化によって、内外の影響を心配する必要は少なくなるのですが、スタイルの継承による影響に気付きづらいのが難点です。
同時に、外部から影響を受けないということは、コンポーネント内でブラウザなどのユーザエージェントのスタイルをリセットする必要があり、スタイル指定が煩雑かつ冗長になります。
加えて、Shadow DOM は Light DOM への紐づけができないため、アクセシビリティ上の問題が発生しないように注意する必要があります。
例えば、Light DOM の <label>
要素 から、Shadow DOM の <input>
要素にはアクセスできません。もちろん、Shadow DOM 同士であっても同様です。
このように、Shadow DOM はカプセル化の恩恵は受けられる反面、一般的なウェブサイトにおいては気軽に使えるシーンは少ないように感じます。
HTML テンプレート
見出し「HTML テンプレート」本記事では HTML テンプレートの詳細な説明は割愛しますが、ブラウザ上では描画されない <template>
要素を使用して、繰り返し使用する HTML 構造を定義したり、<slot>
要素を使用して、Shadow DOM に Light DOM のコンテンツを挿入することができます。
なお、<template>
要素については、Web Components でカスタム要素を作成する文脈に限らず、通常の HTML 要素に対しても活用できます。
HTML Web Components とは
見出し「HTML Web Components とは」それでは、ここまでの Web Components の説明を踏まえて、HTML Web Components とは何かを見ていきますが、改めてカスタム要素で説明した特徴に注目します。
前述の Web Components の例では、<my-component></my-component>
のように中身が空のカスタム要素を配置して、HTML 自体は JS 側で動的に生成していましたが、カスタム要素の内側には通常の HTML 要素を含めることができます。
以下のレンジスライダーをカスタマイズする例で、もう少し詳しく説明していきます。まずは、以下の HTML をベースとします。
<custom-slider>
という要素で囲われていますが、まだカスタム要素を定義していない状態です。このとき、<custom-slider>
の内側に配置された HTML がそのまま表示されます。
CSS を追加して以下のような見た目に変更します。まだカスタム要素は定義していません。
この状態でも、レンジスライダーとしては最低限の機能を確保していますが、ここからさらに以下の 2 つの機能を追加します。
- スライダーの値に応じてトラックの長さを変更
- スライダーの値をアウトプットして表示
コードは以下のとおりで、HTML に <output>
要素を追加したのと、JS でカスタム要素を定義しています。重要な点として、このカスタム要素は Shadow DOM ではなく Light DOM です。
CSS はコード量が多いので折りたたんでいます。
CSS を確認する
<style>
custom-range {
--color-fg: #007373;
--color-bg: #fff;
--color-hover: #005454;
--thumb-inline-size: 16px;
--thumb-block-size: 16px;
--track-inline-size: 100%;
--track-block-size: 2px;
--active-scale: 1.3;
--progress: 100;
display: grid;
gap: 1.5em;
inline-size: 100%;
padding: 40px;
border-radius: 16px;
background-color: hsl(180 37% 81%);
text-align: center;
}
@media (hover: hover) and (pointer: fine) {
custom-range:has(input[type="range"]:hover) {
--color-fg: var(--color-hover);
}
}
custom-range label {
inline-size: fit-content;
font-size: calc(1rem * 14 / 16);
line-height: 1.1;
text-align: left;
}
custom-range output {
display: inline-grid;
place-items: center;
margin: auto;
border-radius: 8px;
inline-size: 3em;
aspect-ratio: 1;
background-color: var(--color-fg);
color: var(--color-bg);
transition: all 0.2s ease;
}
custom-range:has(input[type="range"]:active) output {
font-weight: 400;
scale: var(--active-scale);
}
custom-range input[type="range"] {
position: relative;
z-index: 1;
display: block;
inline-size: 100%;
block-size: var(--thumb-block-size);
background: none;
outline: none;
-webkit-appearance: none;
appearance: none;
}
custom-range input[type="range"]::after {
content: '';
margin-block: auto;
position: absolute;
border-radius: var(--track-block-size);
inset-block: 0;
inset-inline-start: 0;
display: block;
inline-size: 100%;
block-size: var(--track-block-size);
background-color: var(--color-fg);
transform-origin: 0 50%;
scale: calc(var(--progress) * 0.01) 1;
}
custom-range .c-slider-bg {
position: relative;
}
custom-range .c-slider-bg::before,
custom-range .c-slider-bg::after {
content: '';
position: absolute;
inset: 0;
display: block;
margin: auto;
background-color: var(--color-bg);
}
custom-range .c-slider-bg::before {
inline-size: 2px;
block-size: 4px;
translate: 0 4px;
}
custom-range .c-slider-bg::after {
inline-size: 100%;
block-size: var(--track-block-size);
}
/* webkit */
custom-range input[type="range"]::-webkit-slider-thumb {
position: relative;
z-index: 10;
inline-size: var(--thumb-inline-size);
block-size: var(--thumb-block-size);
border-radius: var(--thumb-block-size);
box-shadow: none;
background-color: var(--color-fg);
appearance: none;
cursor: grab;
transition: all 0.2s ease;
}
custom-range input[type="range"]:active::-webkit-slider-thumb {
scale: var(--active-scale);
}
custom-range input[type="range"]:focus::-webkit-slider-thumb {
border: 1px solid var(--color-bg);
box-shadow: 0 0 0 2px var(--color-fg);
}
custom-range input[type="range"]::-webkit-slider-runnable-track {
border: none;
cursor: pointer;
appearance: none;
}
/* moz */
custom-range input[type="range"]::-moz-range-thumb {
position: relative;
z-index: 10;
inline-size: var(--thumb-inline-size);
block-size: var(--thumb-block-size);
border-radius: var(--thumb-block-size);
box-shadow: none;
background-color: var(--color-fg);
appearance: none;
cursor: grab;
transition: all 0.2s ease;
}
custom-range input[type="range"]:active::-moz-range-thumb {
scale: var(--active-scale);
}
custom-range input[type="range"]:focus::-moz-range-thumb {
border: 1px solid var(--color-bg);
box-shadow: 0 0 0 2px var(--color-fg);
}
custom-range input[type="range"]::-moz-range-track {
border: none;
cursor: pointer;
appearance: none;
}
</style>
1 つ目の「スライダーの値に応じてトラックの長さを変更」では、CSS 変数(--progress
)経由でトラックの長さを変更して、視覚的なわかりやすさを調整しています。
2 つ目の「スライダーの値をアウトプットして表示」は、単純に <input type="range">
の値を <output>
要素に指定しています。
クラス内の this
キーワードはカスタム要素自体を指すので、コンポーネントの範囲に限定した要素の取得や値の指定が可能になり、document.querySelector()
などと比較すると、スコープを特定しやすく、コンポーネント指向な実装が可能になります。
HTML Web Components の特徴
見出し「HTML Web Components の特徴」このように、ベースとなる HTML / CSS に対して、JS の機能を積み上げる実装が可能になることが、HTML Web Components の最大の特徴といえます。
これらの点を踏まえた HTML Web Components の特徴は以下のとおりです。
- Light DOM である
- Progressive Enhancement である2
- コンポーネント指向である
- アクセシビリティが確保しやすい
- レイアウトシフトの影響が小さく、パフォーマンスに優れている3
- グローバルスタイルを使用できる(反面、外部から影響を受けるデメリットもある)
- ウェブ標準なので環境を選ばない
- ブラウザでそのまま実行できるのでビルドが不要
- ロックインやバージョン管理によるメンテナンスの心配がない
一方で、HTML Web Components の課題としては以下が考えられそうです。
- スタイルの管理方法が確立していない
- HTML の一元管理ができない
スタイルの管理については次項で説明します。
HTML の管理に関しては、Astro や Eleventy といったフレームワークを使用して、コンポーネントをファイルごとに一元管理する方法が現実的です。その場合には、ロックインやメンテナンスコストとのトレードオフになりますが、ある程度はやむを得ないと考えています。
HTML Web Components のスタイル管理
見出し「HTML Web Components のスタイル管理」HTML Web Components は Light DOM なので、CSS のスコープは閉じられません。そのため、スコープを限定させるために、カスタム要素を起点としたスタイル指定が有効です。
このとき、ネスト記法(CSS Nesting)を使用すると、効率的に記述できます。
ネスト記法(CSS Nesting)は、主要なブラウザでサポートされていますが、Safari でサポートが開始されたのが 16.5 からなので注意が必要です。
また、コンポーネント外部のスタイルとの競合を避けるために、クラスには接頭辞 c-
を付けるといったルールを持たせるとよいかもしれません。
なお、将来的に @scope
規則のサポートが十分になれば接頭辞は不要になり、以下のように簡潔に指定できるようになります。
注意する点としては、入れ子にしたカスタム要素にもスタイルが影響する可能性があることです。
以下のように @scope
規則で範囲(donut scope)を示せば、ある程度避けることはできますが、その場合にも子孫要素に含まれるカスタム要素名を事前に知っておく必要があります。
カスタム要素のデフォルトスタイル
見出し「カスタム要素のデフォルトスタイル」カスタム要素のデフォルトの display
の値は inline
なので、レイアウトに応じて display
の値を変更してあげる必要があります。
例えば、先ほどのレンジスライダーの例で、カスタム要素 <custom-range>
の display
がデフォルトの inline
のままだと、領域が確保されずに以下のように表示が崩れます。
すべてのカスタム要素にスタイルをまとめて指定する方法が存在しないのが、もどかしく感じますが、カスタム要素ごとに display
の値を指定する必要があります。
:defined
擬似クラス
この見出しのリンクCSS の :defined
擬似クラスを使用することで、カスタム要素が定義されたときのスタイルを定義できます。以下のように :not()
擬似クラスとあわせることで、カスタム要素が定義されるまでのスタイルが指定できます。
カスタム要素の生成に時間を要する場合には、操作不可であることを表すスタイルを追加できます。その場合には、aria-busy
属性を追加するなどして、スクリーンリーダーなどの支援技術にも状態が伝わるようにするとよいでしょう。
おわりに
見出し「おわりに」この記事では HTML Web Components について、個人的に理解している内容をまとめました。
今後、HTML Web Components という呼び方が定着するかはわかりませんが、HTML をベースに機能を積み増していく Progressive Enhancement の思想に基づいており、合理的で有効なテクニックだと感じています。
一方で、CSS や HTML の管理方法については若干手探りなところもあり、実際に使用しながらベストプラクティスを見つけていければと考えています。
参考文献
見出し「参考文献」HTML Web Components については、以下のウェブページを参考にしました。
- Blinded By the Light DOM | meyerweb.com(外部リンクを開く)
- HTML web components | Adactio(外部リンクを開く)
- HTML Web Components | Jim Nielsen’s Blog(外部リンクを開く)
- Let it snow | Hawk Ticehurst(外部リンクを開く)
Web Components 全般については、以下のウェブページを参考にしました。
- ウェブコンポーネント | MDN(外部リンクを開く)
- Pros and cons of using Shadow DOM and style encapsulation | Manuel Matuzovic(外部リンクを開く)
- Web Components | 12 Days of Web(外部リンクを開く)
- You're (probably) using connectedCallback wrong | Hawk Ticehurst(外部リンクを開く)
脚注
-
プログレッシブエンハンスメント(Progressive Enhancement)という考えかた | Accessible & Usable ↩
-
Flash of Unstyled Custom Element(FOUCE)と呼ばれる、JS が実行されてカスタム要素が有効になった瞬間のレイアウトのずれや、ちらつきを回避することができます。 ↩