Hello CircleJS

CircleJSにもVue-initのように開発環境を一発で作れるコマンドがあるみたいなので使ってみます。まずはインストールが必要です。

npm i -g create-cycle-app

そして、create-cycle-app <プロジェクト名> --flavor <flavor名>という感じで指定します。flavorはこれを書いている段階ではcycle-scripts-one-fits-allというのしか無いのでこれを指定します。

create-cycle-app my-app --flavor cycle-scripts-one-fits-all

成功すると以下のような文面がでます。

Success! Created my-app at /Users/nju33/test/ccc/apppp/my-app
Inside that directory, you can run several commands:

  npm start
    Starts the development server

  npm test
    Start the test runner

  npm run build
    Bundles the app into static files for production

  npm run eject
    Removes this tool and copies build dependencies, configuration files
    and scripts into the app directory. If you do this, you cant go back!

We suggest that you begin by typing:

  cd my-app
  npm start

上記の文面の通り、npm startするとlocalhost:8080でサーバーが立ち上がり、「My Awesome Cycle.js app」が表示されました。

カウンターアプリを作る

カウンターアプリを作る

最初Todoを作ろうと思ったんですが、ざっとドキュメント見た限り「なんのこっちゃ」だったので、簡単なカウンターアプリでちょっとずつ理解する作戦にしました。結果から言うと、app.tsxだけ編集してこんな感じになりました。

import xs, {Stream} from 'xstream';
import {VNode} from '@cycle/dom';
import {Sources, Sinks} from './interfaces';

export function App(sources : Sources) : Sinks {
  let count = 0;

  const vdom$ : Stream<VNode> = sources.DOM.select('button').events('click')
    .map(e => Number(e.target.value))
    .startWith(0)
    .map(value => {
      count += value;
      return (
        <div>
          <h1>Counter</h1>
          <h2>{count}</h2>
          <button value={1}>Increment</button>
          <button value={-1}>Decrement</button>
        </div>
      );
    });
  return {DOM: vdom$};
}

上記で説明するとこと言えばvdom$に入れてるStreamの所ぐらいですかね。

sources.DOM.select('button').events('click')はボタンにイベントを登録しています。document.getElementByTagName('button').addEventListener('click', ...)的なやつですね。

まだ一回もイベントが起きてない時用になんですが、startWithで流れるデータの一番最初の値を設定することができます。初期値を設定しないと処理が流れるための値がないので、DOMがレンダリングされることもなくなってしまいます。

<button/>がクリックされるとその要素のイベントがmapに渡されます。ここでそれを本当に使いたい値に変換しますが、そのタイプはstartWithと同じもので返すようにします。

つまりこう書くことはできません。0.targetなんてプロパティはない為です。

const vdom$: Stream<VNode> = sources.DOM.select('button').events('click')
  .startWith(0)
  .map(e => {
    count += Number(e.target.value);
    return (...);
  });

まぁとりあえず、これでいいのか分かりませんが、カウントできるやつができました。

Counter app complete

カウンターアプリをもうちょい改善する

ここは追記なんですが、実は上記のコードはちゃんとドキュメントを読んでなくて、CycleJSはMVIなんだって所すら読んでなくて書き方とかどうやって書くのが正しいのかよく分かっていなかった。翌日ドキュメントサイトに一通り目を通して、CycleJSを書く時はこんな感じで考えてかけばいいんだなーというのが分かった。

  • Intent / ユーザーの「これをクリックしてカウントを増やしたい」とか「これをクリックしてカウントを減らしたい」みたいな、そのページ上でユーザーがやりたいと思いそうなActionから、それによって起こる副作用の実装までを担当

  • Model / Intentで指定したActionの初期値を設定したり、Viewで使いたいデータへ変換(2つのStreamを1つに合わせたり)処理を書く

  • View / Modelで定義した値を使ってHTML部分を作る

こんな感じの理解で上記セクションのコードを修正したらこんな感じになりました。

import xs, {Stream} from 'xstream';
import {VNode} from '@cycle/dom';

import {Sources, Sinks} from './interfaces';

export function App(sources : Sources) : Sinks {
  // intent
  interface Action {
    increment : Stream<number>;
    decrement : Stream<number>;
  };
  const action$ : Action = {
    increment: sources.DOM.select('.increment').events('click').mapTo(+1),
    decrement: sources.DOM.select('.decrement').events('click').mapTo(-1)
  };

  // model
  interface State {
    count : Stream<number>;
  };
  const state$ : State = {
    count: (() => {
      return xs.merge(action$.increment, action$.decrement)
        .fold((sum, n) => sum + n, 0);
    })()
  };

  // view
  const vdom$ : Stream<VNode> = state$.count.map(count => {
    return (
      <div>
        <h1>Counter</h1>
        <h2>{count}</h2>
        <button className='increment'>Increment</button>
        <button className='decrement'>Decrement</button>
      </div>
    );
  });

  return {
    DOM: vdom$
  };
}

.foldの辺りとか分かりづらいので自分なりに説明してみます。

action$で定義してる2つのActionで使われている.eventseventが流れますが、mapToは流れてきた値をすべて指定した値にしてしまうメソッドです。map(() => -1)みたいなのと同じだと思います。

xs.mergeで2つのStreamの延長線上に共通の処理を追加することができて、今回だと.foldを追加してます。 これはJavaScriptの.reduceと似ていて2つ目の引数に初期値が来ます。つまり上記のcountは最初は0という値が設定されます。

そして、例えば.incrementなボタンが押されるとActionの段階では+1のStreamが、.foldに渡ってきてsum=0, n=1という風に値が入るので、countは1になります。もう一度同じボタンを押すとsum=1, n=11+1=2という感じになって、その後デクリメントするとsum=2, n=-12-1=1countは変わります。

うーん、理解できたようなできないような。とりあえず何となく分かってきたような気がするので次はTodoアプリでも作りたいと思います。