リアクティブ・プログラミングに特化した JS フレームワーク Cycle.js を学ぼう #1 - 導入編

更新 : RxJS v6 に対応しました。

過去数回に渡って RxJS の基本的な使い方をご紹介してきました。

RxJS 自体はリアクティブ・プログラミングを実現するためのいちライブラリであり、いわゆる Angular や Vue.js、React といったタイプのものとは毛色が異なります ( 強いて言うなら lodash.js のようなタイプが近いかもしれません ) 。そんな Rx をフレームワークの域にまで昇華させた Cycle.js なるものを学びはじめたのでご紹介したいと思います。

Cycle.js とは?

一言で言うなら Observable と 仮想 DOM を良い感じに組み合わせて提供してくれるフレームワークです。

『フレームワーク』と公式で謳っていますが、Angular のようにあらゆる機能を備えたフルスタックフレームワークというわけではなく、Cycle.js 自体が提供している機能は非常に薄くて少ないものとなっています。RxJS のような Observable 機能を web アプリケーション開発において使いやすくするために薄くラップしたものと捉えていただければ OK です。

2014年11月誕生と比較的新しいフレームワーク

GitHub のグラフによると2014年11月2日に最初のコミットがなされています。React の最初のコミットが2013年5月26日、AngularJS ( 1.x系 ) が2010年1月3日、Vue.js は少なくとも2014年2月には世に出ていたので1)First Week of Launching Vue.js、比較的後発の新しいフレームワークということが分かります。

コントリビュータ ( 作者 ) の André Staltz 氏は Flux に対する解決案として Cycle.js を開発

Cycle.js のメイン・コントリビューターである André Staltz 氏は RxJS にも積極的にコミットしており、JavaScript 界隈のリアクティブ・プログラミング領域において強い存在感を放っているようです。

Staltz 氏は Cycle.js をリリースする前に Redux の作者である Dan Abramov(@dan_abramov)氏らと『Flux で良い設計をするためにはどうすればよいか』というテーマについて議論をしています。かねてから Flux に対して課題を感じていた Staltz 氏はその解決案として Cycle.js を開発したとのことです。

インストールから Hello World まで

Cycle.js がどのようなものなのか、実際に動かして体験してみるとしましょう。公式サイトにある Getting Started のチュートリアルを動かすところまでやってみたいと思います。

前提条件

  • MacOS Sierra 10.12.4
  • Node.js v10.15.3
  • npm v6.4.1
  • yarn v1.15.2

Ver.7.0 以降、Cycle.js は TypeScript で実装されていることから、プロダクションコードもTypeScriptで書いていきます。Observable を扱う関係上、型のある TypeScriptの方が適しています。

完成予想イメージはこちら。

サンプルコードはこちらから取得できます。

ディレクトリ構成は以下の通り。

.
├── README.md
├── bs-config.js
├── node_modules/
├── package.json
├── public/
├── src/
│   ├── index.html
│   └── scripts
├── tsconfig.json
├── webpack.config.js
└── yarn.lock

プロジェクト用ディレクトリを作成

今回の環境構築に必要な Node パッケージをインストールします。任意のディレクトリ ( 今回は hello-cyclejs とします ) を作成し、 npm プロジェクトを作成します。

# ディレクトリを作成
$ mkdir -p path/to/your/directory/cyclejs-hello
# ディレクトリに移動
$ cd path/to/your/directory/cyclejs-hello

次に package.json を作成します。サンプルなので細かい項目は入力せず全て初期値のままにしてしまいましょう。

$ yarn init -y
⋮
success Saved package.json
✨  Done in 18.71s.
➜  cyclejs-hello
{
  "name": "cyclejs-hello",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}

npm initでも構いませんが、yarn の方が圧倒的に高速なのでおすすめです。

Cycle.js 本体と依存するライブラリをインストール

Ver.7.0以降、Cycle.js は依存ライブラリを RxJS から xstream というものに変更しました。xstream は RxJS の軽量版といった位置づけで、RxJS よりもオペレータの数が厳選されていたり命名に若干変更が入れられたライブラリです。jQuery に対する Zept のようなものだと思っていただければ OK です。開発者である Staltz氏は xstream の使用を強く推していますが、RxJS もこれまで通り使えるよう互換性は担保されています 2)2017年2月9日のアップデートで一部互換性がなくなった箇所があり、そこに関してはハックまがいのことをする必要があります。。今回は RxJS を使うので、これも追加でインストールします。

ターミナルから以下のコマンドを実行して yarn 経由でインストールします。

$ yarn add rxjs xstream @cycle/{dom,run,rxjs-run}

Angular 同様、 Cycle.js もまた単一のファイルではなくモジュールとして機能ごとに分割されており、使用者が必要とするモジュールを組み合わせて使う仕組みとなっております。今回は最低限必要となる3つのモジュールをインストールしました。

@cycle/dom DOM とのやり取りを可能にする Cycle.js ドライバ。Cycle.js を使って画面描画をする際は必須。
@cycle/run リアクティブ・プログラミングによって様々な値を加工するアプリケーションの世界 ( main関数 ) と、その値を受け取ってDOM操作やHTTP通信といった副作用を扱う外部世界 ( ドライバ ) を結びつける機能。いわゆる Cycle.js のコアな部分。
@cycle/rxjs-run RxJS で記述したプロダクションコードを Cycle.js で実行するためのモジュール。

他にもいろいろと便利なモジュールが提供されていますが、まずはこの3つから始めていくとしましょう。

サンプルコードを書いてみる

公式サイトにある Example コードを書いてみます。

import { CycleDOMEvent, div, h1, hr, input, label, p, VNode } from '@cycle/dom';
import { DOMSource, makeDOMDriver } from '@cycle/dom/lib/cjs/rxjs';
import { run } from '@cycle/rxjs-run';
import { Observable } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
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> = input$.pipe(
    map((ev: CycleDOMEvent) => (ev.target as HTMLInputElement).value),
    startWith(''),
  );
  // 現在の状態を画面に描画 ( View )
  const vdom$: Observable<VNode> = name$.pipe(
    map((name: string) => {
      return div('.container', [
        div('.form-group', [label('Name: '), input('.field.form-control', { attrs: { type: 'text' } })]),
        hr(),
        h1([`Hello ${name}`]),
        p([name.padStart(20, 'x')]),
      ]);
    }),
  );
  // 結果をドライバに出力する ( Sinks )
  return {
    DOM: vdom$,
  };
}
// アプリケーションからの戻り値を受け取るドライバ群を定義
const drivers = {
  DOM: makeDOMDriver('#app-container')  // DOM をレンダリングするドライバ
};
// アプリケーションとドライバを結びつける
run(main, drivers);

Cycle.js の仕組みについては追って解説しますので、ここでは簡単に処理の流れだけを解説します。まず Cycle.js は『アプリケーション世界 ( main() 関数 ) 』と『外部世界 ( ドライバ層 )』と世界を大きく二つに分割しており、アプリケーションからの戻り値 ( Sinks ) を外部世界が受け取って、その結果 ( Sources ) を再びアプリケーションが受け取るという、その名の通り処理が循環するような動きをします。

main() 関数では、sources に含まれる DOM 情報の中にあるテキストインプットの入力イベントを取得し ( Intent ) 、そこから現在の入力値を取り出して目的のための処理を行い ( Model ) 、それを元に描画したい DOM 構造を記述しています ( View ) 。最後にその結果を外部世界に放出 ( Sinks ) 。

ドライバはアプリケーションから放出された値を受け取り、主に副作用を伴う処理を担います。ここでは DOM をレンダリングする DOMDriver という Cycle.js 公式のドライバを使います。makeDOMDriver という関数に#app-containerという引数を渡します。これはDOMのレンダリング結果を表示するコンテナ要素のセレクタです。

最後にrun 関数を実行してアプリケーションとドライバを結びつけ、処理を循環させます。

HTML

ベースとなる HTML を作成します。

<!DOCTYPE html>
<html lang="ja">
  <head>
  <meta charset="UTF-8">
  <title>Hello Cycle.js</title>
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
</head>
<body>
<div id="app-container"></div>
<script src="app.js"></script>
</body>
</html>

body には <div id="app-container" /> のみ記述されています。makeDOMDriver の引数に渡した要素がこちらです。中身は Cycle.js にて全て動的に描画するので、HTMLはこれだけで充分です。

TypeScript のコンパイルとローカルサーバの起動

TypeSscript をコンパイルするための Node パッケージとローカルサーバを起動するための browser-sync をインストールします。

$ yarn add -D typescript ts-loader webpack webpack-cli browser-sync npm-run-all @babel/{core,polyfill,preset-env}

先ほど書いたコードと Cycle.js のコードを Bundle ( 依存関係を解決して単一のファイルに結合 ) するために webpack およびそれに関連するモジュールもインストールします。また、レガシーブラウザでも動作させることを考慮して @babel もインストールしておくとします。

ビルドタスクを定義します。

const path = require('path');
const nodeModulesPath = path.resolve(__dirname, 'node_modules');
const babelLoaderOption = {
  loader: 'babel-loader',
  options: {
    presets: [
      [
        '@babel/preset-env',
        {
          targets: {
            node: 'current',
          },
        },
      ],
    ],
  },
};
module.exports = {
  mode: 'development',
  entry: ['@babel/polyfill', './src/scripts/main.ts'],
  output: {
    path: path.resolve(__dirname, 'public/'),
    filename: 'app.js',
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js'],
    modules: ['node_modules'],
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: [babelLoaderOption, 'ts-loader'],
        exclude: [/node_modules/, nodeModulesPath],
      },
      {
        test: /\.jsx?$/,
        use: [babelLoaderOption],
        exclude: [/node_modules/, nodeModulesPath],
      },
    ],
  },
};

最後に package.json に以下のコードを追記して npm script コマンドを定義します。

"scripts": {
  "reset": "rm -rf public/; mkdir -p public/",
  "copy": "cp src/index.html public/index.html",
  "script": "webpack --progress --colors",
  "serve": "browser-sync start -c bs-config.js",
  "start": "yarn reset; yarn copy; run-p \"script --watch\" serve",
},

以下のコマンドを実行して、コンパイルとサーバ起動を実行します。

$ npm run start

ブラウザが起動し、冒頭に紹介したデモが表示されましたでしょうか。なんてことのないデータバインディングのデモですが、これで Cyclejs デビューが出来ました。

此処から先は Cycle.js の設計思想と大まかな仕組みについて解説します。

Cycle.js の設計思想

web アプリケーション以外にも当てはまることですが、一般的な GUI アプリケーションは最初に表示した画面にてユーザーからの入力を待ち受け、受け取った入力から何かしらの処理を行い、その結果を画面上に表示して再びユーザーからの入力を待ち受けるという一連の流れをひたすら繰り返すものです。よりプログラミングっぽく言い換えれば、画面更新という命令やユーザーからの入力は全てアプリケーション自身に伝えられ、それらは全て『イベントソース』ということになります。web アプリケーションですと DOM を介して画面を描画し、DOM を介して入力を受け付けることになります。

http://cycle.js.org/drivers.html より引用

上の図は先ほどの説明を表したものです。上段が アプリケーション層、下段がドライバ層です。下段の domDriver()main() から受け取ったイベントソースないし 仮想 DOM 構造情報 ( Request Observables ) を元に画面にDOMを描画し、そこでのユーザーイベントといったやり取りを抽象化してイベントソース ( Response Observables ) として main() に返す役割を担います。

先ほどのデモでは main() 関数内の処理のみを記述しました。main() 関数では domDriver から受け取ったイベントソースを元にリアクティブ・プログラミングを駆使して最終的に新しい描画イベントを発火するようなイベントソースを domDriver に返しています。DOM の描画といった副作用を伴う処理は全て domDriver 側で行っており、main() 関数内はシンプルなリアクティブ・プログラミングとなっています。Cycle.js はこのように DOM の描画や HTTP 通信など副作用を伴う処理を全てドライバという外部世界に隔離 ( カプセル化 )し、アプリケーション層である main() 関数内はシンプルなリアクティブ・プログラミングのみを記述するという設計となっています。

Model-View-Intent

アプリケーションフレームワークというと『MVC ( Model-View-Controller )』や『MVVM ( Model-View-ViewModel )』といった設計思想をよく見かけますが、Cycle.js は『MVI ( Model-View-Intent )』という設計思想を採用しています。

MVI とは、main 関数を Intent ( ユーザーからの入力といったイベントを取得 ) 、Model ( イベント情報を処理する ) 、View ( 画面に描画するなどといった出力でユーザーに戻す ) といった3つに分割するパターンです。

https://cycle.js.org/model-view-intent.html より引用
概要 入力 出力
Intent DOM イベントなどユーザーからの入力イベントを取得する DOM Driver Source Action Observables
Model 入力を受け取って値を処理するなど、状態を管理する Action Observables state$ Observable
View Model から受け取った状態を視覚化する ( DOM の構築など ) state$ Observable DOM Driver Sink としての VNode Observable
function main(source): Sinks {
    // Intent
    ⋮
    // Model
    ⋮
    // View
    ⋮
    return {
        DOM: view$
    }
}

実はとてもシンプル!?お手軽ミニ Cycle.js を作ってみよう

フレームワークの仕組みを理解するにはコードを読むのはもちろん、同じような仕組みを実際に作ってみるのが一番です。実は Cycle.js は非常にシンプルな仕組みで出来ています。以下のような RxJS のサンプルコードを元に見ていきましょう。

timer(0, 1000)
  .pipe(map(i => `Seconds elapsed: ${i}`))
  .subscribe((text: string) => {
    const container = document.querySelector('#app-container');
    container.textContent = text;
  });

一秒ごとにカウントアップされた数値が流れ、Seconds elapsed: 0 といった文字列に変換されて画面に描画されるだけのコードです。Cycle.js はこの一連の流れを『Logic』と『Effects ( 副作用 )』の二つに分割します。Logic は Rx.Observable から map() までを指し、timer() というイベントソースを受け取って map 関数で値を処理した結果 ( 状態 ) を返しています。Eeffects は subscribe 以降を指し、map 関数からの値を受け取ってDOM描画という処理を行っています。もうお分かりですね。Logic はアプリケーション層 ( main関数 ) であり、Effects はドライバ層そのものなのです。コードを少し整理してみましょう。

// Logic ( functional )
function main() {
  return timer(0, 1000)
    .pipe(map(i => `Seconds elapsed: ${i}`));
}
// Effects ( imperative )
function DOMDriver(text$: Observable<string>) {
  text$.subscribe(
    (text: string) => {
      const container = document.querySelector('#app-container');
      container.textContent = text;
    }
  );
}
// Run
const sink = main();
DOMDriver(sink);

一気に Cycle.js っぽくなってきました。現在ドライバは DOM 描画をするものだけですが、コンソール出力するドライバも作ってみましょう。

⋮
function ConsoleLogDriver(msg$: Observable<string>) {
  msg$.subscribe(
    (msg: string) => {
      console.log(msg);
    }
  );
}
// Run
const sink = main();
DOMDriver(sink);
ConsoleLogDriver(sink);

DOMDriver と同じものを渡すのではつまらないので、ConsoleLogDriver には別のイベントソースを渡してみるとします。main 関数を以下のように修正します。

function main() {
  return {
    DOM: timer(0, 1000).pipe(map(i => `Seconds elapsed: ${i}`)),
    Log: timer(0, 2000).pipe(map(i => 2 * i))
  };
}

戻り値である sink をDOMLogといった複数のイベントソースを含んだオブジェクトにしました。これに合わせて各ドライバの実行処理を以下のように修正します。

const sinks = main();
DOMDriver(sink.DOM);
ConsoleLogDriver(sink.Log);

いかがでしょう。Cycle.js の大まかな仕組みが見えてきたのではないでしょうか。最後の各ドライバを実行している箇所をうまい具合に隠蔽したものが run() 関数です。驚くかもしれませんが、Cycle.js が提供している機能はだいたいこれで全部です。この run 関数もソースコードにして100行程度しかありません。ドライバに関しては DOMDriver や HTTPDriver など幾つかありますが、それらはあくまでプラグイン的なものであってコアとなっているのはこの run 関数だけです。非常に薄いフレームワークというのがお分かりいただけたでしょうか。

締め

以上、長くなりましたが Cycle.js のほんの入り口部分をご紹介しました。次回以降はより詳しい使い方を見ていきたいと思います。

結局のところ、これ使うと何が嬉しいの?

RxSwift や RxJava などがあるようにリアクティブ・プログラミングは web フロントエンドに限らず他の領域でもジワジワと使われ始めています3)それでもまだ『一部の物好きが使うライブラリ』という印象が拭えませんが。。そういった他領域のエンジニアと話す中でよく耳にするのが、『何でもかんでも Observable に乗せて実装しようとするあまり、却って物事を難しくしてしまう』というのがあります。たしかにリアクティブ・プログラミングは洗練された素晴らしいアーキテクチャかもしれませんが、そこに縛られて無理にそれだけを使ってばかりでは技術的負債の温床になりかねません。

Cycle.js は アプリケーション層 ( main ) と 外部世界 ( drivers ) と大きく二つに世界を分割しています。ストリームに流すことで綺麗に書ける ( リアクティブ・プログラミング ) ところは main 関数内に記述し、DOM を激しく操作するなどストリームにそぐわない泥臭い処理 ( 副作用 ) は drivers 側に定義することでストリームの外に隔離するという選択肢が用意されています。特に drivers 内で書ける実装は特定のライブラリやフレームワークに則ったものではなく、ごく一般的な書き方なので開発者のスキルをそのまま活かすことができます4) 引数と戻り値は Observable に乗せてあげる必要があるので、最低限 Rx の知識は必要になりますが…

学習コストは高い or 低い?

Rx、 特に Observable の基礎部分は予め習得していないとお話になりません。そういった意味では ( オブジェクト志向な方々にとっては特に ) ハードルはやや高いと言えます。また、実装するモノに応じて Main 側でストリームに乗せるか Drivers 側に隔離するのか選べると言いましたが、どちらが適しているのかは使用者自身が見極めなくてはなりません。そこには使用者自身の設計力ないしセンスが求められます。フルスタックなフレームワークであればよほどのことがない限りそのお作法に則ることで解決出来ますが、Cycle.js のような薄ーいフレームワークだと全部自分で決めなくてはなりません。自由と責任は表裏一体ということですが……、うん、やはり使いこなすまでの学習コストは高いかもしれませんね。

しかしその分 web フロントエンドエンジニアとして鍛えられることも多いので、長い目で見ればこれはこれで得るものの多い魅力的なフレームワークかと思います。必然的に Rx の知見も貯まるので iOS や Android といった他領域のエンジニア間との共通認識が得られるのも嬉しいメリットです5)弊社サービスのうち、いくつかのモバイルアプリは Rx が採用されているなど、少しずつ浸透し始めています。

参考リンク

脚注

脚注
1 First Week of Launching Vue.js
2 2017年2月9日のアップデートで一部互換性がなくなった箇所があり、そこに関してはハックまがいのことをする必要があります。
3 それでもまだ『一部の物好きが使うライブラリ』という印象が拭えませんが。
4 引数と戻り値は Observable に乗せてあげる必要があるので、最低限 Rx の知識は必要になりますが…
5 弊社サービスのうち、いくつかのモバイルアプリは Rx が採用されているなど、少しずつ浸透し始めています。