読者です 読者をやめる 読者になる 読者になる

cycle.jsを使うアプリのコード分割方針の例

前の記事でcycle.jsについて少し紹介したものの、いまから始める人のことを考えると日本語の情報が無くて大変だろうと思ったので、何か説明出来ることがないか探した。多分使いはじめると途中でコードの分割方法について困ると思うので、いま開発しているエディタアプリではこうやっているよというのを書く。

概観

エディタ領域のコードは重いので左側2枚のサイドバーの部分のコードだけ抜き出して書くと、現状こういう感じのファイル構成になっている。

.
|-- entities
|   |-- sheet-group.js
|   `-- sheet.js
|-- index.html
|-- index.js
|-- intent.js
|-- intents
|   |-- load-sheet.js
|   `-- load-sheets.js
|-- model.js
|-- models
|   |-- panes-count.js
|   |-- sheet-group.js
|   |-- sheet-groups.js
|   `-- sheets.js
|-- templates
|   |-- root.js
|   |-- sheet-group-selector.js
|   `-- sheet-selector.js
`-- view.js

image

index.js

このファイルが index.html から読み込まれるエントリポイントになる。同じディレクトリにある、Model・View・Intentの関数をそれぞれ読み込み、Cycle.jsのイベントループに設定するとアプリケーションが動き出す。index.js からは、出来る限りファイル構成がどうなっているかについて意識しなくても良いようにしようとしてこうなった。

import { makeDOMDriver } from '@cycle/dom';
import Cycle, { Rx } from '@cycle/core'
import intent from './intent'
import model from './model'
import view from './view'

Cycle.run(
  (responses) => {
    return {
      DOM: view(model(intent(responses)))
    };
  },
  {
    DOM: makeDOMDriver('body')
  }
);

intent.js

intents ディレクトリの中にある小さく分割された複数のintentを読み込み、大きなintentとして合成して返す。intentは「responsesを受け取って、Object{String => Observable} を返す関数」ということにしている。responsesは、{ DOM: ... } というデータ構造になっているObjectで、主にDOMからのイベントを受け取るためのイベントソースの集合である。DOM以外にもHTTPやipc (Electronのプロセス間通信のための仕組み) などから入力を受け取る場合もあるので、intentをresponsesを受け取る関数として定義した。intentは単純なObjectを返すだけなので簡単に1つの関数にまとめられる。

import loadSheet from './intents/load-sheet'
import loadSheets from './intents/load-sheets'

export default function (responses) {
  return {
    ...loadSheet(responses),
    ...loadSheets(responses)
  };
}

model.js

modelsディレクトリの中にある小さく分割された複数のmodelを読み込み、1つのストリームとして合成して返す。modelは「actionsを受け取り、Observable<Object> を返す関数」として定義したので、Rx.Observable.combineLatest で合成できる (どれか1つのObservableに新しい値が来たら新しい値を返すやつなので、これを使えば何らかの状態が更新されたときにViewに更新を伝えられる)。細かいけど、それぞれの分割されたmodel関数の名前は、その関数のObservableが返す値を表す名詞にしてみてる。しっくりこなかったら変える。

import _ from 'lodash'
import { Rx } from '@cycle/core'
import panesCount from './models/panes-count'
import sheetGroup from './models/sheet-group'
import sheetGroups from './models/sheet-groups'
import sheets from './models/sheets'

export default function (actions) {
  return Rx.Observable.combineLatest(
    panesCount(actions),
    sheetGroup(actions),
    sheetGroups(actions),
    sheets(actions),
    (...objects) => _.extend(...objects)
  );
}

view.js

viewだけは同じ型の小さなviewには分割されない。代わりに、templatesディレクトリ以下にtemplate関数を返す複数のファイルを用意して分割するようにしている。template関数は「Objectを受け取り、VirtualDOMを返す関数」として定義してある。ただのテンプレート関数。

import root from './templates/root'

export default function (state$) {
  return state$.map(root);
}