🐙

基本的なWebComponentsの作り方

2023/10/14に公開

Web Componentsとは

ロジック(Javascript)と見た目(HTML、css)をひとまとめにした独自のHTMLタグを作ることができます
ReactやVue.jsのコンポーネントのようなものですが、特定のフレームワークに依存せず広く使用できることがメリットです
現在(2023/10/14)の主要なブラウザはサポートされています

まずはHelloWorld

まずはプログラミングのお約束のHello Worldです
画面にHello Worldと表示するだけのWeb Components(以後Hello Worldコンポーネントと呼ぶ)です

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>WebComponentsテスト</title>
</head>
<body>
<hello-world></hello-world>
<script>
    class HelloWorld extends HTMLElement{
        connectedCallback() {
            this.innerText = 'Hello World';
        }
    }
    customElements.define( 'hello-world', HelloWorld );
</script>
</body>
</html>

見たことのない<hello-world></hello-world>というHTMLタグがありますが、このように独自のタグを作成することができます

では、具体的な作り方ですが、まずHTMLElementを継承したクラス(HelloWorld)を定義します
定義したクラスをcustomElements.defineでタグとして登録します
今回はわかりやすくするためhello-worldタグとして登録しましたが、クラス名と全く違うタグを設定することもできます
例えばcustomElements.define('test', HelloWorld)とすると、<test></test>というタグで同様に画面にHello Worldと表示されます

connectedCallback()でなにをしているの?

connectedCallbackはDOMにHello Worldコンポーネントが追加されるタイミングで実行されます
初期化処理や内部のDOMを生成するコードを記述します

connectedCallback() {
    this.innerText = 'Hello World';
}

今回は簡単にinnerTextにHello Worldを設定しているのみです
通常のJavascriptでDOMを操作する場合とそう大きな違いはありません
子要素に別のエレメントを追加する場合はdocument.createElement()でエレメントを作成し、appendChildします

金額入力コンポーネントを作成してみる

ごく基本的なWeb Componentsの作成方法を説明しましたが、画面に固定の文字を表示するだけでは実用性がありません
より実用的な例として金額入力コンポーネントを作成します
必要な機能は以下の通りとします

  • 数字と数値に関するもの記号のみ入力可能
  • 3桁ごとにカンマを表示する
  • valueは数値とする
  • 別のライブラリやフレームワークなどの影響を受けないようにする

具体的なコードは以下のようになります

class CurrencyInput extends HTMLElement{
    connectedCallback() {
        const shadow = this.attachShadow({mode: 'open'});
        const input = document.createElement('input');
        input.part = 'input';
        input.addEventListener('input', e => {
            if(e.target.value !== '') {
                const numberValue = Number(e.target.value.replace(/,/g, ''));
                if (!isNaN(numberValue)) {
                    input.value = numberValue.toLocaleString();
                    this.value = numberValue;
                } else {
                    input.value = input.value.replace(/[^0-9.-]/g, '');
                    this.value = 0;
                }
            } else {
                this.value = 0;
            }

            this.dispatchEvent(new Event('input'));
        });
        shadow.appendChild(input);  // <-- Shadow Domにする場合
        // this.appendChild(input); // <-- Shadow Domにしない場合
    }
}
customElements.define( 'currency-input', CurrencyInput );

コンポーネント初期化

内部にinputタグを一つだけ持つシンプルな構造とします
初期化処理はシンプルなものでdocument.createElementでinputのエレメントを作成し、イベントなどを設定しています
inputイベントで入力された値を判定し、表示用のテキストとしてinput.valueを書き換えています
その後、コンポーネントの外部にinputイベントを発火しています

ここでポイントとなるのがinput.valuethis.valueの2つ変数が存在している点です
input.valueはコンポーネント内部で使用されるだけで、外部からは取得できません
一方、this.valueは外部から取得することができます

例えば以下のようにすると外部から値を取得することができます

<currency-input></currency-input>
<script>
  const value = document.querySelector('currency-input').value;
</script>

Web Componentsも既存のHTMLタグと同様にdocument.querySelectorで取得することができます

コンポーネントを外部から切り離す

Web Componentsを決まった用途でしか使用しない場合、あまり考慮する必要はないかもしれませんが、複数プロジェクトなどで使いまわす場合、別のライブラリやcssに影響を受けるのは好ましくありません
そこで、コンポーネント内部を完全に別のものとして切り離すことができます
これをShadow Domといいます

コンポーネント内部をShadow Domにするにはconst shadow = this.attachShadow({mode: 'open'});とすればいいです
ここで作成したshadowに対して別途作成したエレメントをappendChildしていくことになります
逆にShadow Domにしたくない場合はthis.appendChildとすれば外部と繋がったDomになります

Shadow Domとした場合、下記のコードはエラーとなります

<currency-input></currency-input>
<script>
  // Shadow Domの場合currency-input内部のinputに直接アクセスできない
  const value = document.querySelector('currency-input input').value;
</script>

Shadow Domのデメリット

Shadow Domにすれば、他のライブラリなどの影響を受けないため、予期せぬ原因で動作不良を起こすことがありません
一方、デメリットも存在します
Javascriptだけではなく、cssも切り離されているという点です
例えば、bootstrapを使用する場合を考えます

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>WebComponentsテスト</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">

</head>
<body>
<custom-button></custom-button>
<button class="btn btn-primary">primary</button>
<script>
    class CustomButton extends HTMLElement{
        connectedCallback() {
            const shadow = this.attachShadow({mode: 'open'});
            const button = document.createElement('button');
            button.className = 'btn btn-primary';
            button.innerText = 'primary';
            shadow.appendChild(button);
        }
    }
    customElements.define( 'custom-button', CustomButton);
</script>
</body>
</html>

2つのbuttonの見た目が違うことがわかります
custom-buttonのbuttonにはbootstrapの影響が及んでいません

Shadow Dom内部に外部からcssを適用する方法

Shadow Domは外部と切り離されていますが、cssに関しては外部から適用する方法があります
まずコンポーネント側の準備としてcssを適用させたいエレメントにpartというプロパティを指定します

const input = document.createElement('input');
input.part = 'inner-input'; // 任意の名前を指定できる

その後、疑似要素partを使用してcssを適用します
カッコ内はpartで指定した名前を指定します

currency-input::part(inner-input){
  text-align: right;            
}

これでnumeric-input内部のinputにcssを適用させることができます
注意点としてはpartが指定されていなければ、cssは適用できないという点です

今回の成果物

最後に全コードを記載しておきます

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>WebComponentsテスト</title>

    <style>
        currency-input::part(inner-input){
            text-align: right;
        }
    </style>
</head>
<body>
    <currency-input></currency-input>
    <button type="button" id="check" class="btn">value check</button>
    <script>
        class CurrencyInput extends HTMLElement{
            connectedCallback() {
                const input = document.createElement('input');
                input.part = 'inner-input';
                input.addEventListener('input', e => {
                    if(e.target.value !== '') {
                        const numberValue = Number(e.target.value.replace(/,/g, ''));
                        if (!isNaN(numberValue)) {
                            input.value = numberValue.toLocaleString();
                            this.value = numberValue;
                        } else {
                            input.value = input.value.replace(/[^0-9.-]/g, '');
                            this.value = 0;
                        }
                    } else {
                        this.value = 0;
                    }

                    this.dispatchEvent(new Event('input'));
                });
                // Shadow Domにする場合
                const shadow = this.attachShadow({mode: 'open'});
                shadow.appendChild(input);
                
                // Shadow Domにしない場合
                //this.appendChild(input);
            }
        }
        customElements.define( 'currency-input', CurrencyInput );
        
        document.getElementById('check').addEventListener('click', e => {
            console.log(document.querySelector('currency-input').value)
        });
    </script>
</body>
</html>

Discussion