はじめに
React に慣れてきた頃、WebComponents を使う機会がありました。
React だと書けるが、WebComponents では書けないことがあり、ハマったので記事にしてみます。
ハマったところ
select
をコンポーネントにしてみます。
React の場合
MUI を参考にしてみます。
React Select component - Material UI
Select components are used for collecting user provided information from a list of options.
https://mui.com/material-ui/react-select/
<Select>
<MenuItem value={10}>Ten</MenuItem>
<MenuItem value={20}>Twenty</MenuItem>
<MenuItem value={30}>Thirty</MenuItem>
</Select>
select
のコンポーネントと言えば、こんな感じですよね。
WebComponents で実装した場合
MUI のように理想としては以下のように作りたいところです。
<x-select>
<x-option value="10">Ten</x-option>
<x-option value="20">Twenty</x-option>
<x-option value="30">Thirty</x-option>
</x-select>
では作成してみましょう。
まずは WebComponents のおさらいですが、以下のようにすればdiv
で囲ってslot
が入るだけのコンポーネントを作れます。
customElements.define(
"x-div",
class extends HTMLElement {
constructor() {
super();
this._root = this.attachShadow({ mode: "closed" });
}
render() {
this._root.innerHTML = `<div><slot/></div>`;
}
connectedCallback() {
this.render();
}
}
);
<x-div>SLOTの内容<x-div></x-div></x-div>
上に従ってx-select
とx-option
を作ってみましょう。
customElements.define(
"x-select",
class extends HTMLElement {
constructor() {
super();
this._root = this.attachShadow({ mode: "closed" });
}
render() {
this._root.innerHTML = `<select><slot/></select>`;
}
connectedCallback() {
this.render();
}
}
);
customElements.define(
"x-option",
class extends HTMLElement {
constructor() {
super();
this._root = this.attachShadow({ mode: "closed" });
}
render() {
this._root.innerHTML = `<option><slot/></option>`;
}
connectedCallback() {
this.render();
}
}
);
<x-select>
<x-option value="10">Ten</x-option>
<x-option value="20">Twenty</x-option>
<x-option value="30">Thirty</x-option>
</x-select>
実際に動かしてみます。
何故 slot
の中に、何も入らなかったのでしょうか? 一応 x-option
を使わずに実行してみます。
<x-select>
<option value="10">Ten</option>
<option value="20">Twenty</option>
<option value="30">Thirty</option>
</x-select>
これもslot
の部分に、何も入っていないことが分かります。何故でしょうか?
これは WebComponents の仕様が関係しています。 子に<slot/>
を持てる要素はattachShadow()
が使える要素に限るのです。
使用できる要素の一覧は以下で確認できます。ここにselect
が含まれていないことが分かります。
Element: attachShadow() メソッド - Web API | MDN
Element.attachShadow() メソッドは、シャドウ DOM ツリーを特定の要素に追加し、そのシャドウルート (ShadowRoot) への参照を返します。
https://developer.mozilla.org/ja/docs/Web/API/Element/attachShadow
Customized built-in element の場合
先ほどの例は、自立カスタム要素(Autonomous custom element)で実装したものでした。 では、カスタマイズされた組み込み要素(Customized built-in element)ではどうでしょうか?
コードを以下のように変えてみます。
customElements.define(
"x-select",
class extends HTMLSelectElement {
constructor() {
super();
this._root = this.attachShadow({ mode: "closed" });
}
render() {
this._root.innerHTML = `<select><slot/></select>`;
}
connectedCallback() {
this.render();
}
},
{ extends: "select" }
);
customElements.define(
"x-option",
class extends HTMLOptionElement {
constructor() {
super();
this._root = this.attachShadow({ mode: "closed" });
}
render() {
this._root.innerHTML = `<option><slot/></option>`;
}
connectedCallback() {
this.render();
}
},
{ extends: "option" }
);
<select is="x-select">
<option value="10" is="x-option">Ten</option>
<option value="20" is="x-option">Twenty</option>
<option value="30" is="x-option">Thirty</option>
</select>
実際に動かしてみると、JavaScript のエラーが発生しました。
Uncaught DOMException: Failed to execute 'attachShadow' on 'Element': This element does not support attachShadow at new customElements.define.extends
この原因も先ほどと同じです。 select
やoption
はattachShadow()
が出来ないので、このような実装をする事が出来ないのです。
Declarative Shadow DOM の場合
最近モダンブラウザで使えるようになった、Declarative Shadow DOMもせっかくなので試してみましょう。
<x-select>
<template shadowrootmode="close">
<select>
<slot></slot>
</select>
</template>
</x-select>
<x-option>
<template shadowrootmode="close">
<option>
<slot></slot>
</option>
</template>
</x-option>
<x-select>
<option value="10">Ten</option>
<option value="20">Twenty</option>
<option value="30">Thirty</option>
</x-select>
<x-select>
<x-option value="10">Ten</x-option>
<x-option value="20">Twenty</x-option>
<x-option value="30">Thirty</x-option>
</x-select>
がっ……駄目っ……! 理由はやはり同じです。
結論
WebComponents では、React(MUI)のような書き方が出来ないことが分かりました。
ではどうするのか
Vue.js を思い出してみてください。Vuetify のSelect
では以下のような書き方をします。
Select component — Vuetify
The select component provides a list of options that a user can make selections from.
https://vuetifyjs.com/en/components/selects/#usage
<v-select
label="Select"
:items="['California', 'Colorado', 'Florida', 'Georgia', 'Texas', 'Wyoming']"
></v-select>
このように属性(Props)を使うような書き方をすれば、WebComponents でもそれらしい事が出来ます。
JavaScript で属性を確認して、option
を ShadowDOM 内に生成するようにします。
JSON.parse(this.getAttribute("items")).forEach((item) => {
const option = document.createElement("option");
option.textContent = item;
option.value = item;
this._root.querySelector("select").appendChild(option);
});
こちらでも、課題はいくつかあります。
課題 1 何を入れたら良いのか直感的に分かりづらい
今回は items の中身に配列(JSON)を入れていますが、これは実装者によるため予想不能です。 属性名が value かもしれませんし、options
かもしれません。 配列じゃなくて、CSV 形式(California, Colorado, ...
)かもしれません。
そのため、チームで統一するようにするか、コンポーネントを使う度にドキュメントを見直す事になります。
課題 2 破壊的変更になりがち
<select>
には<option>
以外にも、<optgroup>
を使う事が出来ます。 それを運用中に実装するとなったら困ることになります。
<optgroup>: 選択肢グループ要素 - HTML: ハイパーテキストマークアップ言語 | MDN
<optgroup> は HTML の要素で、 <select> 要素内の選択肢のグループを作成します。
https://developer.mozilla.org/ja/docs/Web/HTML/Element/optgroup
例えば items を配列で実装していた場合は、ここで詰むことになります。 破壊的変更をするか、新しくoptgroup
に対応したselect
コンポーネントを作るかという二択になってしまいます。
JSON でoption
かoptgroup
が選択出来るように実装していた場合はラッキーですが、課題 1 の問題点明らかに出ていると思います。
<x-select
items="[
{'type': 'optgroup', 'label': 'グループ', 'options': {{'label': '公開', 'value': '1'}, {'label': '非公開', 'value': '0'}}},
]"
/>
課題 3 読みづらい
これは個人的な感想ですが、React などの書き方と比べると、分かりづらい書き方を強要されます。 Lit などのフレームワークを使えば多少マシになりますが、React や Vue.js で書きたくなること間違いなしです。
最後に
React と比較した WebComponents の実装を本記事で書きました。 この問題は WebComponents があまり使われていない理由の一つになっていると思います。
attachShadow()
を使う事が出来る要素が増えれば解決するのですが、セキュリティ上の都合による理由など簡単には行かないようです。
それでも、WebComponents のポテンシャルは無視できません。今後のアップデートに期待したいところです。
新しいメンバーを募集しています
Sena / Engineer
生涯に亘り技術を極めていきたい。