Produced by Fourier

Reactのノリで行くとハマるWebComponentsの実装例

Sena Sena カレンダーアイコン 2024.03.13

💡
2024/02/29 現在の情報になります。

はじめに

Reactに慣れてきた頃、WebComponentsを使う機会がありました。

Reactだと書けるが、WebComponentsでは書けないことがあり、ハマったので記事にしてみます。

ハマったところ

select をコンポーネントにしてみます。

Reactの場合

MUIを参考にしてみます。

React Select component - Material UI thumbnail

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-selectx-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 thumbnail

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

この原因も先ほどと同じです。
selectoptionattachShadow() が出来ないので、このような実装をする事が出来ないのです。

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 thumbnail

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 thumbnail

<optgroup>: 選択肢グループ要素 - HTML: ハイパーテキストマークアップ言語 | MDN

<optgroup> は HTML の要素で、 <select> 要素内の選択肢のグループを作成します。

https://developer.mozilla.org/ja/docs/Web/HTML/Element/optgroup

例えばitemsを配列で実装していた場合は、ここで詰むことになります。
破壊的変更をするか、新しく optgroup に対応した select コンポーネントを作るかという二択になってしまいます。

JSONで optionoptgroup が選択出来るように実装していた場合はラッキーですが、課題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

Sena slash forward icon Engineer

生涯に亘り技術を極めていきたい。

関連記事