◆ CustomElements
  ◆ タグを自作できる
  ◆ メソッド・プロパティ・getter/setter など作れる
◆ ShadowDOM
  ◆ 外側のツリーと切り離す
  ◆ CSS の影響を受けない
  ◆ id や class など属性を自由につけれる
◆ template タグ
  ◆ HTML 構造のテンプレート
  ◆ 同じ構造のものを複数使うところでテンプレートをクローンして使う
◆ HTMLImports
  ◆ 外部 HTML を読み込む
  ◆ template や CustomElement の定義を HTML ファイルにまとめられる
◆ まとめて使う
  ◆ HTML ファイルをインポートするだけでコンポーネントを使えるように出来る

WebComponents

昔見たページが WebComponents をうまくまとめてくれていて分かりやすかったのですが 久々に使おうとしてググってみても見当たらなかったので自分でまとめてみます
個々の機能はあちこちに丁寧な説明があるのですが全部まとめた使い方ってあまりないのですよね
特に HTMLImports は見当たらないです

CustomElements と ShadowDOM は使い方が変わって新しいのが v1 です
古いのは v0 です
ここでは v1 のみの紹介です

CustomElements

p タグや div タグのようなタグを自作できる機能です

これまででも存在しない適当なタグ名を使えました
エラーは出ずに未定義の HTMLUnknownElement という型になります

クラスを customElements に登録

CustomElements ではタグ名とそのタグの動作を定義するクラスを登録できます
クラス定義でメソッドやプロパティの追加 イベント時の処理を設定できるようになります

class CustomElement extends HTMLElement {
x(){ console.log(100) }
get y(){ return this.hasAttribute("y") }
set y(v){ this.setAttribute("y", v) }
z(){ this.innerHTML = `<div class="a"><span class="b">c</span></div>` }
}
customElements.define("c-elem", CustomElement)

const ce = document.createElement("c-elem")

ce.x()
// 100

ce.y = "foo"
ce.outerHTML
// <c-elem y="foo"></c-elem>

ce.z()
ce.outerHTML
// <c-elem y="foo"><div class="a"><span class="b">c</span></div></c-elem>

CustomElements の登録には customElements.define を使います
タグ名とそのタグの挙動を定義したクラスを引数に渡します

クラスは HTMLElement を継承している必要があります
HTMLElement を継承してないと define でエラーになります
customElements.define で定義したものは document.createElement で作ることができるようになります
直接クラスを new して body などに append しても大丈夫です

document.body.append(new CustomElement())

children

コンストラクタで初期化時に子要素を変更するなどができます
しかし 子要素も含めて全体の DOM の一部なので外側から普通にアクセスできます

特別なメソッドやプロパティがある div を別名で作ってるようなものです

タグ名に 「-」 必須

定義するタグ名には 「-」 が含まれていないといけません
ないと登録時にエラーが出ます

組み込みのタグには 「-」 が含まれないので将来的に重複しないようにという理由だったと思います
「custom-element」や「libname-customelement」などが登録可能です
組み込みタグでは 2 語になっても fieldset, textarea, datalist のように「-」なしの名前になります

クラス構文使わないとダメ

クラス構文嫌いな人もいるとは思いますが CustomElements の登録ではクラス構文必須です
仕様レベルでの決まりではないように思うので将来的にはかわるかもしれません

HTMLElement を継承していないといけないのは決まっているので 継承するようクラス構文ではない方法で実装してみると HTMLElement.apply で new が必要と言われます

function CustomElement(...args){
HTMLElement.apply(this, args)
}
CustomElement.prototype.__proto__ = HTMLElement.prototype
customElements.define("c-elem", CustomElement)
const ce = document.createElement("c-elem")
// Please use the 'new' operator, this DOM object constructor cannot be called as a function.

かと言って new を使っても Illegal constructor になるので CustomElement のインスタンスを作ることができません

無名クラスで定義

要素を作るときにクラス名に対して new を使わないなら 無名クラスでつくってしまえます
customElements.define("c-elem", class extends HTMLElement {
x(){ console.log(100) }
})
document.createElement("c-elem").x()
// 100

こっちのほうが楽でよく見る方法です

この方法でもクラスを取得できないわけではないです
customElements.get でタグ名を指定するとコンストラクタを返してくれます
customElements.define("c-elem", class extends HTMLElement {
x(){ console.log(100) }
})
const CustomElement = customElements.get("c-elem")
new CustomElement().x()
// 100

HTMLElement 以外を継承

HTMLElement を継承していれば他の要素を継承することもできます
ただ 今のところは Chrome ではちゃんと動いていません (→ Chrome 67 で実装されました)
Chrome の開発者向けブログのサンプルをそのまま使ってもダメだったので私の確認時のコードにミスがあったということはないと思います

継承できるものは例えば HTMLButtonElement があります
変更するところは

  • class の extends を変更
  • define の extends を指定
  • createElement の is を指定

の 3 つです

customElements.define("button-elem", class extends HTMLButtonElement {}, {extends: "button"})
document.createElement("button", {is: "button-elem"})

今の Chrome だとインスタンスは作れるのですがクラスを継承してくれず 自分で定義したメソッドがインスタンスに存在しません
またコンストラクタも実行されません
定義できるだけで使えません

customElements.define("button-elem", class HTMLButtonElement {
constructor(){ console.log("constructor") }
method(){ console.log("method") }
}, {extends: "button"})
const elem = document.createElement("button", {is: "button-elem"})
// no output
elem.method()
// TypeError: elem.method is not a function

もうひとつ Chrome で動かない部分があって HTMLButtonElement などを継承したクラスを new できません

customElements.define("button-elem", class extends HTMLButtonElement {}, {extends: "button"})
new (customElements.get("button-elem"))
// Illegal constructor

仕様的にはできるはずらしいですし 関係しそうな issue はあったので数バージョンの内には直るんじゃないかなと思ってます

getter/setter

hidden や id など一部の属性はプロパティと対応していて 片方を更新するともう一方もされます
そういう処理はクラス定義で自分で作る必要があります

customElements.define("c-elem", class HTMLElement {
get customId(){
return this.getAttribute("custom-id")
}
set customId(value){
this.setAttribute("custom-id", value)
}
get customHidden(){
return this.hasAttribute("custom-hidden")
}
set customHidden(value){
if(value){
this.setAttribute("custom-hidden", "")
}else{
this.removeAttribute("custom-hidden")
}
}
})

ShadowDOM

要素の内側に切り離された空間を作ることが出来る機能です
外側のクエリセレクタなどでは内側が見えません
通常のアクセスができない隔離された空間なので iframe に近いです
Google 関係の ShadowDOM の解説ではよく video タグは内部で ShadowDOM で実装されていると言われています
ただしページの JavaScript レベルで作るものは video みたいに中が見えないことはなく devtools で ShadowDOM のツリーも見えます

HTML

つくる

要素の attachShadow メソッドを実行すると ShadowDOM がアタッチされます
attachShadow の返り値か attachShadow 実行後の要素の shadowRoot プロパティで ShadowRoot を取得できます
ShadowRoot は DocumentFragment の一種なので innerHTML や append で内側に要素を追加できます

const div = document.createElement("div")
div.innerHTML = `<span>300</span>`
div.attachShadow({mode: "open"})
div.shadowRoot.innerHTML = `<p>400</p>`
document.body.append(div)
div.outerHTML
// <div><span>300</span></div>

画面表示: 400

devtools:
<div>
#shadow-root (open)
<p>400</p>
<span>300</span>
</div>

slot

div の子要素に 300 が存在しますが 画面上は ShadowDOM の中の 400 が表示されています
ShadowDOM があるとそっちのツリーが優先して表示されます
ShadowDOM をアタッチした要素の子要素は slot タグを使って ShadowDOM のツリー内に配置できます

<!-- document tree -->
<div id="div">
<p slot="s1">s1のコンテンツ</p>
<span slot="s2">s2のコンテンツ</span>
<b slot="s2">これもs2のコンテンツ</b>
<pre>slot指定なし</pre>
<section>ここもslot指定なし</section>
</div>
<!-- div's shadow tree -->
<section>
<h1>s1</h1>
<slot name="s1"></slot>
<h1>s2</h1>
<slot name="s2"></slot>
<h1>デフォルト</h1>
<slot></slot>
<h1>デフォルトは全部1つ目に入って2つ目は空</h1>
<slot></slot>
<h1>フォールバックコンテンツ</h1>
<slot name="s3">s3 がないので代わりにこれが表示される</slot>
</section>
document.body.innerHTML = document_tree
document.querySelector("#div").attachShadow({mode: "open"}).innerHTML = shadow_tree
<div id="div">
#shadow-root (open)
<section>
<h1>s1</h1>
<slot name="s1">
→<p>
</slot>
<h1>s2</h1>
<slot name="s2">
→<span>
→<b>
</slot>
<h1>デフォルト</h1>
<slot>
→<pre>
→<section>
</slot>
<h1>デフォルトは全部1つ目に入って2つ目は空</h1>
<slot>
</slot>
<h1>フォールバックコンテンツ</h1>
<slot name="s3">
s3 がないので代わりにこれが表示される
→#text
</slot>
</section>
<p slot="s1">s1のコンテンツ</p>
<span slot="s2">s2のコンテンツ</span>
<b slot="s2">これもs2のコンテンツ</b>
<pre>slot指定なし</pre>
<section>ここもslot指定なし</section>

右矢印のところが slot 機能で表示されているものです


ShadowDOM をアタッチした要素の子要素は ShadowDOM の中の slot タグの中に表示されます
基本は 1 つ目の slot にすべて入ります
なので 2 つ目以降の slot は空になります

ただし 子要素の slot 属性に名前をつけておけば ShadowDOM 内の name 属性が同じ slot タグの中に表示されます
表示される位置が変わるだけで実体の位置は変わりません
parentElement が slot になったりはしないです
表示位置が ShadowDOM の中と言うだけで要素は ShadowDOM の外側にあります

slot タグにも子要素を書けます
slot に対応する要素がないときのフォールバックコンテンツです

slot に表示できるもの

特殊なものでは li や tr タグは表示できます
ですが option はできませんでした

<div>
#shadow-root (open)
<select>
<slot>
→<option>
→<option>
→<option>
</slot>
</select>
<option>option1</option>
<option>option2</option>
<option>option3</option>
</div>

→ 表示されない

<div>
#shadow-root (open)
<ul>
<slot>
→<li>
→<li>
→<li>
</slot>
</ul>
<li>item1</li>
<li>item2</li>
<li>item3</li>
</div>

→ 表示される

<div>
#shadow-root (open)
<table>
<tbody>
<slot>
→<tr>
→<tr>
</slot>
</tbody>
</table>
<tr><td>12345</td><td>6</td></tr>
<tr><td>7</td><td>890890</td></tr>
</div>

→ 表示される

ネスト

ShadowDOM の中の要素に ShadowDOM をアタッチすることもできます

<!-- document tree -->
<div>
<p>text</p>
</div>
<!-- div's shadow tree -->
<section>
<slot></slot>
</section>
<!-- section's shadow tree -->
<h1>
<slot></slot>
</h1>
<!-- result -->
<div>
#shadow-root (open)
<section>
#shadow-root (open)
<h1>
<slot>
→<p>
</slot>
</h1>
</section>
</div>

shadowDOM をアタッチできるタグ

attachShadow はすべてのタグにはできません
というか出来るタグのほうが少ないです

メソッド自体は Element クラスにあるので実行はできるもののエラーになります
try-catch しながらチェックした結果こうなりました

できる:
body, article, section, nav, aside, h1, h2, h3, h4, h5, h6, header,
footer, p, blockquote, main, div, span

できない:
html, head, title, base, link, meta, style, hgroup, address, hr, pre,
ol, ul, menu, li, dl, dt, dd, figure, figcaption, a, em, strong, small,
s, cite, q, dfn, abbr, ruby, rt, rp, data, time, code, var, samp, kbd,
sub, sup, i, b, u, mark, bdi, bdo, br, wbr, ins, del, picture, source,
img, iframe, embed, object, param, video, audio, track, map, area, table,
caption, colgroup, col, tbody, thead, tfoot, tr, td, th, form, label,
input, button, select, datalist, optgroup, option, textarea, output,
progress, meter, fieldset, legend, details, summary, dialog, script,
noscript, template, slot, canvas

JavaScript

JavaScript は ShadowDOM の内外関係なく実行される空間は同じ window のグローバルです
内側にアクセスするには ShadowDOM をアタッチした要素を通して ShadowRoot を経由してアクセスします

attachShadow

attachShadow メソッドは mode の指定が必須です
open か closed を指定します
closed の場合は element.shadowRoot で ShadowRoot を取得できません
attachShadow の返り値を通してしかアクセスできません
ShadowDOM をアタッチした箇所でしか内側にアクセスできないので closed です
devtools を使えば closed でも気にせず操作できます

ShadowRoot

ShadowRoot は DocumentFragment を継承しています
ShadowRoot.prototype.__proto__.constructor.name
// "DocumentFragment"

イベント

ShadowDOM の外では ShadowRoot を持つ要素からイベントが起きたように見えます
一番外側の window にリスナをつけます

window.addEventListener("click", eve => console.log(eve.target, eve.path))

<body>
<div id="xxx">
#shadow-root (open)
<section>click here</section>
</div>
</body>

ShadowDOM の中の section をクリックしてみます

<div id="xxx"></div>
0: section
1: document-fragment
2: div#xxx
3: body
4: shadow
5: document-fragment
6: html
7: document
8: Window {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, frames: Window, …}

イベント発生元を表す target は div になっています
path のほうでは ShadowDOM の中も全部含まれています

もうちょっと複雑な構造にしてみます

<body>
<div id="xxx">
#shadow-root (open)
<slot></slot>
<div id="yyy">
#shadow-root (open)
<div>
<input id="btn" type="button" value="click here">
</div>
</div>
</div>
</body>

ShadowDOM の中の input#btn をクリックします

<div id="yyy"></div>
0: input#btn
1: div
2: document-fragment
3: div#yyy
4: slot
5: document-fragment
6: div#xxx
7: body
8: shadow
9: document-fragment
10: html
11: document
12: Window {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, frames: Window, …}

path には slot も含まれます
div#yyy は div#xxx の子要素で div#xxx の ShadowDOM の中には属していないので target は div#yyy になります

CSS

CSS は内側と外側でスコープが分離されています
内側に書いたセレクタは外側にマッチしませんし 外側に書いたセレクタは内側にマッチしません

ですが 子要素への継承は起きるので外側に書いたスタイルが内側に適用されることはあります
font-size や color がそれにあたります
継承されたくないなら内側のスタイルでリセットする必要があります

ShadowDOM のホストになる要素は内外両方から設定できます
ただし外側優先です

外側の selector

内側から ShadowDOM のホストを対象にしたいなら

:host {}

と書けます

またホスト要素の状態をセレクタで指定したいなら

:host(.active) {}

のようにカッコで指定します

:host.active {}

だと動きません

ホストが特定セレクタにマッチする状態のときの内側のセレクタはそのまま :host に続けて書けます

:host(.foo[data-type="bar"]) .baz {
color: red;
}

ホスト要素の祖先で指定したセレクタを指定することもできます

:host-context(.active) p {
color: red;
}
<div class="active">
<div>
#shadow-root (open)
<p></p>
</div>
</div>

「.active p」としたいのに ShadowDOM の壁で「(.active) → (ShadowRoot) → (p)」となっているときに shadowDOM の外側のセレクタを :host-context に書くだけです

カスタムプロパティ

ShadowDOM 内側のスタイルはあくまでデフォルトで それを使う外側で色やボーダーやマージンなど見た目を調整したいこともあります
カスタムプロパティを使うと対応できます

[内側]
#left{
color: var(--left-color, pink);
}
#right{
color: var(--right-color, aquamarine);
}

[外側]
div.has-shadow{
--left-color: red;
--right-color: blue;
}

var 関数を使って 「--」 から始まる名前を指定しておくと 外側で shadowDOM のホストに指定した同名のプロパティの値が使われます
var の 2 つめの引数は指定がないときのデフォルト値です

Template

template タグです
内側は切り離されて DocumentFragment になります

ShadowDOM とは違って DOM 構造を定義しておくだけのものです
いっさい表示されませんし JavaScript の実行もされません

基本はこれの内側の DocumentFragment をクローンして同じものをいっぱい作るときに使います
JavaScript はクローンして DOM ヘアタッチしたタイミングで実行されます

script タグと同じく好きなところに書けるメリットがあります
tbody, tr, ul の内側など置ける場所が決まっていて パース時に別の場所移動させられて要素へのセレクタが変わってしまう心配もありません

この機能は昔から変わってないのですでに使われてるところが多いと思います
よくあるのはこういうのです

<table>
<tbody>
<template>
<tr><td class="title"></td><td class="body"></td></tr>
</template>
</tbody>
</table>

<script>
const tbody = document.querySelector("tbody")
const tpl = document.querySelector("template").content
fetch("api.json").then(e => e.json()).then(data => {
for(const item of data){
const clone = tpl.cloneNode(true)
clone.querySelector(".title").textContent = item.title
clone.querySelector(".body").textContent = item.body
tbody.append(clone)
}
})
</script>

fetch してきた結果をテーブルで表示するものです
tr を作る時に中身を全部 JavaScript で作るのではなくテンプレートとして準備しておいて変動する箇所のみ書き換えます

HTMLImports

別の HTML をインポートできます
これも iframe に近い感じですが表示はしません
リソースとして取得してくるような使い方です

link タグに rel="import" を指定することで使えます
href 属性にインポートする html のパスを指定します

インポートした HTML は window.document とは別にドキュメントが作られそこに属します
JavaScript で element.import にアクセスすると HTMLDocument が取得できます

インポートする HTML 中に script タグがあった場合は解析時に即実行されます
JavaScript の実行空間は共通です
インポート中の script からでも window.document はメインのドキュメントを参照します
自身の属する document を参照するには document.currentScript.ownerDocument を使います

document.querySelector で link 要素を取得して import プロパティにアクセスする手段もあります
ただしその場合は インポートする側に id などの要素を特定できる属性が必須になってしまうのであまりオススメじゃないです

[main.html]
<link id="import" rel="import" href="import.html">
<div id="div"></div>
<script>
console.log(document.querySelector("#template"))
print1()
document.querySelector("#div").append(
document.querySelector("#import").import.querySelector("#template").content
)
</script>

[import.html]
<template id="template">
<h1>foo</h1>
<p>bar</p>
</template>
<script>
console.log("import.html script start")
function print1(){console.log(1)}
console.log(document.currentScript.ownerDocument.querySelector("#template"))
console.log("import.html script end")
</script>

[最終的な DOM 構造]
<div id="div">
<h1>foo</h1>
<p>bar</p>
</div>

[console の結果]
import.html script start
<template id=​"template">​…​</template>​
import.html script end
null
1

まとめる

それぞれ単体でも十分使えますが 4 つをまとめて使うことができます

CustomElement のコンストラクタで自身に ShadowDOM をアタッチすることで内側はそのクラスで管理するようにできます
querySelector にひっかかったり css の影響を受けたくないときに便利です

ShadowDOM は作っても中身は空です
自分で中の要素をつくらないといけないのですが文字列ベースで作るのは大変です
template タグを用意しておけば 中身をクローンして ShadowRoot に append するだけで要素の構造の初期化は完了です

template タグや CustomElement の定義をメインの HTML に入れると長くなりますし使いまわすのが難しいです
コンポーネントのファイルを読み込むだけで CustomElement を使えるようにするために HTMLImports が使えます
独立した HTML ファイルにまとめて HTMLImports でロードします
ロード時に JavaScript は実行されて CustomElement が定義されるので使う側はロードするだけ済みます

サンプル

トグルボタンを作ってみたサンプルです
readonly, disabled などは未対応です

ボタンをちゃんと継承できるようになれば自力で頑張る必要もないところなので作ってません

[main.html]
<!doctype html>
<link id="import" rel="import" href="toggle-button.html">

<table width=300>
<tr>
<th>AAA</th>
<td>
<toggle-button id="tb1" state="right"></toggle-button>
</td>
</tr>
<tr>
<th>BBB</th>
<td>
<toggle-button id="tb2" leftvalue="foo" rightvalue="bar" state="left"></toggle-button>
</td>
</tr>
</table>

[toggle-button.html]
<!doctype html>
<template id="toggle-button-template">
<style>
div{
width: 60px;
height: 28px;
display: inline-block;
position: relative;
}
div::before{
content: "";
display: block;
width: 100%;
height: 10px;
border-radius: 5px;
background: silver;
margin: 9px 0;
}
input{
display: none;
}
label{
width: 20px;
height: 20px;
border-radius: 50%;
box-shadow: 1px 1px 1px 1px #555;
background: #f5f5f5;
position: absolute;
top: 4px;
left: 0;
}
input:checked+label{
left: auto;
right: 0;
}
</style>
<div>
<input id="checkbox" type="checkbox">
<label for="checkbox"></label>
</div>
</template>
<script>
{
const self_doc = document.currentScript.ownerDocument
customElements.define("toggle-button", class extends HTMLElement {
constructor(){
super()
const root = this.attachShadow({mode: "open"})
const template = self_doc.querySelector("#toggle-button-template").content
root.append(template.cloneNode(true))
if(this.hasAttribute("state")){
this.state = this.getAttribute("state")
}
this.addEventListener("click", eve => {
this.toggle()
})
}
get value(){
const check = this.shadowRoot.querySelector("#checkbox")
return [
this.getAttribute("leftvalue") || "left",
this.getAttribute("rightvalue") || "right",
][+check.checked]
}
get state(){
const check = this.shadowRoot.querySelector("#checkbox")
return ["left", "right"][+check.checked]
}
set state(value){
const check = this.shadowRoot.querySelector("#checkbox")
check.checked = value === "right"
}
toggle(){
const check = this.shadowRoot.querySelector("#checkbox")
check.checked = !check.checked
return this.value
}
})
}
</script>


適当に操作してみるとこんな感じです
document.querySelector("#tb1").value
// right
document.querySelector("#tb1").state
// right
document.querySelector("#tb2").value
// foo
document.querySelector("#tb2").state
// left
document.querySelector("#tb2").toggle()
// bar
document.querySelector("#tb2").state
// right

state が left か right を表していて value では left と right に設定した名前で取得できます

template 内の script で初期化出来ない

ShadowDOM 内のイベントリスナをするなどの初期化はクラスのコンストラクタではなく template タグの中の script タグに任せたいです
ですが template タグ内の script タグから自身の要素を取得できません
document.currentScript は null になります

単純にクリックで色を切り替える color-button を作ってみます
理想ではこう書きたいです

<!doctype html>
<template id="color-button-template">
<style>
:host{
display: inline-block;
width: 80px;
height: 80px;
}
div{
width: 100%;
height: 100%;
}
</style>
<div></div>
<script>
{
const root = document.currentScript.getRootNode()
const div = root.querySelector("div")
div.addEventListener("click", update)
update()
function update(){
div.style.background = "#" + Math.random().toString(16).substr(2, 3)
}
}
</script>
</template>

<script>
{
const self_doc = document.currentScript.ownerDocument
customElements.define("color-button", class extends HTMLElement {
constructor(){
super()
const root = this.attachShadow({mode: "open"})
const template = self_doc.querySelector("#color-button-template").content
root.append(template.cloneNode(true))
}
})
}
</script>

クラスのコンストラクタではテンプレートのクローンを ShadowRoot に append するだけで template 内の script タグで初期化を行っています
ですが実際のところ これでは null の getRootNode プロパティへのアクセスとなってエラーです

トグルボタンのようにコンストラクタで処理するか ちょっと無理矢理 template の script タグから自身の ShadowRoot にアクセスできるようにするかです

<!doctype html>
<template id="color-button-template">
<style>
:host{
display: inline-block;
width: 80px;
height: 80px;
}
div{
width: 100%;
height: 100%;
}
</style>
<div></div>
<script>
{
const root = customElements.get("color-button").latest_root // changed
const div = root.querySelector("div")
div.addEventListener("click", update)
update()
function update(){
div.style.background = "#" + Math.random().toString(16).substr(2, 3)
}
}
</script>
</template>

<script>
{
const self_doc = document.currentScript.ownerDocument
customElements.define("color-button", class extends HTMLElement {
constructor(){
super()
const root = this.attachShadow({mode: "open"})
const template = self_doc.querySelector("#color-button-template").content
this.constructor.latest_root = root // added
root.append(template.cloneNode(true))
}
})
}
</script>

参考

主にこの辺です
https://developers.google.com/web/fundamentals/web-components/shadowdom?hl=ja
https://developers.google.com/web/fundamentals/web-components/customelements?hl=ja