no-image

DartでWeb Componentsことはじめ

Web Componentsとは

Web Componentsというのは、その名の通りHTMLのコード片をコンポーネント化するための規格だ。もともとHTMLはリッチアプリケーションを作るためのプラットフォームではなかったので、複雑な構造を管理する機能に欠けていた。そこで、Googleを中心にHTMLでコンポーネントを扱う技術の標準化が進められることになった。

Web Componentsの構成要素

Web Componentsは以下の4つの仕様から成り立っている。

  • HTML Templates
  • HTML Imports
  • Shadow DOM
  • Custom Elements

それぞれ独立した仕様なので、開発規模などに応じて、必要なものを選択しよう。

対応状況

現状で対応しているのはBlinkエンジン(Chrome, Opera)のみにとどまる。Firefoxも一部機能に対応しているが、フラグをオンにする必要がある。

DartとWeb Components

DartはGoogleが開発しているだけあって、Web Componentsへの対応も十分だ。Dartiumも元はChromiumなので、当然全ての機能に対応している。未対応ブラウザでも動作させたい場合は、Polymer.dartなどのpolyfillを利用しよう。

HTML Templates

HTML Templatesは極めて単純なアイデアだ。<templates>タグで囲まれた要素は、そのままでは表示されず、リソースが読み込まれたりスクリプトが実行されたりすることもない。<template>要素の中にテンプレートとしたい要素群を記述しておけば、スクリプト側からコピーして利用することができる。

<template>要素無しでも似たようなことは実現できるが、<template>要素を使用した方が、意味が明確になるだろう。

Dart側からは普通のHTML要素と同様に処理すればいいが、<template>要素の中身はDocumentFragmentになっており、TemplateElementクラスのcontentプロパティから取得する必要があることには注意しよう。

サンプル

html-templates-sample

HTML Templatesを利用したサンプル

<!DOCTYPE html>
<html>
<head lang="ja">
    <meta charset="UTF-8">
    <title>HTML Templates</title>
    <script async type="application/dart" src="templates.dart"></script>
</head>
<body>
  <!--
    -- <template>要素の中身は一切実行されない。
    -- スクリプト側から取り出して利用する。
    -->
  <template id="profile-card-template">
      <div class="profile-card">
          <!--
            -- scoped styleは、その親要素内でしか適用されないスタイル。
            -- まだサポートが不十分で、現在対応しているのはFirefoxのみ。
            -->
          <style scoped>
              .profile-card {
                  background-color: rgb(250, 250, 250);
                  box-shadow: 1px 1px 1px 0;
                  margin:  1em;
                  padding: 0.5em;
                  width:   15em;
              }
          </style>
          <div class="name">John Doe</div>
          <div class="phone-number">00-0000-0000</div>
      </div>
  </template>
</body>
</html>
import 'dart:html';

void main() {
  // <template>内部のDocumentFragmentを取得する。
  var template = (querySelector("#profile-card-template") as TemplateElement).content;

  // cloneして使う。
  var card1 = template.clone(true);
  card1.querySelector(".name").text = "ティム・クック";
  card1.querySelector(".phone-number").text = "111-111-1111";

  var card2 = template.clone(true);
  card2.querySelector(".name").text = "サティア・ナデラ";
  card2.querySelector(".phone-number").text = "222-222-2222";

  document.body.append(card1);
  document.body.append(card2);
}

 HTML Imports

<template>要素のような再利用性の高いコードを、利用する側のHTMLファイルに直接埋め込むのは、あまり賢い方法ではない。外部ファイルで定義しておいて、利用するHTMLからそのファイルを読み込むのが一般的な手法になるだろう。HTML Importsはそういった外部ファイルの読み込みをサポートする。

HTML Importsを利用するには、<head>要素内に一行書き加えるだけだ。

<link rel="import" href="external-file.html">

インポートされたドキュメントを取得するには、LinkElementのimportプロパティを利用する。

Document importedDocument = (querySelector("link[rel='import']") as LinkElement).import;

Shadow DOM

Shadow DOMは一般のDOMから隔離されたDOMを提供する。任意の要素の下に、Shadow DOMと呼ばれる裏のDOMを作成することができる。Shadow DOMが作成された要素のことを「ホスト」と呼ぶ。

Shadow DOMが作成されると、ホストの内容はレンダリングされず、代わりにShadow DOMの内容がレンダリングされるようになる。このとき表面上はあくまでホストのDOMのままなので、見かけだけをシンプルに保ったまま、複雑な構造を実現することが可能になる。

Shadow DOMを利用した場合のDOM構造

Shadow DOMを利用したときのDOM構造

また、ホストのスタイルやスクリプトがShadow DOMに影響を与えることはない。もちろんShadow DOMのスタイルやスクリプトがホストに影響を与えることもない。もしShadow DOMを超えてアクセスしたいときは、明示的な宣言が必要になる。

従来のように<div>地獄でコンポーネントを構築すると膨大な内部構造が露出してしまうが、Shadow DOMを使えば、それらを完全に内部に閉じ込めることが可能になる。

Shadow DOMの身近な例としてはChromeの<video>要素などがある。表示上はただの<video>タグだが、コントロールなどの部品はShadow DOMで作られている。

Chromeのタグの内部構造

Chromeの<video>要素の内部構造

Shadow DOMの作成

Shadow DOMを作成したい要素のcreateShadowRoot()メソッドを実行することで、Shadow DOMのRootを取得することができる。あとは通常のDOMと同様に操作できる。

var host = querySelector("#host");
var shadowRoot = host.createShadowRoot();
shadowRoot.append(new DivElement());

Shadow DOMからホストの内容を取得する(Insertion Point)

通常は、Shadow DOMが作成されるとホストの内容は表示されなくなる。しかし、ホストとShadow DOMが完全に分離してしまうのは不便だ。そこでShadow DOMにホストの内容を挿入する、挿入ポイント(Insertion Point)という仕組みが用意されている。

挿入ポイントはHTML上で<content>要素で指定する。<content>要素はShadow DOMに追加された場合のみ機能する。

例えば以下のようなホストとShadow DOMがあるとする。

<div id="host">Host Content</div>
<div>Shadow Content</div>

このときのブラウザ上での表示は、以下のようにShadow DOMのみの表示となる。

Shadow DOMを追加したときの表示

Shadow DOMを追加したときの表示

ここでShadow DOMに変更を加えて、挿入ポイントを追加してみよう。

<div>Shadow Content <content></content></div>

すると以下のように、挿入ポイントにホストの内容が挿入される。

挿入ポイントを指定したときの表示

挿入ポイントを指定したときの表示

挿入要素の選択

<content>要素のselect属性にセレクタを指定することで、任意の要素を挿入することができる。ただし、挿入ポイントで選択できるのはホスト直下の要素に限る。つまり、<content select=”section h1″></content>のような使い方はできない。

例えば以下のようなホストとShadow DOMを考える。

<div id="host">
  <div class="title">Host Title</div>
  <div class="text">Host Text</div>
</div>
<div><content select=".title"></select></div>

このとき表示されるのは.titleのみになる。

select=".title"を指定した場合の表示

select=”.title”を指定したときの表示

外部からShadow DOMへのアクセス

表のDOMからShadow DOM内へアクセスしたい場合、そのためのセレクタも用意されている。ここではCSSで例を示すが、スクリプトでもこれらのセレクタを使用することで同様にアクセスすることが可能だ。

::shadow疑似要素

::shadow疑似要素はホストのShadow DOMにマッチする。

#host::shadow .inner-element {
  font-size: 2em;
}

入れ子になったShadow DOMへアクセスする場合は、そのたびに::shadow疑似要素を記述する。

#host::shadow #inner-host::shadow .inner-element {
  font-size: 2em;
}

 /deep/コンビネータ

/deep/コンビネータを使えばShadow DOMの階層を気にせずアクセスすることができる。

#host /deep/ #inner-host .inner-element {
  font-size: 2em;
}

Shadow DOMから外部へのアクセス

Shadow DOMから外部へアクセスする手段も用意されている。

:host疑似クラス

:host疑似クラスは、Shadow DOMのホストにマッチする。

:host div {
  color: red;
}

また、:host(selector)を使用することで、セレクタにマッチするホストのみに限定することができる。

:host(.foo) div {
  color: red;
}

:host-context()疑似クラス

:host-context()疑似クラスは、ホストだけではなく、その子要素にもマッチする。

:host-context(.foo) {
  color: red;
}

::content疑似要素

::content疑似要素は<content>要素を通した要素にマッチする。

::content h1 {
  border-bottom: 1px solid rgb(0,0,0);
}

サンプル

Shadow DOMを利用したサンプル

Shadow DOMを利用したサンプル

<!DOCTYPE html>
<html>
<head lang="ja">
  <meta charset="UTF-8">
  <title></title>
  <script async type="application/dart" src="shadow_dom.dart"></script>
  <style> html { background-color: rgb(220,220,220); } </style>
</head>
<body>
  <!--
    -- Shadow DOMとして使用するDOMのテンプレート。
    -- スクリプトだけで作成してもかまわないが、
    -- 複雑な場合は<template>で定義しておくのが楽だろう。
    -->
  <template id="card-template">
    <div class="card">
      <!-- <content>要素の中にアクセスするには::content疑似要素が必要。 -->
      <style scoped>
        .card {
          background-color: rgb(254, 254, 254);
          box-shadow: 1px 1px 2px 0 rgba(0, 0, 0, 0.3);
          display: flex;
          flex-direction: column;
          width: 20em;
        }

        .book-info {
          text-align: center;
          padding: 1rem;
        }

        ::content .title{
          font-size: 2em;
        }

        ::content .content {
          background-color: rgba(0, 0, 0, 0.01);
          box-shadow: 0 2px 8px -4px rgba(0, 0, 0, 0.5) inset;
          line-height: 1.5;
          padding: 1em 1rem;
        }
      </style>
      <!--
        -- Shadow DOM内で<content>タグを使えば、表のDOMの内容を取り込むことができる。
        -- 表のDOMの一部だけを取り込みたい場合はselect属性でセレクタを指定する。
        -->
      <div class="book-info">
        <content select=".title"></content>
        <content select=".author"></content>
      </div>
      <content select=".content"></content>
    </div>
  </template>

  <!--
    -- ホストとする要素。
    -- Shadow DOMを追加しても、見かけ上はこのDOMが表示される。
    -- ホスト直下の要素は<content>要素を使用することで
    -- Shadow DOM側に表示することができる。
    -->
  <div id="gingatetsudo-card">
    <div class="title">銀河鉄道の夜</div>
    <div class="author">宮沢賢治</div>
    <div class="content">
        「ではみなさんは、そういうふうに川だと云われたり、乳の流れたあとだと云われたりしていたこのぼんやりと白いものがほんとうは何かご承知ですか。」先生は、黒板に吊した大きな黒い星座の図の、上から下へ白くけぶった銀河帯のようなところを指しながら、みんなに問をかけました。
    </div>
  </div>
</body>
</html>
import 'dart:html';

void main() {
  var template = (querySelector("#card-template") as TemplateElement).content;

  // Shadow DOMのRootを作成する。
  // Shadow DOMを作成すると表のDOMの内容は非表示になる。
  // 表のDOMの内容を表示したい場合は<content>タグを使う。
  var card = querySelector("#gingatetsudo-card");
  var shadowRoot = card.createShadowRoot();
  shadowRoot.append(template.clone(true));
}

Custom Elements

Custom Elementsはユーザーによる要素の定義を可能にする。ただし、ユーザー定義の要素名はハイフンを含む必要がある。つまり<tweet-card>や<search-box>といった要素を定義することはできるが、<tweet>や<search>のような要素を定義することはできない。

Dartでの独自要素の定義

Dartから要素を定義するには、HtmlElementを継承したクラスを作成して、document.registerElement()メソッドで要素を登録する。コンストラクタはcreated()を定義する。独自のコンストラクタを定義したい場合、generativeコンストラクタは追加できないので、factoryコンストラクタを使う。

import 'dart:html';

// HtmlElementを継承したクラスを作る。
// コンストラクタはHtmlElement.created()のみ。
// 独自のコンストラクタを追加したい場合は
// factoryコンストラクタを使おう。
class MyElement extends HtmlElement {
  MyElement.created() : super.created();
  factory MyElement() => document.createElement("my-element");
}

void main() {
  // 任意のタグ名で登録。
  // 名前にはハイフンを含める必要がある。
  document.registerElement("my-element", MyElement);
}

これで要素が認識されるようになる。定義した要素を利用するには、HTML側に直接書いてもいいし、スクリプト側で操作してもいい。

<my-element></my-element>

既存要素の拡張

既存のHTML要素を拡張するには、拡張したい要素を継承すればいい。独自要素を定義するときとは違い、registerElement()やcreateElement()の引数として、継承元の要素名を指定する必要がある。

import 'dart:html';

class MyDivElement extends DivElement {
  MyDivElement.created() : super.created();
  factory MyDivElement() => document.createElement("div", "my-div");
}

void main() {
  document.registerElement("my-div", MyDivElement, extendsTag:"div");
}

HTMLでの記述時は、is属性を利用して指定する。

<div is="my-div"></div>

ライフサイクルメソッド

HtmlElementクラスには、特定のイベント時に呼び出されるライフサイクルメソッドがある。それらをオーバーライドすれば、各イベント発生時の処理を定義することができる。

ライフサイクルメソッドには、要素作成時に呼び出されるcreated()コンストラクタの他に、DOMへの追加時に呼び出されるattached()メソッド、DOMからの削除時に呼び出されるdetached()メソッド、属性が変更されたときに呼び出されるattributeChanged()がある。

import 'dart:html';

class MyElement extends HtmlElement {
  MyElement.created() : super.created();

  factory MyElement() => document.createElement("my-element");

  // DOMに追加されたとき呼び出される。
  @override
  void attached() => super.attached();

  // DOMから取り除かれたときに呼び出される。
  @override
  void detached() => super.detached();

  // 属性が変更されたときに呼び出される。
  @override
  void attributeChanged(String name, String oldValue, String newValue) =>
      super.attributeChanged(name, oldValue, newValue);
}
void main() {
  document.registerElement("my-element", MyElement);
}

サンプル

Custom Elementsを利用したサンプル

Custom Elementsを利用したサンプル

<!DOCTYPE html>
<html>
<head lang="ja">
  <meta charset="UTF-8">
  <title></title>
  <link id="toggle-button-link" rel="import" href="toggle_button.html">
  <script async type="application/dart" src="custom_elements.dart"></script>
</head>
<body>
  <toggle-button value="on">Click to Toggle</toggle-button>
  <div id="display"></div>
</body>
</html>
<template>
  <style scoped>
    :host {
      cursor:  pointer;
      padding: .5em 1em;
    }

    :host([value="on"]) {
      background-color: rgb(120, 250, 120);
    }

    :host(:not([value="on"])) {
      background-color: rgb(250, 120, 120);
    }
  </style>
  <content></content>
</template>
import 'dart:html';
import 'dart:async';

// 本来は、クラス定義からregisterElementまでは
// importされるHTML側でやるべきなのだろうが
// Dartiumがまだ複数のscriptタグに対応していないので
// ここで全てまとめて行っている。

// トグルボタン要素のクラス。
class ToggleButtonElement extends HtmlElement {
  static DocumentFragment _template;
  StreamController<bool> _onToggleController;
  bool _on;

  // HtmlElement.created()コンストラクタを定義しておく。
  ToggleButtonElement.created() : super.created() {
    var shadow = createShadowRoot();
    shadow.append(_getTemplate().clone(true));

    var initialValue = attributes["value"];
    value = (initialValue == "on");

    _onToggleController = new StreamController<bool>.broadcast();

    onClick.listen((e)=>toggle());
  }

  // 引数を受け取ったりなんなりしたいときはfactoryコンストラクタを利用する。
  factory ToggleButtonElement() => document.createElement("toggle-button");

  DocumentFragment _getTemplate() {
    if(_template == null) {
      var link = (document.querySelector("#toggle-button-link") as LinkElement).import;
      _template = (link.querySelector("template") as TemplateElement).content;
    }
    return _template;
  }

  bool toggle() {
    value = !value;
    _onToggleController.add(value);
  }

  bool get value => _on;
  void set value(bool flag) {
    _on = flag;
    attributes["value"] = _on ? "on" : "off";
  }

  Stream<bool> get onToggle => _onToggleController.stream;
}

void main() {
  document.registerElement("toggle-button", ToggleButtonElement);

  var toggleButton = querySelector("toggle-button") as ToggleButtonElement;
  var display = querySelector("#display")..text=toggleButton.value.toString();

  toggleButton.onToggle.listen((value)=>display.text=value.toString());
}