対象読者
- AngularやReactなどのフレームワークに頼らずに再利用可能なHTMLやCSSを整備したいマークアップエンジニア
- AngularやReactなどのフレームワークとWeb Componentsを併用する利点を学びたいJavaScriptエンジニア
前提環境
筆者の検証環境は以下の通りです。
- macOS Monterey 12.4
- Google Chrome 102.0.5005.115
Web Componentsを構成する3種のAPI群
前回は、再利用可能なUI部品があることの嬉しさと、ブラウザで再利用可能なUI部品を作るための技術「Web Components」について概要を解説しました。今回はもう少し解像度を高めて、どんな役割のAPIがあるのかを確認していきましょう。まずは前回の復習です。Web Componentsには大別して次の3つの技術の組み合わせによって成り立っている、という話をしました。
- カスタム要素(Custom Elements)
- シャドウDOM(Shadow DOM)
- HTMLテンプレート(HTML Templates)
最終的には組み合わせて使うケースが多いものですが、それぞれの特徴を押さえておくことで、必要に応じて柔軟に組み替えられるようにもなります。次項からひとつずつ見ていきましょう。
カスタム要素(Custom Elements)
まずは、カスタム要素(Custom Elements)に関するAPIについて解説します。
Web Componentsの大きな特徴の一つは、元々ブラウザに用意されている組み込み要素(通常のHTMLタグ)とは異なるカスタム要素(自作タグ)を作成して、その中にUIや振る舞いを閉じ込められる(カプセル化できる)ということです。このカスタム要素の名前(タグ名)を決めたり、属性に指定した値に対してどのように振る舞うのかを定義するためのAPIが用意されています。
主要なものについて一つずつ解説していきます。
ページで使えるカスタム要素を登録する
まずは、カスタム要素を扱うための入口となるAPIについて解説します。
当たり前のことではありますが、リスト1のように、普通のHTMLファイルに自作のタグ名を書いても、何も表示されません。
    <!DOCTYPE html>
    <html lang="ja">
    <body>
      <div>
        
        <my-profile></my-profile>
      </div>
    </body>
    </html>
    
 このタグ名や、このタグによって描画すべきものについて、ページに伝えるためのインターフェースとして用意されているのがCustomElementRegistryです。window.customElementsというプロパティでアクセスできるので、HTMLから呼び出したJavaScript内であればほとんどの場所で手軽に利用することができます。
 おもに使うメソッドはCustomElementRegistry.define()です。第一引数にタグ名、第二引数に振る舞いを表すクラスを渡すことで、カスタム要素を登録できます。第三引数にはオプションとして、{ extends: 'p' }のようにHTMLのタグ名を指定することで、既存の組み込み要素を拡張する形でのカスタム要素を登録することもできます。
 それでは、ページに対して「MyProfileクラスに定義されている」と伝えるためのコードを書いてみましょう(リスト2)。
    class MyProfile extends HTMLElement { // (1)
      constructor() {
        super();
      }
    }
    
    customElements.define("my-profile", MyProfile); // (2)
    
 (1)で要素の振る舞い(まだ空っぽですが…)を定義したクラスを、(2)のdefineメソッドでページに登録しています。
 リスト2のJavaScriptコードが実行されることで、ページ内で
    <!DOCTYPE html>
    <html lang="ja">
    <body>
      <div>
        <my-profile></my-profile>
      </div>
      
      <script src="./define.js"></script>
    </body>
    </html>
    
ブラウザで表示してもやはり何も表示されないので、一見するとリスト1とリスト3では何も変わっていないように感じられますが、リスト3ではしっかりと表示可能な要素として認知されてた状態になっている点で違っています。
カスタム要素でUIを表示するサンプルはシャドウDOMの領分になってしまうため、本項では割愛します。
ライフサイクルコールバック
 カスタム要素を作り込んでいくと、カスタム要素がメイン文書(window.document)のツリーに追加されたタイミングで初期化処理をしたくなったり、属性が変わったタイミングで色を割り当て直す処理をしたくなったりすることがあります。そういった処理を行うために、カスタム要素へ何らかの変更が加えられた際に呼び出されるコールバックが整備されています。カスタム要素の追加から削除までの一生涯における各種イベントを通知することから、ライフサイクルコールバックと呼ばれています。
カスタム要素のクラス内で利用できるライフサイクルコールバックを表1に示します。
| コールバック名 | 概要 | 
|---|---|
| connectedCallback | 文書にカスタム要素が接続されたときに呼び出される | 
| disconnectedCallback | カスタム要素が文書から切断されたときに呼び出される | 
| adoptedCallback | カスタム要素が別の文書に移動すると呼び出される | 
| attributeChangedCallback | カスタム要素の属性が追加・変更・削除されると呼び出される | 
 attributeChangedCallbackは少し動きが想像しづらいので、サンプルを見てみましょう。リスト4は、
    class MyProfile extends HTMLElement {
    
      static get observedAttributes() {
        return ['fullname', 'age']; // (5)
      }
    
      constructor() {
        super();
    
        // カスタム要素の中にシャドウDOMを接続する
        this.attachShadow({ mode: "open" });
    
        // HTMLファイルで指定された属性を取り出し、初期表示に使用する
        const fullname = this.getAttribute('fullname') || 'John Doe';
        const age = this.getAttribute('age') || 'unknown';
    
        // (1) このカスタム要素の文書構造を定義する
        this.shadowRoot.innerHTML = `
          <div>
            <h1>My Profile</h1>
            <div id="fullname">full name: <span class="value">${fullname}<span></div>
            <div id="age">age: <span class="value">${age}</span></div>
          </div>
        `;
      }
    
      connectedCallback() {
        console.log("connectedCallback", { isConnected: this.isConnected });
      }
    
      disconnectedCallback() {
        console.log("disconnectedCallback");
      }
    
      adoptedCallback() {
        console.log("adoptedCallback");
      }
    
      attributeChangedCallback(name, oldValue, newValue) { // (2)
        console.log('attributeChangedCallback', { name, oldValue, newValue })
    
        if (name === 'fullname') { // (3)
          this.shadowRoot.querySelector('#fullname .value').textContent = newValue;
        }
    
        if (name === 'age') { // (4)
          this.shadowRoot.querySelector('#age .value').textContent = newValue;
        }
      }
    }
    
    customElements.define("my-profile", MyProfile);
    
何かUIがあったほうがわかりやすいので、コンストラクタでシャドウDOMを割り当てつつ、(1)で名前と年齢を表示する文書構造を定義しました。
 属性に変更があると、(2)のattributeChangedCallbackが呼び出されます。第一引数(name)には属性の名前(今回はfullnameかage)が渡され、第二引数(oldValue)には更新前の値、第三引数(newValue)には更新後の値が渡されます。実用上は第一引数と第三引数を使うことが多くなりそうです。
処理内容を記述する場合は、(3)や(4)のように属性の名前で条件分岐をすることになります。今回は属性の変更内容に応じてUIの表示を更新したかったので、(1)で作った文書構造に基づいてシャドウDOMのツリー内を検索し、表示内容を更新しました。
 なお、属性に書いた内容ならなんでもattributeChangedCallbackが呼び出されるわけではありません。(5)でobservedAttributesというメソッドに定義した属性名の一覧にある属性だけが、attributeChangedCallbackの呼び出し対象になります。
それでは、HTMLから実際に呼び出して、属性を変更した際の挙動を見てみましょう(リスト5)。
    <!DOCTYPE html>
    <html lang="ja">
    <body>
      <div>
        
        <my-profile fullname="Taro Suzuki" age="1"></my-profile>
      </div>
      
      <script src="./lifecycle.js"></script>
      
      <script<
        let intervalId;
        intervalId = setInterval((() => { // (3)
          const profile = document.querySelector('my-profile'); // (4) カスタム要素を取得
          let age = Number(profile.getAttribute('age')); // 文字列を数値に変換
          age += 1;
          profile.setAttribute('age', age); // (5) 属性を更新する
    
          if (age >= 10) {
            // 10歳以上になったら、インターバルを終了する
            clearInterval(intervalId);
          }
        }), 1000);
      </script>
    </body>
    </html>
    
 何はともあれ、まずは(1)でリスト4のJavaScriptファイルを読み込み、この文書内で
 さて、このサンプルでは属性が変わっていくたびにコールバックが呼び出される様子を見せたいわけですが、妥当なUIが思い浮かばなかったので、(3)のsetIntervalで1秒に1つずつ年齢を加算していくという味気ない例をご紹介することにします。
 毎秒どんな処理を行うのか見ていきましょう。(3)では通常の要素と同じように、要素名を使ってquerySelectorでDOMツリー内のカスタム要素を参照しています。属性を更新する方法も通常の要素と同じなので、(5)で年齢の値を一つ大きくしてage属性にセットしています。
では、実際にこのHTMLファイルを表示してみましょう(図1)。
 
 1秒ごとにage:の数字が大きくなっていきますね。どうやら属性の変更によってカスタム要素の中で起きたattributeChangedCallbackの呼び出しを上手く扱えたようです。
このように、カスタム要素に関するAPIでは、カスタム要素の内側と外側の世界を繋ぐ役割をおもに担っています。

 
              
               
                          
                           
                          
                           
                          
                           
                          
                           
                          
                           
                          
                           
                          
                           
                          
                           
                          
                           
                          
                           
                          
                           
                              
                               
                              
                               
                              
                               
                              
                               
                              
                               
                      
                     
                      
                     
                      
                     
                      
                     
                      
                     
                      
                     
                      
                     
															
														 
															
														.png) 
     
     
     
     
     
													 
													 
													 
													 
													 
										
									
 
                     
                    