JSX、TemplateLiteral とは一味違うテンプレートエンジン HyperScript - Cycle.js を学ぼう #2

前回のエントリ で Cycle.js の概要についてご紹介しました。今回は HyperScript という Cycle.js における DOM の描画の仕組みについてご紹介します。

HyperScript - Cycle.js における DOM の描画を司る

まずは前回のエントリにあるサンプルコードをおさらいしましょう。

type Sources = {
  DOM: DOMSource;
}
type Sinks = {
  DOM: Observable<VNode>;
}
/**
 * アプリケーション
 * @param sources
 * @returns {{DOM: Observable<VNode>}}
 */
function main(sources: Sources): Sinks {
  // キー入力イベントを取得 ( Intent )
  const input$: Observable<Event> = sources.DOM.select('.field').events('input');
  // 入力イベントから現在の状態ないし値を取得 ( Model )
  const name$: Observable<string> = Observable.from(input$)
    .map((ev: Event) => (ev.target as HTMLInputElement).value)
    .startWith('');
  // 現在の状態を画面に描画 ( View )
  const vdom$: Observable<VNode> = name$.map(name => {
    return div('.well', [
      div('.form-group', [
        label('Name: '),
        input('.field.form-control', {attrs: {type: 'text'}}),
      ]),
      hr(),
        h1(`Hello ${name}`)
      ]);
  });
  // 結果をドライバに出力する ( Sinks )
  return {
    DOM: vdom$
  };
}
// アプリケーションからの戻り値を受け取るドライバ群を定義
const drivers = {
  DOM: makeDOMDriver('#app-container')  // DOM をレンダリングするドライバ
};
// アプリケーションとドライバを結びつける
run(main, drivers);

アプリケーション層であるmain()関数内で Model から View を生成している箇所があります。よく見るとdiv()label()input()hr()といったHTMLタグ名まんまのメソッドが呼ばれていますね。これが HyperScript です。

このように書いたものが、

div('.well', [
  div('.form-group', [
    label('Name: '),
    input('.field.form-control', {attrs: {type: 'text'}}),
  ]),
  hr(),
  h1(`Hello Cycle.js!!!`),
  p('.lead', 'A functional and reactive JavaScript framework.')
]);

最終的にこのような HTML となって出力されるわけです。

<div class="well">
  <div class="form-group">
    <label>Name: </label>
    <input type="text" class="field form-control">
  </div>
  <hr>
  <h1>Hello Cycle.js!!!</h1>
  <p class="lead">A functional and reactive JavaScript framework.</p>
</div>

セレクタやtype="text"といった attributes はもちろん、要素の入れ子構造が HTML のそれと対になっているのがお分かりでしょうか。HyperScript はメソッドでありながら HTML のツリー構造を模したような記述が出来るというのが特徴です。どうですか?摩訶不思議ながらも便利そうに思えてきませんか?それでは使い方についてもう少し詳しく見ていきましょう。

HTML のタグ名ごとにメソッドが用意されている

上の例にあるように <div />div()<h1 />h1() など現行の HTML5 要素全てがメソッド名として用意されています。animate()rect()といった SVG 関連の要素はもちろん、meta()link()script()といったメタ要素のものも用意されています。

可変長引数であり、渡した値の数・内容に応じて生成処理が変わる

上記の例のh1()p()を見てみましょう。

h1(`Hello Cycle.js!!!`),
p('.lead', 'A functional and reactive JavaScript framework.')
<h1>Hello Cycle.js!!!</h1>
<p class="lead">A functional and reactive JavaScript framework.</p>

h1()には Hello Cycle.js!!! と innerHTML にあたる値だけが引数として渡されています。一方の p()に渡している innerHTML A functional and reactive ... は第二引数となっており、第一引数には『セレクタ』が渡されています。どちらも同じ文字列ですが、.# で始まる場合はセレクタとして認識され、そうでない場合は innerHTML とみなされます。ちなみに ID と Class が混在するセレクタの場合は必ず ID を先に指定する必要があります

div('#foo.well')  // OK
div('.well#foo')  // NG

子要素を指定する場合は innerHTML に子要素を配列形式で渡します。

div('.well', [
  div('.form-group', [
    label('Name: '),
    input('.field.form-control'),
  ])
]);

改行してインデント付けてあげれば実際の DOM 構造がイメージ出来るなど可読性も充分ですね。

属性は Snabbdom を使って指定する

Snabbdom は非常にシンプルかつ単純な仕組みの仮想DOM ライブラリであり、Cycle.js の DOMDriver は内部でこのライブラリに依存しています。

使い方は至ってシンプルで、以下のようなオブジェクト形式で各種属性の値を指定します。

{
    attrs: {
        type: 'text',
        width: 100
    },
    style: {
        'background-color': 'red',
        'font-weight': 'bold'
    },
    class: {
        'modifiered-style': boolean
    }
}

type="text"style="display: block" といったセレクタ以外の属性はattrsキー配下のブロックに記述します。もちろん data属性も扱うことが可能です。中身はいたって普通の JavaScript なので、三項演算子やメソッド呼び出しの戻り値渡しなども普通に出来ます。インラインスタイルは styleキー配下のブロックに記述します。

classキーのブロックは動的に管理したいクラスを記述します。管理するクラス名をキーとし、値が true となるとそれが適用されます。

HyperScript 自体は単なる DOM 要素の生成のみをするもの

いわゆる Virtual DOM のように変更前と後との差分だけを更新するというものではなく、あくまで document.createElement() を実行しているだけの実にシンプルなライブラリです ( ソースコードは160行足らずしかありません ) 。Cycle.js の DOMDriver もmain 関数から Sink してきた情報をそのまま HyperScript に投げて描画しているだけです。Virtual DOM に関しては Snabbdom が全てになっており、そちらについての詳しくは別エントリにて解説したいと思います。

HyperScript の実態はh()という単一のメソッド ( API )のみである

先ほど『div()label()input()といった HTML タグまんまのメソッド』とご紹介しましたが、実はこれらは全て Cycle.js の DOMDriver が独自に定義しているヘルパーメソッドであり、HyperScript そのものとは厳密には異なります。実際の HyperScript は h() というメソッドのみであり、第一引数に div.welldiv#foo.well と生成したい HTML 要素名まで含んだセレクタ文字列を渡すという仕様になっています。つまり生の HyperScript を使う場合、先ほどのサンプルコードは以下のようになります。

h('div.well', [
  h('div.form-group', [
    h('label', 'Name: '),
    h('input.field.form-control', {attrs: {type: 'text'}}),
  ]),
  h('hr'),
  h(' h1', `Hello Cycle.js!!!`),
  h('p.lead', 'A functional and reactive JavaScript framework.')
]);

一応 Cycle.js でもこちらの記述は可能です。悪くはないですが、瞬時に DOM ツリー構造を把握するには少々厳しいものがあります。やはりひとつひとつのタグ用のヘルパーメソッドがある方が嬉しいですね。

これ使うと何が嬉しいの?

例えば Angular のテンプレート機能は``内に View の構造を記述しますが、あくまで文字列としての定義となります。

@Component({
  selector: 'hero-detail',
  template: `
    <div *ngIf="hero">
      <h2>{{hero.name}} details!</h2>
      <div><label>id: </label>{{hero.id}}</div>
      <div>
        <label>name: </label>
        <input &#91;(ngModel)&#93;="hero.name" placeholder="name"/>
      </div>
    </div>
  `
})

コードシンタックス的に文字列ということは、タイポなどで未定義のメソッド名や変数名をしてしまっても TypeScript のコンパイルが通ってしまい、実際に動作させるまで間違いに気付かないということになります。 id の重複や HTML のマークアップミス ( 入れ子構造の崩れなど ) があれば Angular 自身がエラーを投げてくれますが、やはりそれも実際に動作させた後の話であって事前に間違いに気づくことはできません。

その点、HyperScript は全て純粋な JavaScript の関数なので、変数やメソッドの間違いがあれば TypeScript のコンパイル時にエラー扱いとされます。地味な話ですが、何百、何千と繰り返すコンパイルからの間違いチェックですので、少しでも効率よく作業出来ることに越したことはありませんね。