HTML5Experts.jp

Web Componentsを構成する4つの仕様 ー Web Components基礎編

我々Web開発者がWeb Componentsという言葉を耳にしてから、もう2年程経ったでしょうか。Web Componentsが変えるWeb開発の未来という記事に、「今のWeb開発がどのような課題を抱えているか、それをWeb Componentsがどう解決するか」を書きました。これを踏まえて、本連載ではWeb Componentsの仕様から実装、PolymerやX-TagといったWeb Componentsを支えるライブラリなどの周辺知識まで解説していきます。

Web Componentsを支える4つの仕様

連載第1回目となる本記事では、Web Componentsを支える4つの仕様について解説します。Web Componentsは以下の4つの独立した仕様から構成されます。

つまり、Web Componentsはそれがひとつの仕様というわけではなく、これらの各機能を組み合わせてHTMLをコンポーネント化する技術のことを指します。よって、各仕様は独立した機能なので、ブラウザが実装していれば単独で利用することも可能です。今回はこれらの仕様について、順に解説していきます。また、記事中で実際のコードを取り扱っていますが、これらを実際に試す場合は、各機能の実装が進んでいるGoogle Chromeで行うことをオススメします。

Custom Elements

Custom Elementsは、ブラウザに新たな要素を定義する仕様です。CSSでのスタイリングやJavaScriptで付与するインタラクションといった特徴をまとめたカスタム要素として登録し、新たなネイティブ要素として利用可能にします。

カスタム要素を定義する

カスタム要素を新しく定義するにはdocument.registerElementというDOMのAPIを使います。document.registerElementの第1引数に文字列でカスタム要素名を指定し、第2引数の指定でカスタム要素の挙動が決定されます。ネイティブで定義されているタグとの区別のために、カスタム要素名にはハイフンを含める必要があります。

// <sample-element>を定義する
document.registerElement('sample-element');

これでsample-elementという、カスタム要素を使うことができるようになりました。

カスタム要素の挙動を設定する

先程定義したsample-elementは、何の機能も持たない要素です。このsample-elementの挙動をカスタマイズするには、 document.registerElementの第2引数のprototype属性に指定します。

// <sample-element>のプロトタイプ
var SampleElementPrototype = Object.create(HTMLElement.prototype);

// <sample-element>を定義する document.registerElement('sample-element', { prototype: SampleElementPrototype });

このように、HTMLElementを継承したSampleElementPrototypeオブジェクトを生成し、prototype属性に指定します。 HTMLElementを継承することで、HTMLとして基本的な振る舞いをするようになります。挙動を更に細かく制御するために、 SampleElementPrototypeには、ライフサイクルコールバックを指定することが可能です。ライフサイクルコールバックには以下の4つがあります。

createdCallback 要素が生成されたときに実行されるコールバック関数
attachedCallback 要素がHTMLに追加されたときに実行されるコールバック関数
detachedCallback HTMLから要素が除かれたときに実行されるコールバック関数
attributeChangedCallback 属性変更時に実行されるコールバック関数

これらのライフサイクルコールバックを必要に応じて利用します。

カスタム要素を利用する

document.registerElement('sample-element')を実行したあとは、定義したカスタム要素を実際に利用することが可能です。HTML上にsample-elementタグを書くことでも利用可能ですし、以下のようにJavaScriptから生成することもできます。

// <sample-element>を生成する
var sampleElement = document.createElement('sample-element');

// 生成した<sample-element>をbodyに追加する document.body.appendChild(sampleElement);

また、document.registerElement()は定義したカスタム要素のコンストラクタ関数を返すので、それをnewと共に実行することでも生成可能です。

// document.registerElementの返り値を変数に保持する
var SampleElement = document.registerElement('sample-element', {
  prototype: SampleElementPrototype
});

// <sample-element>を生成する var sampleElement = new SampleElement();

// 生成した<sample-element>をbodyに追加する document.body.appendChild(sampleElement);

既存の要素を拡張する

カスタム要素の作成にはprototypeを利用して挙動をゼロから指定するほか、extendsに拡張したい要素名を指定し、その要素の拡張機能を作成するという方法があります。

// <button>を拡張するextended-buttonを定義する。
document.registerElement('extended-button', {
  prototype: ExtendedButtonPrototype,
  extends: 'button'
});

extendsを使って作成された要素を利用する場合は、is='extended-button'のようにextendsで指定した要素のis属性に指定します。

<button is='extended-button'>This is button</button>

extendsを使って作成された要素は、元々の要素の見た目や内部の特徴を持ちつつも、is='〜'で指定されたカスタム要素の特徴を持ちます。この場合、button要素にextended-buttonで定義した処理が付与されることになります。

また、extends属性にはカスタム要素を指定することはできません。prototypeに指定するオブジェクトに継承させましょう。

// <sample-element>のプロトタイプ
var SampleElementPrototype = Object.create(HTMLElement.prototype);

// <sample-element>を定義する document.registerElement('sample-element', { prototype: SampleElementPrototype });

// これはNG。カスタム要素をextendsすることはできない // 継承したい場合はSampleElementPrototypeを利用する document.registerElement('extended-again-element', { prototype: Object.create(HTMLElement.prototype), extends: 'sample-element' });

Shadow DOM

Shadow DOMはHTMLの世界にスコープの概念をもたらします。HTMLとCSS、そしてJavaScriptを組み合わせて作ったUIコンポーネントの再利用を考えた時に必ず障壁となるのがスコープがないという問題でした。(正確に言えば、iframeだけはスコープを形成しますが、セキュリティが強く柔軟性に欠け、コンポーネント化という目的は果たせません)

例えばボタンのコンポーネントを作るために.buttonというクラスを定義しても、このCSSを別の場所で利用しようとした際に同名のクラスが定義されていると、どちらか一方が上書きされてしまいます。こうした問題に対しては命名規則の工夫等、様々なアプローチがされてきましたが、いずれもカスケーディングを完全に回避できる保証はありません。

これを根本的に解決してくれるのがShadow DOMです。

Shadow DOMの仕組み

Shadow DOMによって、要素は新たにShadow Rootという新たなノードを持つことができるようになります。このShadow Rootを持つ要素はShadow Hostと呼ばれ、スコープの起点となります。Shadow Rootにぶら下がるDOMツリーには外部からアクセスすることができず、内部の処理が外部に漏れることもありません。

つまり、 カスタム要素の振る舞いをShadow DOMに閉じ込めることで、既存のスタイルやJavaScriptに影響されずに扱う ことができます。

実は、Chromeでは既にネイティブのHTMLの要素に、Shadow DOMが使われています。代表的なのがvideo要素です。video要素のShadow DOMを確認するには、DevToolsのSettingsの「Show user agent shadow DOM」をチェックする必要があります。

DevToolsでvideo要素を見てみると、videoの下に #shadow-root があり、その下にdiv要素やinput要素がぶら下がっているのが確認できます。divinputにフォーカスしてみると、それらが再生ボタン等のUIコントロール部分を構成しているのがわかると思います。このShadow Hostはvideo要素ということになります。

video要素の他にも、textareainput等でネイティブで使われているShadow DOMを確認することが可能です。

Shadow Rootの生成

では実際にShadow DOMを利用していきます。Shadow Rootを生成するにはcreateShadowRoot()というDOMのAPIを使います。 createShadowRootはHTMLElementをインターフェースとするどの要素からも実行することが可能です。ここでは先程のsample-element要素内で使います。

// <sample-element>のプロトタイプ
var SampleElementPrototype = Object.create(HTMLElement.prototype);

SampleElementPrototype.createdCallback = function () {

// <sample-element>にShadow Rootを生成する var shadowRoot = this.createShadowRoot();

// <style>を生成する var style = document.createElement('style'); var styleString = ''; styleString += 'button {background: #000; color: #fff; font-size: 24px;}'; styleString += 'input {font-size: 24px; background: #cfc;}'; style.innerHTML = styleString;

// <input type='button'>と<button>を生成する var input = document.createElement('input'); input.setAttribute('type', 'text'); var button = document.createElement('button'); button.textContent = 'This is button.';

// 生成した<style>と<input type='button'>と<button>をShadow Rootに追加する shadowRoot.appendChild(style); shadowRoot.appendChild(input); shadowRoot.appendChild(button); };

// <sample-element>を定義する document.registerElement('sample-element', { prototype: SampleElementPrototype });

作成したShadow Rootに対し、styleinputbuttonの各要素を追加しました。Shadow Rootに要素が追加されるとShadow Hostの要素は表示されなくなり、代わりにShadow Rootの内容が表示されるようになります。

こちらは実際に動くサンプルです。

style要素内にはbutton要素を装飾するCSSを記述していますが、Shadow Root配下にあるので外部に漏れることはありません。また、グローバルな領域にbuttonの装飾をするCSSがありますが、sample-element内のbuttonに対しては影響していないことが確認できます。

このように、Shadow DOMはHTMLにスコープを提供します。Web Componentsに関連する機能の中でも、最も重要と言える機能かもしれません。

Templates

TemplatesはHTMLをひな形として扱うための仕様です。今までHTMLをひな形として扱いたい場合にはscript要素を使った方法等がありましたが、これらはあくまでハック的なアイデアに過ぎませんでした。Templatesではtemplateというタグで括ることで、ブラウザはその中のHTMLを不活性なHTML要素として認識します。不活性な要素は描画されることはなく、document.querySelector()等でアクセスすることもできません。

HTMLの生成はもちろんDOMのAPIでも可能ですが、HTML生成がJavaScript内で行われていると構造がわかりにくいですし、メンテナンスの観点からもHTML上にテンプレートが配置されているほうが望ましいです。

template要素を利用する

先程の、Shadow Rootに追加しているHTMLをtemplateを使って書きなおしていきます。Shadow Rootに追加しているHTMLをそのままtemplate内に記述するだけです。このtemplate要素にはIDを付与しておきます。

<template id='sample-element-template'>
  <style>
    button {
      background: #000;
      color: #fff;
      font-size: 24px;
    }
    input {
      font-size: 24px;
      background: #cfc;
    }
  </style>
  <input type='text'>
  <button>Button</button>
</template>

先程行っていた、 DOMのAPIでHTMLを生成しShadow Rootに追加する という処理を、 テンプレートをコピーしてShadow Rootに追加する という処理に置き換えます。

// <sample-element>のプロトタイプ
var SampleElementPrototype = Object.create(HTMLElement.prototype);

SampleElementPrototype.createdCallback = function () {

// <sample-element>にShadow Rootを生成する var shadowRoot = this.createShadowRoot();

// <template>を取得する var template = document.querySelector('#sample-element-template');

// <template>の中の要素をコピーする var clone = document.importNode(template.content, true);

// 生成した<style>と<input type='button'>と<button>をShadow Rootに追加する shadowRoot.appendChild(clone); };

// <sample-element>を定義する document.registerElement('sample-element', { prototype: SampleElementPrototype });

追加したい要素の構造がHTML側に整理されたことで、ぐっと見通しが良くなりました。

HTML Imports

Custom Elements、Shadow DOM、Templatesを使ってsample-elementを作成してきました。ここまでの処理をHTMLファイルにまとめて、そのHTMLをロードすることでsample-elementが利用可能になります。

JavaScriptファイルや画像といったサブリソースをロードするにはscriptimg要素を使った方法がありましたが、HTMLに関してはネイティブは存在していませんでした。XMLHttpRequestを使ってロードする方法もありますが、HTMLのロードに必ずJavaScriptを利用するのもやや大袈裟と言えます。この最も基本的とも言える 外部のHTMLをロードする という機能を実現するのがHTML Importsです。

HTML Importsを利用する

外部のHTMLファイルをロードするには、以下のようにlink要素を使ってロードします。

<link rel='import' href='sample-element.html'>

断片化されたHTMLファイルはこのようにlink要素をつかってロードすることが可能で、読み込まれたファイルは読み込み先のHTMLに引き継がれます。ここでは、Custom Elements、Shadow DOM、Templatesを使って構築してきたsample-elementを外部ファイル化します。

<template id='sample-element-template'>
  <style>
    button {
      background: #000;
      color: #fff;
      font-size: 24px;
    }
    input {
      font-size: 24px;
      background: #cfc;
    }
  </style>
  <input type='text'>
  <button>Button</button>
</template>

<script> // <sample-element>のプロトタイプ var SampleElementPrototype = Object.create(HTMLElement.prototype);

SampleElementPrototype.createdCallback = function () {

// &lt;sample-element&gt;にShadow Rootを生成する
var shadowRoot = this.createShadowRoot();

// &lt;template&gt;を取得する
var template = document.querySelector('#sample-element-template');

// &lt;template&gt;の中の要素をコピーする
var clone = document.importNode(template.content, true);

// 生成した&lt;style&gt;と&lt;input type='button'&gt;と&lt;button&gt;をShadow Rootに追加する
shadowRoot.appendChild(clone);

};

// <sample-element>を定義する document.registerElement('sample-element', { prototype: SampleElementPrototype }); </script>

テンプレートとなるHTMLから、実際にsample-elementを定義するスクリプト処理までをまとめてsample-element.htmlとしました。 このように単一のHTMLファイルに集約することでコンポーネントの責任の在処も明確にすることが可能です。 外部ファイル化したsample-element.htmlをロードする最も単純な例は以下のようになるでしょう。以下をindex.htmlとします。

<html>
  <head>
    <link rel="import" href="sample-element.html">
  </head>
  <body>
    <sample-element></sample-element>
  </body>
</html>

しかし、このsample-element.htmlをいざインポートしようとすると、エラーが発生します。具体的にはdocument.querySelector('#sample-element-template');で要素が存在しないためにnullを返すためです。

インポート時のdocumentの扱いに注意する

HTMLの評価はindex.html側で行われるため、querySelectorの実行者であるdocumentはロード先のindex.htmlになります。そうするとsample-element.htmlに配置してある#sample-element-templateindex.htmlにないので、要素が見つからないという結果になってしまいます。

そのため、querySelectorの実行者をsample-element.htmldocumentにする必要がありますが、これにはdocument.currentScriptという属性を利用します。document.currentScriptでは実行中のスクリプトノードを返します。ノードからはownerDocumentを使って親となるドキュメントを参照することができるので、これらを組み合わせてquerySelectorの実行者がsample-element.htmlのドキュメントになるようにします。

<script>
  // 実行中のスクリプトを参照する
  var currentScript = document.currentScript;

// <sample-element>のプロトタイプ var SampleElementPrototype = Object.create(HTMLElement.prototype);

SampleElementPrototype.createdCallback = function () {

// &lt;sample-element&gt;にShadow Rootを生成する
var shadowRoot = this.createShadowRoot();

// &lt;template&gt;を取得する
var template = currentScript.ownerDocument.querySelector('#sample-element-template');

// &lt;template&gt;の中の要素をコピーする
var clone = document.importNode(template.content, true);

// 生成した&lt;style&gt;と&lt;input type='button'&gt;と&lt;button&gt;をShadow Rootに追加する
shadowRoot.appendChild(clone);

};

// <sample-element>を定義する document.registerElement('sample-element', { prototype: SampleElementPrototype }); </script>

querySelectorの実行者がsample-element.htmldocumentになったことで正常に動くようになります。その直後のdocument.importNodedocument.registerElementはそのままにしてあることにも注目してください。ノードのコピーや、カスタム要素の登録はインポート先のドキュメントで行うのが適切と言えるでしょう。これで晴れてsample-element.htmlのインポートが正常にできるようになりました。

まとめ

Custom Elements、Shadow DOM、Templates、HTML Importsの4つの仕様の基本的な使い方について解説しました。簡単におさらいすると、以下のようになります。

  1. Custom Elementsでカスタム要素を新たに定義し、基本的な挙動を指定する。
  2. カスタム要素に指定するCSSやJavaScriptの効力はShadow DOMに閉じ込める。
  3. テンプレートとして扱うHTMLをtemplateタグに宣言する。
  4. カスタム要素を定義する一連の処理を記述したHTMLを、HTML Importsで読み込む。

再利用可能なコンポーネント化を実現するために、それぞれがどういった役割を果たしているかを理解することはもちろん重要ですが、それらはあくまで独立した仕様であり、単一の機能として利用できることも認識しておきましょう。

次回は、より実践的なコンポーネント作成を解説する予定です。