これはShadow DOMとは何かを、Custom Elementsのさわりまでを含めて説明するときに書いたメモ書きをまとめたものです。
Shadow DOMはウィジェットコンポーネントや、ブックマークレットツール作るときとかに便利だったりします。
第1章 要素を作ってページに追加する
まずはShadow DOMを使わない例です。
1つのp
要素を持ったdiv
要素を、ページの最後に追加してみましょう。
const { body } = document; // documentオブジェクトに生えているbody要素を変数化する
const div = document.createElement('div'); // div要素を作る
div.insertAdjacentHTML('afterbegin', '<p>Hello Shadow DOM!</p>'); // div要素に新しくHTMLを書き込む
body.append(div); // body要素の最後にdiv要素を追加する
第2章 Shadow rootを作る
Shadow DOM では、「Shadow host」と「Shadow root」というキーワードが出てきます。
div
要素を Shadow host に変換
Shadow host とは、Shadow root を持つ要素のことです。
さっそくattachShadow()
メソッドを利用して、追加するdiv
要素を Shadow root に変換してみましょう。
const { body } = document;
const div = document.createElement('div');
div.attachShadow({ mode: 'open' }); // div要素が Shadow host に変換される
div.insertAdjacentHTML('afterbegin', '<p>Hello Shadow DOM!</p>');
body.append(div);
Shadow host に変換すると、div
要素の中にあったp
要素が見えなくなります。
とはいえ、p
要素が削除されたわけではないことが開発者ツールで確認できます。
Shadow root を取得
Shadow root は、Shadow DOM を扱う領域です。
通常のDOMツリーと異なり、Shadow root の中にあるDOMツリーを Shadow tree と言います。
Shadow root はattachShadow()
の返り値、または Shadow host のshadowRoot
プロパティから参照できます。
const { body } = document;
const div = document.createElement('div');
const shadowRoot = div.attachShadow({ mode: 'open' }); // 返り値を取得
div.insertAdjacentHTML('afterbegin', '<p>Hello Shadow DOM!</p>');
body.append(div);
// attachShadow() の返り値で取得したもの
console.log(shadowRoot); // > #shadow-root (open)
// shadowRoot プロパティからの参照
console.log(div.shadowRoot); // > #shadow-root (open)
// どっちも同じもの
console.log(shadowRoot === div.shadowRoot); // > true
第3章 Shadow root を closed モードで作る
Closed Shadow DOMは、あとからshadowRoot
を参照することができなくするための方法です。
attachShadow()
に渡すオプションで、mode
プロパティに文字列'closed'
を渡します。
この方法で生成された Shadow root は、最初のattachShadow()
の返り値以外の手段では取得できなくなります1。
const { body } = document;
const div = document.createElement('div');
const shadowRoot = div.attachShadow({ mode: 'closed' });
div.insertAdjacentHTML('afterbegin', '<p>Hello Shadow DOM!</p>');
body.append(div);
// attachShadow() の返り値で取得したもの
console.log(shadowRoot); // > #shadow-root (closed)
// shadowRoot プロパティからの参照
console.log(div.shadowRoot); // > null
// 不一致
console.log(shadowRoot === div.shadowRoot); // > false
第4章 【slot編】Shadow root の中にコンテンツを表示してみる
Shadow host になっている要素は自信のコンテンツの中身の代わりに、Shadow root の中身を表示します。
slot
要素を使うことで、Shadow host(div
要素)の中身を Shadow root の中(Shadow DOM)に出力できます。
const { body } = document;
const div = document.createElement('div');
const shadowRoot = div.attachShadow({ mode: 'open' });
const slot = document.createElement('slot'); // slot要素を作る
div.insertAdjacentHTML('afterbegin', '<p>Hello Shadow DOM!</p>');
shadowRoot.append(slot); // Shadow root に追加する
body.append(div);
slot
要素を作成し、Shadow root に追加すると、画面に「Hello Shadow DOM!」というコンテンツを持ったp
要素が表示されるようになります。
開発者ツールを使うと、Shadow root の中にp
要素が疑似的にコピーされて表示されているのが確認できます。
第5章 【slot編】Shadow rootの中で、狙ったコンテンツを狙って位置に表示してみる
ただslot
要素を埋め込むだけだと、Shadow host の中身が区別なくすべてコピーされていました。
特定の個所に、特定のコンテンツを出力したいような場合には、slot
要素とslot
属性を利用します。
slot
要素とslot
属性はそれぞれ役割が異なる点に注意が必要です。
name
属性を持つslot
要素は、「同じ値をslot
属性に持つ要素を表示する」という機能を持つ要素です。
次のコードを実行してみると、slot
属性を持っていない要素は表示されません。
一方でslot
属性を持っている要素は、name
属性が対応しているslot
要素の中身にコピーされています。
const { body } = document;
const div = document.createElement('div');
const shadowRoot = div.attachShadow({ mode: 'closed' });
// slot 要素を作り name属性に name を指定
const slotForName = document.createElement('slot');
slotForName.name = 'name';
// slot 要素を作り name属性に age を指定
const slotForAge = document.createElement('slot');
slotForAge.name = 'age';
// slot 属性にそれぞれ対応させたい slot 要素の name 属性値を指定する
div.insertAdjacentHTML('afterbegin', `
<p>slot属性を持たないテキスト</p>
<p slot="name">山田太郎</p>
<p slot="age">17歳</p>
`);
// slot 要素を Shadow root に追加
shadowRoot.append(slotForName);
shadowRoot.append(slotForAge);
body.append(div);
【イメージ】Shadow host化したdiv
要素と Shadow root の中身
実際はshadow-root
要素のようなものは存在しませんが、イメージするならこういう構造になります。
本来のDOMツリー上には存在しない、影のような存在であるところが Shadow DOM の特徴です。
<div>
<shadow-root>
<slot name="name"></slot>
<slot name="age"></slot>
</shadow-root>
<p>slot属性を持たないテキスト</p>
<p slot="name">山田太郎</p>
<p slot="age">17歳</p>
</div>
【イメージ】表示上はこんな感じ
slot
要素の中に、name
属性値と対応するslot
属性値を持つ要素が埋め込まれます。
<div>
<!-- #shadow-root -->
<slot name="name"><p slot="name">山田太郎</p></slot>
<slot name="age"><p slot="age">17歳</p></slot>
<!-- /#shadow-root -->
<!-- <p>slot属性を持たないテキスト</p> -->
<!-- <p slot="name">山田太郎</p> -->
<!-- <p slot="age">17歳</p> -->
</div>
第6章 Shadow rootの中にコンテンツを表示してみる(template編)
Shadow DOMの良さがグッとわかるタイミングは、template
要素+slot
要素やCustom Elementと組み合わせたときだと思います。
まずは、template
要素とslot
要素を活用する方法を試してみましょう。
記事一覧、製品一覧など、同じ構成のものを複数動的出力(JavaScriptで読み込み、出力)する作るとき特に便利です。
-
template#template
要素の中に出力するときの構造のみのHTMLを記述する - 表示させたい「情報」だけを実際に使う出力したい情報を
div#card
要素に記述する -
div#card
要素にShadow rootを作る(Shadow host化)(A) -
tempalte#tempate
要素の中身をcloseNode(true)
で複製し、Shadow rootに追加する(B)
以上のステップを踏むと、div#card
要素の中に、「情報」を持ったtable
要素が表示されます。
<template id="template">
<table>
<tbody>
<tr>
<th>ID</th>
<td><slot name="user-id"></slot></td>
</tr>
<tr>
<th>名前</th>
<td><slot name="user-name"></slot></td>
</tr>
<tr>
<th>本文</th>
<td><slot name="user-description"></slot></td>
</tr>
<tr>
<th>投稿日</th>
<td><slot name="user-date"></slot></td>
</tr>
</tbody>
</table>
</template>
<div id="card">
<span slot="user-id">@kawarabeEcma</span>
<span slot="user-name">河童エクマ</span>
<span slot="user-description">人間の姿にがんばって化けている働く河童。</span>
<span slot="user-date">1月1日</span>
</div>
<script>
const template = document.querySelector('#template');
const div = document.querySelector('#card');
// (A)
const shadow = div.attachShadow({
mode: 'closed',
});
// (B)
shadow.append(template.content.cloneNode(true));
</script>
第7章 複数のShadow rootを作ってみる
6章では1つのtemplate
要素と、1つのShadow host(「情報」だけが書かれたマークアップ)の組み合わせを行いました。
ここでは1つのtemplate
要素と、複数のShadow hostを組み合わせてみます。
class
属性とfor...of
を使って、ループ文で処理してみた例がこちら!
<template id="template">
<table>
<tbody>
<tr>
<th>ID</th>
<td><slot name="user-id"></slot></td>
</tr>
<tr>
<th>名前</th>
<td><slot name="user-name"></slot></td>
</tr>
<tr>
<th>本文</th>
<td><slot name="user-description"></slot></td>
</tr>
<tr>
<th>投稿日</th>
<td><slot name="user-date"></slot></td>
</tr>
</tbody>
</table>
</template>
<div class="card">
<span slot="user-id">@kawarabeEcma</span>
<span slot="user-name">河童エクマ</span>
<span slot="user-description">こんエクマ~!</span>
<span slot="user-date">1月1日</span>
</div>
<div class="card">
<span slot="user-id">@hogehoge</span>
<span slot="user-name">田中太郎</span>
<span slot="user-description">おはようおはよう</span>
<span slot="user-date">1月2日</span>
</div>
<div class="card">
<span slot="user-id">@piyopiyo</span>
<span slot="user-name">山田太郎</span>
<span slot="user-description">おはこんばんにちは</span>
<span slot="user-date">1月3日</span>
</div>
<script>
const template = document.querySelector('#template');
const cards = document.querySelectorAll('.card');
for (const div of cards) {
const shadow = div.attachShadow({
mode: 'closed',
});
shadow.append(template.content.cloneNode(true));
}
</script>
第8章 オリジナルの要素を使ってみる(Custom Elements編)
続いて、自分だけの新しい要素が作れるCustom Elementを試してみしょう。
先ほどはclass
属性を使って自主的にDOM探索をしてShadow rootを作っていましたが、Custom Elementを使うことでDOM探索をする必要もなくなります。
※ オリジナルの要素名は、必ずハイフン(-
)を1つ含める必要があります。
<hoge-piyo>
<span slot="user-id">@kawarabeEcma</span>
<span slot="user-name">河童エクマ</span>
<span slot="user-description">こんエクマ~!</span>
<span slot="user-date">1月1日</span>
</hoge-piyo>
<hoge-piyo>
<span slot="user-id">@hogehoge</span>
<span slot="user-name">田中太郎</span>
<span slot="user-description">おはようおはよう</span>
<span slot="user-date">1月2日</span>
</hoge-piyo>
<hoge-piyo>
<span slot="user-id">@piyopiyo</span>
<span slot="user-name">山田太郎</span>
<span slot="user-description">おはこんばんにちは</span>
<span slot="user-date">1月3日</span>
</hoge-piyo>
<template id="template">
<table>
<tbody>
<tr>
<th>ID</th>
<td><slot name="user-id"></slot></td>
</tr>
<tr>
<th>名前</th>
<td><slot name="user-name"></slot></td>
</tr>
<tr>
<th>本文</th>
<td><slot name="user-description"></slot></td>
</tr>
<tr>
<th>投稿日</th>
<td><slot name="user-date"></slot></td>
</tr>
</tbody>
</table>
</template>
<script>
/** hoge-piyo要素の振る舞いを定義するclassです */
class HogePiyoHTMLElement extends HTMLElement {
/** 要素が生まれたときに何をするかを書くところ */
constructor() {
super(); // おまじない。詳しくはclassの継承。
const template = document.querySelector('#template');
const shadow = this.attachShadow({
mode: 'closed',
});
shadow.append(template.content.cloneNode(true));
}
}
// 作ったオリジナルの要素をブラウザに覚えてもらう
customElements.define('hoge-piyo', HogePiyoHTMLElement);
</script>
class
を使ってhoge-piyo
要素が生み出されたとき、自動的にtemplate#template
要素を使って画面に出力する、という動きが定義されました。
これは元々HTMLに書かれていたhoge-piyo
要素のみならず、JavaScriptで作ったものにも、しっかり自動的に反応してくれます。
const newHogePiyo = document.createElement('hoge-piyo');
newHogePiyo.insertAdjacentHTML('afterbegin', = `
<span slot="user-id">@ponyoponyo</span>
<span slot="user-name">佐藤太郎</span>
<span slot="user-description">あたらしいやつ</span>
<span slot="user-date">1月4日</span>
`;
document.body.append(newHogePiyo); // ちゃんとテンプレにはまって表示される
第9章 Shadow DOMの中身をスタイリングしたい
通常のCSSはShadow rootの中に影響を及ぼさず、Shadow rootの中にあるCSSはその外に影響しません。
これはShadow DOMの最も大きな特徴だと思います。
これまでiframe
要素で実現してきた埋め込み系ウィジェットや、ブックマークレットなどで操作パネルなどを画面上に表示させたい場合に、既存スタイルに影響されず安心してスタイリングができます。
Shadow rootの中をスタイリングするには、Shadow rootの中でstyle
要素を埋め込むか、link
要素で外部CSSを読み込むしかありません。
Shadow rootの中のlink
要素はレンダリングをブロックしないため、外部CSSを読み込もうとするとFOUC現象が発生する可能性があります。同様のtextContent
を持つstyle
要素が複数個所にいた場合でも、ブラウザがうまく最適化してくれるので、どちらの実装方法でもパフォーマンスは同様です2。
なお、Shadow hostになっている要素をShadow rootの中からスタイリングしたい場合は、:host
というセレクタが利用できます。
:host {
background: red;
}
💡 2023年現在はstyle
要素を設置せずに、ShadowDOM自体に適応できます
const sheet = new CSSStyleSheet();
const shadow = node.attachShadow({ mode: "closed" });
sheet.replaceSync(`
:host {
background: red;
}
`);
shadow.adoptedStyleSheets = [sheet];
See also: Document: adoptedStyleSheets property - Web APIs | MDN
これでShadow hostになっている要素の背景が赤くなります。
ただし、すでに通常のCSSでShadow host化したにスタイルが当たっている場合はそちらのほうが優先度が高くなることに注意が必要です。
第4章のコードでスタイリングしてみよう。
次の例では、うまく文字の色が赤くなりません。
少し難しい部分ですが、これは実際にp
要素がShadow rootの中にいるわけではないためです。
あくまでもこの例で登場するstyle
要素は、Shadow rootの中にいるものです。そのため、「Shadow hostそのもの」と、そのShadow rootの中身しか装飾できません。
<div>
<!-- #shadow-root -->
<slot><p>こんえくま!🥒</p></slot> <!-- このp要素はここにいるように見えるけれど、実際にはいないのでCSSが当たらない -->
<style>
/* shadow-rootの中しかCSSを当てられず、`slot`要素の中身にはCSSが当たらない */
:host {
/* ルートの`div`要素を装飾できる */
}
</style>
<!-- /#shadow-root -->
<!-- <p>こんえくま!🥒</p> -->
</div>
template
要素との組み合わせのタイミングでstyle
要素を作成するか、template
要素の中にstyle
要素ごと記述することで、一緒にShadow rootの中に設置していく必要があります。
それを考慮したうえでtemplate
要素の構造を考えてみましょう。
// 第4章の例
const div = document.createElement('div');
const slot = document.createElement('slot');
const style = document.createElement('style'); // style要素を作成
const shadow = div.attachShadow({
mode: 'closed',
});
div.insertAdjacentHTML('afterbegin', '<p>こんえくま!🥒</p>');
// CSSを記述
style.textContent = `
:host {
background: pink;
}
slot {
color: blue; /* 当たる */
}
p {
color: red; /* うまく当たらない */
}
`;
shadow.append(slot);
shadow.append(style); // Shadow rootに追加
document.body.append(div);
// Custom Elementの例
class HogePiyoHTMLElement extends HTMLElement {
cssText = `
:host {
display: block;
background: pink;
}
slot {
color: blue; /* 当たる */
}
span {
color: red; /* うまく当たらない */
}
`
constructor() {
super();
const template = document.querySelector('#template');
const style = document.createElement('style'); // style要素を作成
const shadow = this.attachShadow({
mode: 'closed',
});
// CSSを記述
style.textContent = this.cssText;
shadow.append(template.content.cloneNode(true));
shadow.append(style); // Shadow rootに追加
}
}
// 作ったオリジナルの要素をブラウザに覚えてもらう
customElements.define('hoge-piyo', HogePiyoHTMLElement);
おまけ:Custom Elementの良さ
独自の命名規則で属性を定義できたり、アクセサを使うことでIDL属性のように実装することができるところも面白いですが、1番の魅力は1つの要素だけで1つのUIとして完結させられるところではないでしょうか。
たとえば、input
要素やtextarea
要素はHTMLに記述するだけで、文字が書けてサイズも変更できるものが生まれます。
video[controls]
要素も置いただけで操作可能なプレイヤーがすぐに生成されますが、これらのような独自の機能をもったものはShadow DOMが使われています。
Google Chromeの開発者ツールでは、次の手順でその実装の構造(Shadow hostの中身)を確認できるようになります。
実際にinput
要素などを確認してみてください。
- 開発者ツールを開く(F12、Command+Option+l)
- 「Settings」を開く(歯車のマーク)
- 「Preferences」の「Elements」の「Show user agent shadow DOM」にチェックを入れる
第8章などのhoge-piyo
要素の例では、Custom Elementが生成されたら決められたテンプレートに従って表示してくれるようにcustomElement.define()
でブラウザと約束しましたが、これらの要素もそれと同じ理論で「あらかじめブラウザがそういう風に機能を提供するようになっている」というイメージです。
こうすると要素が生まれたタイミングでattachShadow
をすることやtemplate
の中身をShadow rootに埋め込んだりすることをブラウザが自動的にやってくれるので、ループ文も必要なくなります。
第10章 最後に
ここまで書いてきた内容の全部入りのコードを用意しました。
これをいじくりまわしてShadow DOMで遊んでみましょう!🥒
<style>
#list {
display: flex;
flex-wrap: wrap;
}
hoge-piyo {
flex: 1;
margin: 10px;
}
</style>
<div id="list">
<hoge-piyo>
<span slot="user-id"><a href="https://twitter.com/kawarabeEcma">@kawarabeEcma</a></span>
<span slot="user-name">河童エクマ</span>
<span slot="user-description">こんエクマ~!</span>
<span slot="user-date">1月1日</span>
</hoge-piyo>
<hoge-piyo>
<span slot="user-id">@hogehoge</span>
<span slot="user-name">田中太郎</span>
<span slot="user-description">おはようおはよう</span>
<span slot="user-date">1月2日</span>
</hoge-piyo>
<hoge-piyo>
<span slot="user-id">@piyopiyo</span>
<span slot="user-name">山田太郎</span>
<span slot="user-description">おはこんばんにちは</span>
<span slot="user-date">1月3日</span>
</hoge-piyo>
</div>
<template id="template">
<style>
:host {
background: cyan;
}
table {
border-spacing: 4px;
}
th,
td {
padding: 10px;
}
th {
white-space: nowrap;
background: #efefef;
}
</style>
<table>
<tbody>
<tr>
<th>ID</th>
<td><slot name="user-id"></slot></td>
</tr>
<tr>
<th>名前</th>
<td><slot name="user-name"></slot></td>
</tr>
<tr>
<th>本文</th>
<td><slot name="user-description"></slot></td>
</tr>
<tr>
<th>投稿日</th>
<td><slot name="user-date"></slot></td>
</tr>
</tbody>
</table>
</template>
<script>
{
console.log('hoge-piyo要素の動きを定義します');
class HogePiyoHTMLElement extends HTMLElement {
constructor() {
super();
const template = document.querySelector('#template');
const shadow = this.attachShadow({
mode: 'closed'
});
shadow.append(template.content.cloneNode(true));
console.log('hoge-piyo要素が生まれたよ', this);
}
connectedCallback() {
console.log('hoge-piyo要素が追加されたよ', this);
}
disconnectedCallback() {
console.log('hoge-piyo要素がDOMから削除されたよ', this);
}
}
console.log('hoge-piyo要素をブラウザに知ってもらいます');
customElements.define('hoge-piyo', HogePiyoHTMLElement);
}
{
console.log('新しいhoge-piyoを作成してページに追加します。 ---');
const list = document.querySelector('#list');
const newHogePiyo = document.createElement('hoge-piyo');
newHogePiyo.insertAdjacentHTML('afterbegin', `
<span slot="user-id">@ponyoponyo</span>
<span slot="user-name">佐藤太郎</span>
<span slot="user-description">あたらしいやつ</span>
<span slot="user-date">1月4日</span>
`);
list.append(newHogePiyo); // ちゃんとテンプレにはまって表示される
}
</script>
-
そのメリットについてはこちらを参照:Open vs. Closed Shadow DOM ↩