アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

Web Componentsを試してみた

morishitaです。

プログラムを部品化して再利用したい。

コードを書く人間にとってはいつも考えていることですし、永遠のテーマなのではないかと思います。

オブジェクト指向はクラスとしてパッケージしたコードを再利用する仕組みを提供します。関数型言語は関数で。
言語レベルでなくても、コードを分割し再利用する仕組みはフレームワークやビルドシステムなどいろんな形で存在します。

Web Components もそんな再利用の仕組みの1つです。 ちょっと前に、「すでにモバイルではポリフィルなしでも使える状況になってきている」と聞きかじって気になっていました。

で、ちょっと試して見ました。

なお、このエントリではPolymerなどの polyfill は使いません。

Web Componentsとは?

Web ComponentsはざっくりいうとオリジナルのHTMLタグを作るための仕組みです。JSP(JavaServer Pages)や ASP(Active Server Pages)のカスタムタグを思い出すと「あーあれかー」って感じかと思います1

JSPやASPはサーバサイドの技術でしたが、Web Componentsはクライアントサイドの技術で次の要素を組み合わせて実現する仕組みです。

ざっくりいうとカプセル化された(他のコードを汚染しない)環境で実行されるJavaScriptとHTMLの断片で独自のHTMLタグが実装できる技術ということです2

上記の技術要素をブラウザがサポートしていればWeb Components が使えるってことになります。
それぞれの要素のCan I use... のリンクを付けておきましたが、それによると一部の機能制限はあるもののモバイルだけでなくPCでも最新版のChrome、Safari、Firefox、そしてEdgeでは動く様です。

ネックになるのは4.4.4以前のAndroidとIEですかね。

シンプルな例

シンプルな例としてブラウザのユーザエージェントを表示する<user-agent>タグを作ってみました。

今回作成したものは次のファイル群で構成されます。

/
├── components
│   └── user-agent.js
├── index.html
└── main.js

順に上記のファイルについて説明します。

index.html

まずは<user-agent>タグを利用するページであるindex.html

内容は次のとおりです。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <script type="module" src="main.js"></script>
  <title>Web Components - Simplest Sample</title>
</head>
<body>
  <h1>Web Components</h1>
  <h2>User Agent Tag</h2>
  <user-agent></user-agent>
</body>
</html>

ここで、main.js<script>で読み込んでいます。 type="module"をつけなくてもコンポーネントは動作しますが、main.jsので定義した変数などがページのコンテキストに漏れるのでつけておいたほうがいいです。

<user-agent></user-agent>がカスタム要素を使用しているところです。

main.js

index.htmlで読み込んでいるmain.jsでは、カスタム要素<user-agent>を使えるように定義しています。

import UserAgentElement from './components/user-agent.js';

customElements.define('user-agent', UserAgentElement);

UserAgentElementクラスをcomponents/user-agent.jsからインポートしています。
importはこのファイルを読み込む<script>タグにtype="module"を指定することにより使えるようになります。

そして、customElements.define()でカスタム要素名user-agentとそれを実装するUserAgentElementクラスを指定してカスタム要素を定義しています。
カスタム要素名にはダッシュ(-)を含める必要があるので注意です。

components/user-agent.js

最後に、カスタム要素の実体components/user-agent.jsです。

export default class UserAgentElement extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });
    const ua = navigator.userAgent;
    shadowRoot.innerHTML = ua;
  }
}

ポイントは次のとおりです。

  • カスタム要素の実体であるUserAgentElementクラスをHTMLElementを継承して実装する
  • クラスのコンストラクタでは必ずsuper()を実行する
  • 表示要素をShaddow DOMで実装する
    • 上記の例ではthis.attachShadow()shadowRootを作ってそれをRootとしてDOMを構築しています。

表示結果

表示の結果は次の様になります。

f:id:HeRo:20190624232810p:plain
<user-agent>

<user-agent>で、ブラウザのユーザエージェントが表示されています。 Dev Toolで確認すると、Shadow DOMが使われているのがわかります。

次のブラウザで試してみました。いずれも動作します。

  • Chrome(75, macOS, Android)
  • Safari(12.1.1, macOS,iOS)
  • Firefox(67, macOS,Android)

上記のページには次のリンクからアクセスできます。
Web Components - Simplest Sample

HTML Template も使ってみる

上記の例では、Web Components の技術要素のうち、HTML Templateは使っていません。

HTML Templateを使ったカスタム要素を作ってみます。

<popup-elememt>タグ

ボタンをクリックするとポップアップダイアログを表示するカスタム要素<popup-elememt>を作ってみました。

動作の様子は次のとおりです。

f:id:HeRo:20190624233114g:plain
<popup-element>

こちらで試せます => Popup Sample

HTMLのコードは次のとおりです。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <script type="module" src="main.js"></script>
  <title>Web Components - Popup</title>
</head>
<body>
  <h1>Web Components</h1>
  <h2>Popup Tag</h2>
  <template id="popup-tmpl">
    <style>
      #popup {
        display: none;
        position: fixed;
        background-color: rgba(0,0,0,0.3);
        width: 100vw;
        height: 100vh;
        top: 0;
        left: 0;
      }
      .dialog {
        background-color: white;
        width: 80vw;
        margin: 100px auto 0 auto;
        padding: 10px;
        border-radius: 5px;
      }
      .title { font-weight: bold; }
    </style>
    <div id="popup">
      <div class="dialog">
        <div class="title">
          <slot name="title"></slot>
        </div>
        <div class="content">
          <slot name="content"></slot>
        </div>
      </div>
    </div>
  </template>

  <div>
    <p class="title">ポップアップ - 1</p>
    <popup-elememt>
      <span slot="title">タイトル - 1</span>
      <span slot="content">
        本文1 本文1 本文1 本文1 本文1 本文1 本文1 本文1 本文1 本文1 本文1 本文1
      </span>
    </popup-elememt>
  </div>

  <div>
    <p class="title">ポップアップ - 2</p>
    <popup-elememt>
      <span slot="title">タイトル - 2</span>
      <span slot="content">
        本文2 本文2 本文2 本文2 本文2 本文2 本文2 本文2 本文2 本文2 本文2 本文2
      </span>
    </popup-elememt>
  </div>
</body>
</html>

<template>タグでポップアップダイアログのマークアップを実装しています。
<template>タグで囲われた要素はレンダリングされません。 <style>要素も処理されず、あとでShadow DOM内でレンダリングするときに有効となります。Shadow DOM内でのレンダリングはShadow DOM内に閉じます。したがって<template>タグの外には影響しません。

<script type="module" src="main.js"></script>で読み込んでいるmain.jsは次のとおりです。 前述の<user-agent>と同じくカスタム要素を定義しているだけです。

import PopupElement from './components/popup.js';

customElements.define('popup-elememt', PopupElement);

カスタム要素<popup>を実装しているcomponents/popup.jsは次のとおりです。

export default class PopupElement extends HTMLElement {
  constructor() {
    super();
    // テンプレートの読み込み
    const template = document.querySelector('template#popup-tmpl').content.cloneNode(true)

    // #popup を取り出してクリックしたら閉じる様にイベントを追加
    const popup = template.querySelector('#popup');
    popup.addEventListener('click', function() {
      popup.style.display = 'none';
    })

    // ダイアログを開くボタンはDOMで作る
    const button = document.createElement('button')
    button.innerHTML = 'OPEN'
    button.addEventListener('click', function() {
      popup.style.display = 'block';
    });

    // テンプレートとボタンをShadow DOMに追加する
    const shadowRoot = this.attachShadow({ mode: 'open' });
    shadowRoot.appendChild(template);
    shadowRoot.appendChild(button);
  }
}

カスタム要素のマークアップが多くなると、JS内でDOMのAPIを使って要素を構築するのは大変です。
なので通常のHTMLとして実装し再利用できる<template>タグは便利なのですが、何でしょう? このコレジャナイ感。
カスタム要素の中身のカプセル化がどこかへ行ってしまっています。

だいたい、導入が面倒です。複数のページで利用しようとするとそれぞれでテンプレート部分はコピー&ペーストになります。
必要なスクリプトを読み込むだけで使えるっていうのが理想的です。

テンプレートリテラルでpopupタグを改善してみる

HTML内で実装している<popup>のマークアップをなんとかしましょう。
ES moduleで使えるテンプレートリテラルを利用してマークアップを定義するとJS内で完結できそうです。

components/popup.jstemplateメソッドを追加して、先程の<template>タグの中身相当の文字列を返すようにします。

export default class PopupElement extends HTMLElement {
  constructor() {
    super();

    // テンプレートの読み込み
    const template = document.createElement('div')
    template.innerHTML = this.template(); // innerHTMLを使って一気にDOMオブジェクト化

    // #popup を取り出してクリックしたら閉じる様にイベントを追加
    const popup = template.querySelector('#popup');

    popup.addEventListener('click', function() {
      popup.style.display = 'none';
    })

    // ダイアログを開くボタンはDOMで作る
    const button = document.createElement('button')
    button.innerHTML = 'OPEN'
    button.addEventListener('click', function() {
      popup.style.display = 'block';
    });

    // テンプレートとボタンをShadow DOMに追加する
    const shadowRoot = this.attachShadow({ mode: 'open' });
    shadowRoot.appendChild(template);
    shadowRoot.appendChild(button);
  }

  // ポップアップのマークアップを返す
  template() {
    return `
      <style>
        #popup {
          display: none;
          position: fixed;
          background-color: rgba(0,0,0,0.3);
          width: 100vw;
          height: 100vh;
          top: 0;
          left: 0;
        }
        .dialog {
          background-color: white;
          width: 80vw;
          margin: 100px auto 0 auto;
          padding: 10px;
          border-radius: 5px;
        }
        .title { font-weight: bold; }
      </style>
      <div id="popup">
        <div class="dialog">
          <div class="title">
            <slot name="title"></slot>
          </div>
          <div class="content">
            <slot name="content"></slot>
          </div>
        </div>
      </div>
    `
  }
}

動作はこちらから試せます => Popup sample その2

これでHTMLから<template>を削除でき、<popup-elememt>タグをJSのコード内にカプセル化できます。
HTMLからテンプレートは排除できました...。
が、これはこれでコードが見にくいしエディタのコード補完等の支援を受けられないし…。イマイチ感は残ります。

うーん。

それと、この<popup-element>ではSlotを使ってみましたが、カスタム要素が適用される前に一瞬表示されてしまいます。見苦しいです。 ユーザのアクションがあるまで非表示にしておくようなカスタム要素にSlotを使うのは良くないみたいです3

やってみた感想

以前は動作するブラウザが一部に限られていたのでPolymerの導入とかの話題が多い印象でめんどくさそうだと思っていました。
しかしVanillaなJavaScriptだけで動く様になってきて、思ったより簡単に実装できるのだなとやってみて思いました。
しかもモバイルだけでなくデスクトップのブラウザでもだいたい動くようですし、Polymarを使えばもっと多くのブラウザで動作するコンポーネントライブラリを作ることもできるでしょう。
うまく実装されたWeb ComponentsならJSを読み込めばそれだけで高機能なカスタム要素が利用可能になるのは手軽でいいと思いました。

今回はやりませんでしたが既存のHTMLタグを拡張することも可能です。 Bootstrapのような立ち位置で既存タグの拡張を含む使いやすいコンポーネントライブラリが現れれば一気に利用が広がりそうな可能性は感じました。

一方で、今回試した範囲ではマークアップ部分の実装方法についてはもうちょっといい方法がないものかと感じました。
テンプレートリテラルを使う方法も試しましたが、やはりメンテナンス性が悪そうに思います。
だいたいHTMLテンプレートはデータバインディングの機能は持たないので、何かのデータ取得しそれを表示するカスタム要素を作る場合にはDOMで書き換 動的なリストを表示するようなカスタム要素を作ろうとする場合にはループを回しながら要素を追加しなければなりません。
自前でやるのはしんどいので、mustache.jsなどを使うことになるのかなぁと思います。
結局、何らかのライブラリを使うならVueやReactで実装したいなと思ってしまいました。

最後に

アクトインディではエンジニアを募集しています。


  1. いろいろ違いはあるかと思いますが、目指すところは同じだと思いますし、私は既視感を覚えました。ちょっと古くて伝わらない?

  2. ちょっと乱暴に要約しすぎている?
    より詳しい説明はwebcomponents.orgのSpecificationsを御覧ください。

  3. カスタム要素の属性として値を渡せば一瞬表示されるのは抑えられますが、あまり長い内容をタグの属性として渡すのはコードが見にくくなりますね。Popup sample その3