【超絶シンプル設計】Cycle.js でコンポーネントを作ってみる - Cycle.js を学ぼう #3

他のフレームワークの例に漏れず、 Cycle.js にも『コンポーネント』と呼ばれる仕組みが用意されています。今回はいくつかのサンプルコードを交えながらこのコンポーネントについてご紹介します。

Cycle.js のコンポーネントは『小さな main() 関数』である

Cycle.js のコンポーネントは、親である main 関数から Source Stream を受け取り、それを元に任意の状態 ( state ) となって最後に Sink Stream というデータを親に返します。

この仕組み、聞き覚えがありませんか?アプリケーション層 ( main 関数 ) とドライバ層 ( Driver 関数 ) との関係にそっくりですね。アプリケーション層とドライバ層が SourceSink というインターフェースで循環しているのと同じく、コンポーネントは親である main 関数と Sources / Sinks というインターフェースでやり取りします

https://cycle.js.org/components.html より引用

以上。たったこれだけです。コンポーネントといいつつ何か特別なアーキテクチャがあるわけではありません。これもまた『Cycle ( データストリームの循環 )』というたった一つのシンプルなアーキテクチャで全て作られています。

ラベル付きスライダー - はじめてのコンポーネントを作ってみよう

まずは公式チュートリアルにあるラベル付スライダー を作ってみましょう。完成イメージはこちら。

See the Pen Cycle.js - Our first component: a labeled slider by wakamsha (@wakamsha) on CodePen.

ラベルとスライダーの二つの部品で構成されています。スライダーを動かすとその値がラベルに反映されます。

function main(sources: Sources): Sinks {
  // スライダー入力イベントを取得 ( Intent )
  const input$: Observable<Event> = sources.DOM.select('.slider').events('input');
  // 入力イベントから現在の状態ないし値を取得 ( Model )
  const value$: Observable<number> = Observable.from(input$)
    .map((ev: Event) => (ev.target as HTMLInputElement).value);
  const DEFAULT_VALUE = 50;
  const state$ = value$.startWith(DEFAULT_VALUE);
  // 現在の状態を画面に描画 ( View )
  const vdom$: Observable<VNode> = state$.map(value => {
    return div([
      input('.slider.form-control', {
        attrs: { type: 'range', min: 0, max: 100, value }
      }),
      h1(`Label: ${value} units`),
    ]);
  });
  // 結果をドライバに出力する ( Sinks )
  return {
    DOM: vdom$
  };
}
const drivers = {
  DOM: makeDOMDriver('#app')
};
run(main, drivers);

はじめにユーザーイベントを入力として受け取ります ( input$ ) 。そこからスライダー値を取り出します。これがいわゆる現在の状態となります ( value$ ) 。この値のイベントストリームをテキスト出力すると同時にスライダー要素 ( input ) の仮想 DOM ストリームを生成します ( vdom$ ) 。

なんてことはありませんね。単純なデータバインディングです。コードの可読性を高めるために Model-View-Intent 風にコードを整理してみます。

⋮
/**
 * 入力イベントを取得する
 * @param DOM
 * @returns {Observable<R>}
 */
function intent(DOM: DOMSource): Observable<number> {
    return DOM.select('.slider').events('input')
        .map((ev: Event) => (ev.target as HTMLInputElement).value);
}
/**
 * 入力イベントから状態を生成する
 * @param change$
 * @returns {Observable<T>}
 */
function model(change$: Observable<number>): Observable<number> {
    return change$.startWith(70);
}
/**
 * 仮想DOMを生成する
 * @param value$
 * @returns {Observable<R>}
 */
function view(value$: Observable<number>): Observable<VNode> {
    return value$.map(value => {
        return div([
            label(`Weight is ${value}kg`),
            input('.slider', {
                attrs: { type: 'range', min: 40, max: 150, value }
            })
        ]);
    })
}
function main(sources: Sources): Sinks {
    const change$ = intent(sources.DOM);
    const value$ = model(change$);
    const vdom$ = view(value$);
    return {
        DOM: vdom$
    };
}
⋮

一連の処理を役割ごとにメソッド化することで見通しが良くなりました。

外からプロパティを渡せるようにする

ここまでいい感じですが、『Weight』というラベルとスライダーの最小値、最大値がベタ書きになっています。このままでは使い勝手がよろしくないので、これに以下のような『プロパティ』を渡せるようにしてみます。

type Props = {
    label: string;   // 『Weight』や『Height』など
    unit: string:    // 『kg』や『cm』など
    min: number;     // スライダーの最小値
    max: number;     // スライダーの最大値
    initial: number; // スライダーの初期値  
}

これらのプロパティをどうやってコンポーネントに渡せばよいでしょうか?原則として main 関数に渡ってくる値は Sources のみです。であれば Sources にこれらのプロパティを含めれば良いということになります。Sources の出処はドライバです。ドライバは常に Sinks ( main 関数から出ていく値 ) を取得して Sources を返す関数です。drivers 部分を以下のように書き換えます。

const drivers = {
  DOM: makeDOMDriver('#app'),
  Props: () => Observable.of({
      label: 'Weight',
      unit: 'kg',
      min: 40,
      max: 150,
      init: 60
  })
};

drivers に Props というプロパティを追加しました。これにより main 関数の引数に Props が含まれます。よってこれらの値を main 関数内で参照出来るようになります。先ほどのコードを以下のように書き換えます。

type Props = {
  label: string;   // 『Weight』や『Height』など
  unit: string;    // 『kg』や『cm』など
  min: number;     // スライダーの最小値
  max: number;     // スライダーの最大値
  init?: number;   // スライダーの初期値
  value? : number; // スライダーの最新値
}
type Sources = {
  DOM: Cycle.DOMSource;
  props: Props;
}
⋮
function model(newValue$: Observable<number>, props$: Props) {
  const initialValue = props$.map(props => props.init).take(1);
  const value$ = initialValue.concat(newValue$);
  return Observable.combineLatest(
    value$,
    props$,
    (value, props) => {
      return {
        label: props.label,
        unit: props.unit,
        min: props.min,
        max: props.max,
        value
      };
    }
  );
}
function view(state$: Observable<Props>) {
  return state$.map(state => {
    return div([
      label(`${state.label} : ${state.value}${state.unit}`),
      input('.slider', {
        attrs: { type: 'range', min: state.min, max: state.max, value: state.value }
      })
    ]);
  })
}
function main(sources: Sources): Sinks {
  const change$ = intent(sources.DOM);
  const state$ = model(change$, sources.props);
  const vdom$ = view(state$);
  return {
    DOM: vdom$
  };
}
const drivers = {
  DOM: makeDOMDriver('#app'),
  props: () => Observable.of({
    label: 'Weight',
    unit: 'kg',
    min: 40,
    max: 150,
    init: 60
  })
};

model 関数に props という第二引数を追加して初期値である Props.initを受け取れるようにしました。ここで初期値と入力値である newValue$ を合成してvalue$を生成します。また、model は全ての状態を管理するところですので、ここで value$ とともに他の props 値を含めたオブジェクト、すなわち state$ を返します。

state$ は view 関数に渡され、そこで仮想DOMを生成します。これまでベタ書きだったラベルや最小・最大値は全て state$ から動的に受け取って使います。

先のデモと同じ結果が得られました。試しに labelunit の値を変えるとそれが反映されるのがお分かりかと思います。

コンポーネント化してみる

コンポーネント化する方法ですが実はなんてことはなく、main() という関数名を別のものに変えればそれでおしまいです。これは『ラベル付きのスライダー』なので LabeledSlider とすれば良いでしょう。引数である props も drivers 内に含めるのではなく、main 関数から直接このコンポーネントに渡してあげれば OK です。冒頭で述べた通り、コンポーネントは親である main 関数と Sources / Sinks というインターフェースでやり取りするだけで、仕組み自体は main 関数とドライバとの関係となんら変わりません。

⋮
/**
 * ラベル付きスライダー
 * @param sources
 * @returns {{DOM: Observable<VNode>}}
 */
function LabeledSlider(sources: Sources): Sinks {
  const change$ = intent(sources.DOM);
  const state$ = model(change$, sources.props);
  const vdom$ = view(state$);
  return {
    DOM: vdom$
  };
}
/**
 * アプリケーション
 * @param sources
 * @returns {{DOM: Observable<VNode>}}
 */
function main(sources: Sources): Sinks {
  const props$ = Observable.of({
    label: 'Weight',
    unit: 'kg',
    min: 40,
    max: 150,
    init: 60
  });
  return LabeledSlider({DOM: sources.DOM, props: props$});
}
const drivers = {
  DOM: makeDOMDriver('#app')
};
Cycle.run(main, drivers);

あっという間にコンポーネント化出来ました。あとは必要に応じて LabeledSlider.ts と別ファイル化してあげれば保守性もバッチリでしょう。

isolate - ひとつのコンポーネントから複数のインスタンスを生成する

ここからは先ほどの LabeledSlider を二つ組み合わせて『BMI 計算アプリ』を作ってみます。体重用と身長用それぞれのスライダーを用意し、それらの値からの計算結果を表示するというものです。完成イメージはこちら。

See the Pen Cycle.js - Exporting values from components through sinks by wakamsha (@wakamsha) on CodePen.

複数のインスタンスを生成する方法ですが、単純に考えるなら以下の方法でしょうか。

⋮
function main(sources: Sources): Sinks {
  const weightProps$ = Observable.of({
    label: 'Weight',
    unit: 'kg',
    min: 40,
    max: 150,
    init: 60
  });
  const heightProps$ = Observable.of({
    label: 'Height',
    unit: 'cm',
    min: 140,
    max: 220,
    init: 140
  });
  const weightSinks = LabeledSlider({DOM: sources.DOM, props: weightProps$});
  const heightSinks = LabeledSlider({DOM: sources.DOM, props: heightProps$});
  const weightVDom$ = weightSinks.DOM;
  const heightVDom$ = heightSinks.DOM;
  const vdom$ = Observable.combineLatest(
    weightVDom$,
    heightVDom$,
    (weightVDom, heightVDom) => {
      return div([
        weightVDom,
        heightVDom
      ]);
    }
  )
  return {
    DOM: vdom$
  };
}

体重用プロパティ ( weightProps$ ) と身長用プロパティ ( heightProps$ ) を用意し、 weightSinksheightSinks という二つのインスタンスを生成しました。しかしこれは明らかに間違いです。以下のデモを試してみましょう。

See the Pen Cycle.js - Multiple independent instances of a component by wakamsha (@wakamsha) on CodePen.

ラベル付きスライダーのインスタンスは確かに二つあります。しかしこのスライダーのユーザーイベントは.sliderという要素に対して発火しています。インスタンスが複製されようがコンポーネント内部で指定するセレクタが同じなため、ひとつを操作すると他のインスタンス全てが反応していまいます。これでは使いものになりません。

isolate モジュールを使う

そこで登場するのが isolate という Cycle.js のヘルパー関数です。isolate は、任意の Source Stream ( この場合は LabeledSlider コンポーネント ) に対して独自のスコープを付与することで先ほどのようなイベントフックの重複を回避することが出来ます。

モジュールとして独立して提供されているので、npm からインストールします。


import {Component, OnInit} from '@angular/core';
import {Cycle} from '../../../../declares/interface';
import {Observable} from 'rxjs';
import {VNode, div, makeDOMDriver, label, input, h2} from '@cycle/dom';
import {run} from '@cycle/rxjs-run';
import {DOMSource} from '@cycle/dom/rxjs-typings';
import isolate from '@cycle/isolate';
⋮

使い方はとても簡単で、isolate()関数の第一引数に分離して使用したいコンポーネントの関数を、第二引数にスコープ名を渡すとスコープ名で分離されたインスタンスが返ってきます。先ほどのコードを以下のように修正します。

function main(sources: Sources): Sinks {
  ⋮
  const weightSlider = isolate(LabeledSlider, 'weight');
  const heightSlider = isolate(LabeledSlider, 'height');
  const weightSinks = weightSlider({DOM: sources.DOM, props: weightProps$});
  const heightSinks = heightSlider({DOM: sources.DOM, props: heightProps$});
  const weightVDom$ = weightSinks.DOM;
  const heightVDom$ = heightSinks.DOM;
  const vdom$ = Observable.combineLatest(
    ⋮
  )
  ⋮
}

たったこれだけでコンポーネントの中身の実装はそのままに別々のインスタンスとして期待通りの動作が実現できます。ちなみに第二引数は省略可能で、その場合はランダムな文字列が自動生成されます。また、isolate が分離させるのは DOM ドライバに限らず、あらゆる種類のドライバに対して有効です。

コンポーネントの Sinks に スライダー値を追加する

BMI を計算するには各スライダーの値を取得する必要があります。現状 LabeledSlider からの Sinks は DOM しかないため、ここにスライダー値である value を追加しましょう。

追加と言ってもやることは非常に単純で、以下のようにLabeledSliderをほんの少し修正するだけです。

function LabeledSlider(so: {DOM: Cycle.DOMSource, props$: Observable<Props>}): {DOM: Observable<VNode>, value: Observable<number>} {
  const change$ = intent(so.DOM);
  const state$ = model(change$, so.props$);
  const vdom$ = view(state$);
  return {
    DOM: vdom$,
    value: state$.map(state => state.value)
  };
}

これでスライダーの値を返すことができます。後は main 関数内でこの値を受け取って BMI を計算する処理を追加すれば完成です。

function main(sources: Sources): Sinks {
  const WeightSlider = isolate(LabeledSlider, 'weight');
  const HeightSlider = isolate(LabeledSlider, 'height');
  const weightSinks = WeightSlider({DOM: sources.DOM, props$: weightProps$});
  const heightSinks = HeightSlider({DOM: sources.DOM, props$: heightProps$});
  const weightVDom$ = weightSinks.DOM;
  const heightVDom$ = heightSinks.DOM;
  const weightValue$ = weightSinks.value;
  const heightValue$ = heightSinks.value;
  // 現在の BMI 値を取得 ( State )
  const bmi$ = Observable.combineLatest(
    weightValue$,
    heightValue$,
    (weight: number, height: number) => {
      const heightMeters = height * 0.01;
      const bmi = Math.round(weight / (heightMeters * heightMeters));
      return bmi;
    }
  );
  const vdom$ = Observable.combineLatest(
    bmi$,
    weightVDom$,
    heightVDom$,
    (bmi, weightDOM, heightDOM) => {
      return div([
        weightDOM,
        heightDOM,
        h1(`BMI is ${bmi}`)
      ]);
    }
  );
  return {
    DOM: vdom$
  }
}

以上、小規模ではありますが、共通部分をコンポーネント化し、そこから複数のインスタンスを生成して値を合成するというアプリケーションが出来ました。その実が Sources / Sinks というたった一つのアーキテクチャで成り立っているというのがお分かりいただけたでしょうか。

締め

Cycle.js のコンポーネントの仕組みをご紹介しました。特別これのために新しく覚えることは何もなく、これまでに学習してきたものの流用に過ぎません。非常にシンプルな仕組みですが、しかしその可能性は無限大なのです。