NHN Cloud NHN Cloud Meetup!

JavaScriptフレームワークの概要1- Cycle.js

これから4回にわたり、JavaScript(フロントエンド)フレームワークについて紹介したいと思います。以下のような内容で連載する予定です。

  1. Cycle.js
  2. Angular 2
  3. Vue.js
  4. React

Cycle.js

はじめに

Cycle.jsはイベントストリーム方式を基盤に、フロントエンドアプリケーションを作成できるようにサポートするフレームワークです。RxJSの貢献者の一人であるAndréStaltzが作成し、RxJSに基づいて、完全な反応型(Reactive)プログラミングを支援してくれます。AndréStaltzはCycle.jsを紹介する際、Reactはその名称とは異なり完全に反応性がないという批判をしていますが、その意味では、Cycle.jsはReactが導入した反応的、関数型の特徴をより極端に押し進めたフレームワークであるともいえるでしょう。

Cycle.jsは、関数型、反応型という2つのプログラムを強調します。関数型プログラミングは、現在のJavaScriptで馴染みのある概念ですが、反応型プログラミングという用語は不慣れな方もいると思いますので、そのような方には、この記事を推奨します。反応型プログラミングの定義は場合によっても異なりますが、ここでいう反応型プログラミングとは、非同期データストリームを利用してプログラミングすることです。そのため、RxJSなどのイベントストリームライブラリを積極的に活用します。このような方法のプログラミングを関数反応型プログラミング(Functional Reactive Programming)とも言いますが、このような特徴から、Cycle.jsで作られたコードはthisキーワードがなく、setState()foo.update()のような命令型の呼び出しもありません。

関数反応型プログラミング(FRP)という用語にも様々な見解があります。AndréStaltzが自分の意見をまとめた文章があるので、興味のある方は参考にしてください。

RxJS

Cycle.jsの前に、まずRxJSから紹介しましょう。RxJSは、ReactiveXのJavascriptの実装ライブラリで、Observableというデータモデルを用いて、イベントベースの非同期コードを簡単に扱えるようにサポートしてくれます。ReactiveXは、MicrosoftやNetflixのような企業で積極的に使用されており、特に最近、正式発表されたAngular 2もRxJSを導入するなど、ますますユーザー層が厚くなっています。

開発/駆動環境

依存性

Cycle.jsは唯一の外部依存性を持ちますが、上述したように特定のイベントストリームライブラリを必要とします。RxJS基盤のため、基本的にはRxJSに依存性がありましたが、最近は、別のストリームライブラリが必要だという判断のもと、XStreamというライブラリを作成し、RxJSの代替として使用することを推奨しています。XStreamは、RxJSより小さく、高速なhot streamのみに対応するなど、Cycle.jsに特化されています。しかし、依然としてRxJS(v4)が使用でき、RxJS(v5) most.jsなどのライブラリでも代替可能です。

この他、Cycle.jsは内部的にVirtual-DOM向けにSnabbdomというライブラリを使っており、外部依存が分離されていないため、別途ダウンロードの必要はありません。

開発環境

Cycle.jsのチュートリアルドキュメントを見ると、必須ではありませんが、BabelTypeScriptのようなトランスコンパイラとBrowserifyWebpackのようなバンドルツールの使用を推奨しています。ライブラリ自体がnpmとES6のモジュールを活用して、依存性の管理、バンドルなどがしやすいように構成されており、関数型のコードを作成する際にも、ES6の文法が大いに役立つからです。また、Babelプラグインsnabbdom-jsxを活用すると、JSX文法を使ってVirtual-DOMを作成できます。

ただし、Cycle.jsは7.0.0(Diversity)からTypeScriptで完全に再作成されたため、BabelよりはTypeScriptとの相性が良いかもしれません。

駆動環境

Cycle.jsの弱点とも言えますが、ブラウザのサポート範囲がそれほど広くありません。Cycle DOMリポジトリの記載によると、正式に対応しているIEバージョンは、Window7のIE10とIE11だけです。

(出典:cycleDOM

もちろん上表から除外されたブラウザでも実行できることもありますが、正式なサポート範囲に含まれていないため、100%の正常動作は保証されません。

アーキテクチャ

基本のデータフロー

Cycle.jsは、基本的にユーザーとコンピュータがストリームに基づいて、入力(Input)と出力(Output)をやりとりする関数だと仮定します。コードで表現すると、次のようになります。

function computer(inputDevices) {
  return outputDevices;
}

function human(senses) {
  return actuators;
}

図で表現すると、このようになります。
(出典:cycle.js.org

つまり、コンピュータはマウスやキーボードなどのデバイスを用いて入力を受け、モニター画面、スピーカーなどを用いて出力します。ユーザーは目や耳などの感覚器官で、コンピュータの出力が入力された後に、特定の行為をすることによって、出力を出します。
これらの入力/出力データを一連のストリームとして処理できるようにサポートするのがRxJSのようなライブラリであり、コンピュータとユーザーの入力/出力を接続して、一種の循環サイクルを作り上げるのがCycle.jsの役割です。(なぜ名前がCycleなのか理解できるでしょう)

AndréStaltzの、もしユーザーが1つの関数ならば?を見ると、こうした基本概念がよく理解できます。

Cycle.run(main、drivers)

このような循環サイクルを作成するために、Cycle.jsでは2つのパラメータを受け取るrun関数を提供します。最初のパラメータは、通常mainと呼ばれる関数で、上記computerで表現された関数のように、入力デバイスからのデータストリームをパラメータとして受け取り、出力デバイスにエクスポートするデータストリームを返却する関数です。2番目のパラメータは、ドライバと呼ばれる概念の関数で、main関数で出力されたイベントストリームを受け取り、ユーザーに表示できる形式に変換して、再びユーザーからイベントを受け取り、ストリームに返却するAPIを提供します。代表的なドライバにDOM Driverがあり、main関数から返却されるVirtual-DOM構造のストリームを実際のDOMに変換して、DOMでユーザーのイベントを読み込めるAPIを提供します。

このドライバは、実際にオペレーティングシステムで使用されるドライバの概念と似ています。オペレーティングシステムで、特定のハードウェアと接続する際、途中でアダプタの役割をするものをドライバというように、Cycle.jsのドライバは、main関数と外部環境(DOM、Browser API、HTTP通信など)を繋ぐアダプタの役割をします。

Cycle.jsで特に注意が必要なのは、すべての副作用(Side Effect)がドライバで処理される、ということです。例えば、DOM DriverはDOM変更のような、実際のブラウザに依存するすべての副作用をすべて内部で処理します。これにより、main関数を単純に入力/出力ストリームで受け取る純粋関数として維持ができ、単体テストやメンテナンスにおいて大いに役立ちます。
(出典:cycle.js.org

上図にある下のボックスからDOM、HTTPなどの副作用を処理するのがドライバです。main関数の出力ストリームを受け取り、副作用を処理してmain関数に入力ストリームを提供します。run関数は、main関数とドライバの入出力ストリームを連結する役割をします。

ここで簡単な例をみてみましょう。次のコードは、チェックボックスの状態が変更される度に、pタグ内のテキストをON、あるいはOFFに変更します。

import xs from 'xstream';
import {run} from '@cycle/xstream-run';
import {div, input, p, makeDOMDriver} from '@cycle/dom';

function main(sources) {
  return {
    DOM: sources.DOM
      .select('input').events('change')
      .map(ev => ev.target.checked)
      .startWith(false)
      .map(toggled =>
        div([
          input({attrs: {type: 'checkbox'}}), 'Toggle me',
          p(`${toggled ? 'ON' : 'OFF'}`)
        ])
      )
  };
}

Cycle.run(main, {
  DOM: makeDOMDriver('#app')
});

上記コードのrun関数から第2パラメータに渡したオブジェクトのDOMプロパティが、まさにDOM Driverです。makeDOMDriver関数で指定されたCSSセレクターDOM Driverを作成して、このように渡されたDOM Driverは出力値としてDomSourceオブジェクトを作成して返却します。DomSourceオブジェクトは、main関数の入力値であるsources.DOMとして渡され、DomSourceから提供されるselecteventsAPIを使って実際のDOMで発生するイベントをストリームを作成できます。そして、これらのイベントのストリームを変換して、main関数から最終的に返却されるVirtual-DOMストリームは、再びDOM Driverの入力値になり、実際のDOMに変換されて画面に表示されます。

Model-View-Intent

上記から分かるように、Cycle.jsのAPIはrun関数がすべてだと言えます。main関数をどのように実装するかは、コードの作成者が自由に決定すればよく、AngularやReact/Reduxのように遵守すべき特定APIはありません。
また、Cycle.jsではmain関数が過度に複雑にならないように、Model-View-Intentという仕組みを推奨しています。(出典:cycle.js.org

上記のように、main関数でタスクをIntent – > View – > Modelの順に分けて作成できます。先のコードを再作成すると、次のようになるでしょう。

function main(sources) {
  const actions = intent(sources.DOM);
  const state$ = model(actions);
  const vdom$ = view(state$);

  return {
    DOM: vdom$
  }
}

詳細は後にして、ここでは主要な特徴をみてみよう。

  • intentmodelview はすべて純粋関数である。
  • state$vdom$ 最後の$は一種のコンベンションで、この変数がストリームであることを意味する。
  • intent関数はDOMSourceからイベントストリームを作成し、actionsオブジェクトでまとめて返却する。
  • model関数はintentから受信したイベントのストリームを変換して、実際のアプリケーションの状態(State)変化を表す1つのストリームを作成して返却する。
  • view関数は、modelから受け取った状態のストリームを変換してVNode(Virtual Node:Virtual-DOMを構成するNode)ツリーのストリームを作成して返却する。

Intent

Intentは文字通り、ユーザーの意図をイベントストリームを用いて定義します。まず、上記の例に合うintent関数を作成してみよう。

function intent(domSource) {
  const change$ = domSource.select('input')
      .events('change')
      .map(ev => ev.target.checked);

  return {change$};
}

domSourceはDOM Driverが返却するオブジェクトで、selectメソッドはCSSセレクタを使ってスコープを制限し、eventsメソッドは指定されたDOMイベントが発生すると、イベントデータが発生するストリームを返却します。最後のmap関数は、RxJSやXStreamのストリームオブジェクトから提供されるAPIで、当該ストリームを変換して、新しいストリームに返却します。つまり上記のintent関数は、チェックボックスの状態が変更される度に、true/falseデータが発生するストリームを作成して返却するオブジェクトのchange$という属性に割り当てられます。

Model

Modelは、アプリケーションの状態を管理するという点で、MVCパターンのModelと似ています。重要なことは、ストリームを入力してストリームを返却するという点でしょう。正確には、複数のイベントストリームが入力されて、1つの状態ストリームを返却します。コードを見てみよう。

function model(actions) {
  const toggled$ = actions.change$.startWith(false);

  return toggled$;
}

コードは非常に単純ですが、実際にここでIntentから返却されたイベントストリームは1つしかありません。Modelが初期値を割り当てる程度の役割しか持たないためです。もし、Intentから返却されるオブジェクトがchange$以外のイベントストリームを持つなら、Modelはこれらのストリームを合わせて1つの状態ストリームに返却しなければなりません。チェックボックスの他に、別途インプット要素を存在させ、キーが入力される度に、テキストが追加されるイベントストリームをactions.keydown$と仮定してみましょう。

function model(actions) {
  const toggled$ = actions.change$.startWith(false);
  const text$ = actions.keydown$.startWith('');

  return xs.combine(toggled$, text$)
    .map(([toggled, text]) => ({
      toggled, text, 
    }));
}

上のxs.combineは、XStreamのAPIから複数のストリームをまとめて1つのストリームで返却してくれます。このようにModelから返却されるストリームのデータは、アプリケーションの全体的な状態を表すオブジェクトからストリームだけを除外すると、Reduxで使用される単一状態(Single State)の概念と似ています。実際に、このようにストリームを結合する部分を除いた残りを、Reduxのように状態オブジェクトを変化させるReducer関数として分離して使用できます

View

ViewもMVCパターンのViewと似ています。違いはModelと同様に、ストリームを入力してもらって、ストリームを返却するという点です。

function view(state$) {
  return state$.map(toggled => 
    div([
      input({attrs: {type: 'checkbox'}}), 'Toggle me',
      p(`${toggled ? 'ON' : 'off'}`)
    ])
  );
}

このとき、入力値はModelから渡された状態ストリームで、返却される値はVNodeツリーのストリームです。Cycle.jsは、内部的にSnabbdomライブラリを使いますが、Snabbdomは基本的にVNodeツリーを生成するため、hyperscript文法を使用します。コードにあるdivpinputのような関数は、hyperscriptをさらに簡単に使えるようにcycleDOMが提供しているヘルパー関数です。詳しいAPIは、Snabbdom文書から確認できます。

Reactとの比較

ストリーム基盤の構造を除けば、Virtual-DOMを使ったり、単一の状態オブジェクトを使用する点などは、React/Redux構造と類似性があります。明確に異なるところは、IntentがReactでは<button onclick={handler}>のような式でVirtual-DOM構造に直接イベントハンドラを定義することと、反対のアプローチをとっていることです。かつて、jQueryのようなライブラリから、直接セレクタからイベントハンドラを割り当てていた方式により近いと言えます。

これは、Viewの役割を単純にModelの状態変更に反応して処理する役割に限定させ、より反応的(Reactive)にするためです。これにより、ユーザーの意図(Intent)を追加する作業がViewに影響を与えず、両方のモジュールの役割を明確に分離することができます。

イベントハンドリングが必然的にViewの構造に影響を受けざるを得ないことを考えると、2つを分離する方法は、むしろコード管理を困難にすることがあります。Cycle.jsではこれらの欠点を最小限に抑えるため、ユーザーのIntentを定義する際、DOM構造でclassNameを積極的に活用できるようにisolate()のようなヘルパー関数を提供しています。

コンポーネント

Cycle.jsは、すべてをストリームで対処するため、コンポーネント単位で構造化するときも、ストリームに基づいて作成する必要があります。単にモジュール単位に分割する作業ではありません。下の図を見てみよう。
(出典:cycle.js.org

外側の大きなボックスがmain関数で、内部にある小さなボックスがコンポーネントとします。あるいは、このように特定のコンポーネントが他のコンポーネントを含むこともできます。このように、外部コンポーネント(あるいはmain)に入ってきたストリームから、内部コンポーネントに必要な部分だけを分離して伝達し、内部コンポーネントの出力から出てきたストリームを外部コンポーネントの最終的な出力ストリームと組み合わせて返却します。このとき、内部コンポーネントはイベントの他、Modelのデータも一緒にストリームで受け取るべきで、処理されたデータもVNodeツリーと一緒にストリームに返却する必要があります。
(出典:cycle.js.org

上図から、コンポーネントがユーザーイベントのストリームの他に、必要なデータをprops$ストリームとして入力し、出力値にvtree$ストリームだけでなく、処理された値のvalue$ストリームをエクスポートすることが分かるでしょう。
このように、コンポーネントを作成するときに、全体のDOM領域ではなく、コンポーネントに必要なDOM領域のみにスコープを限定するには、その都度、特定のクラス名を指定するなどの処理が必要ですが、Cycle.jsではこれらの作業をサポートするためにisolate()関数を提供しています。

const ComponentA = isolate(MyComponent, 'comp-a');
const ComponentB = isolate(MyComponent, 'comp-b');

MyComponentは、main関数のように入力ストリームを受け取って出力ストリームを返却する純粋関数です。上記のようにisolateを使うと、MyComponentをそれぞれcomp-acomp-bクラス内にスコープを限定する2つの独立したスライダーにして使用できます。また、2番目の引数を使用しない場合は、内部的にランダムなクラス名を割り当てて、CSSに影響を受ける部分でなければ、クラス名を明示的に指定する必要もありません。

図で見ると概念は単純ですが、実際にストリームを分割・結合するプロセスは、RxJSなどを使ったFRPに慣れていないと理解が難しいでしょう。詳しい内容は、Cycleのコンポーネントの説明文書を参照してください。

テスト

Cycle.jsのアプリケーションは、ほとんどが純粋関数で作られているので、テストはとても簡単です。オブジェクトを生成して状態を管理する必要がなく、関数別に入力/出力テストのみ作成すればよいでしょう。

ただし、イベントストリームへの依存度が高く、これらのストリームをまとめたり、分離する作業が多くなりますが、このようなタスクはテストが簡単ではなく、当該ストリームライブラリがテストに対応する方式に大きく影響を受けます。例えば、RxJS 5以降、Marble Testに対応して、XStreamではfromDiagram関数を提供していますが、これらの機能を使うと、ストリームのテストを次のようにMarble Diagram形態で作成できます。

var e1 = hot('----a--^--b-------c--|');
var e2 = hot(  '---d-^--e---------f-----|');
var expected =      '---(be)----c-f-----|';

expectObservable(e1.merge(e2)).toBe(expected);

このように、Cycle.jsではイベントストリーム自体を扱う部分と、実際のデータを扱う部分を分離して、プログラムを作成すると、テストしやすいコードを作成できるでしょう。

また、DOMなどの外部環境に関連する副作用は、すべてドライバ内部で扱われるため、外部環境をMockingしてテストを作成するのも容易です。ただし、ドライバはMockingに対応しているAPIを提供しなければなりません。例えば、DOM DriverはmockDOMSource関数を提供してDomSourceをMockingできるようにしています。これを活用すると、次のようにテストを作成できます。

const eventDummy = {
  target: {
    parentNode: {
      dataset: {
        id: 5
      }
    }
  }
}

it('removeSong: ', function() {
  const domSource = mockDOMSource({
    '.btn-remove': {
      'click': Observable.of(eventDummy)
    }
  })

  removeSong(domSource).subscribe(id => {
    expect(id).toBe(5)
  })
})

removeSong関数は、Intentの内部で削除ボタンをクリックすると、そのボタンに関連するIDを返却するストリームを作成して返してくれる関数です。上記のようにmockDOMSourceを使うと、.btn-removeclickのイベントストリームを直接作成して設定することができ、作成されたDomSourceを使ってremoveSongを呼び出すと、新しいストリームが返却され、このストリームをsubscribeして、テストコードを作成できます。

パフォーマンス

Cycle.jsのすべてをストリームで処理するという特徴から、他のライブラリに比べて若干のオーバーヘッドがあります。また、ストリーム処理でどのライブラリを使用するかによって、パフォーマンスが影響を受けます。バージョン7.0.0以前では、RxJS自体のパフォーマンスの問題に加えて、処理が遅いという意見が多くありました。しかし、バージョン7.0.0からは、XStreamとSnabbdom基盤に全体のコードベースが変更され、パフォーマンスが向上しています。JavaScriptフレームワークのパフォーマンスを比較した記事を見ると、いくつかのテストでは、Reactよりも高速パフォーマンスを見せています。特にメモリ使用量においては良好な結果が出ています。関数型の特徴でもある、不必要なインスタンス化が多くないため(特にViewが純粋関数であるため)と推測できますが、明確な理由は実際にもう少し観察が必要そうです。

Virtual-DOMを使うという特徴もパフォーマンスに影響を及ぼしますが、イベントが発生する度に全体VNodeが変更される構造から、パフォーマンスが大きく低下する可能性があります。このような場合には、SnabbdomのThunk関数でVNodeをキャッシュして使用すればパフォーマンスを改善できるでしょう。

まとめ

実のところ、Cycle.jsは難しいと思います。ストリーム基盤の反応型プログラミングに慣れていない場合は、まともに使えないでしょう。このような関数反応型プログラミング(FRP)方式は、理解しにくく、たくさん勉強して練習する必要がありますが、一度この方法を理解すると、非同期方式のコードを容易に扱うことができるでしょう。最近関心が高まっている分野だけに勉強する価値は十分にあると思われます。

また、Cycle.jsはうまく設計されたアーキテクチャを持っています。AndréStaltzとCycle.jsの貢献者たちは一貫した哲学を持ち、着実に設計を発展させているようで、ElmやHaskellなどの関数型言語が持つさまざまなメリットも共有しています。特に純粋関数と副作用を確実に区別して扱えるので、アプリケーションの状態を単純かつ明確に管理することができ、テストしやすいコードを作成できます。

Cycle.jsのホームページでは、これらの設計理念について詳しく説明されています。無料動画講義も視聴できますよ。このように非常に入念に管理されているにも関わらず、ユーザー層がそれ程多くなくて残念ですが、関数反応型プログラミングやReactiveXなどに関心がある場合は、是非一度使ってみてほしいと思います。

NHN Cloud Meetup 編集部

NHN Cloudの技術ナレッジやお得なイベント情報を発信していきます
pagetop