Cycle.js で本格的なコンポーネント ( モーダル、タイマーゲージ ) を作ってみる - Cycle.js を学ぼう #4

wakamsha
22

カウントダウンタイマー

完成イメージ

おおまかな設計概要

指定した時間から 0 に向かってカウントダウンしていくものです。開始一時停止リセットだけのシンプルな設計にします。

一点拘りたいのが、この手のアプリの多くが setInterval ( RxJS だと timer オペレータ ) を使ってカウンタ変数をインクリメント / デクリメントするという実装をしがちですが、今回はその実装方法を採用しません。詳しい理由は割愛しますが、アプリ全体の処理が重くなると正確に時間を刻む保証がされなくなるのでカウントダウンのようなシビアな要件には不向きなのです。この辺につきましては以下のブログエントリにて詳しく解説されています。

RxJS の interval オペレータは使用しますが、今回は単純なカウンタ変数ではなく都度 Date.now() から現在時刻を取り出して現在とひとつ前の時刻の差分をチェックするという方法を使います。最初は少々小難しく思えるかもしれませんが1)自分も数ヶ月前にこの方法を教わりました。、知っておくと何かと活躍する Tips ですのでオススメです。

型定義

Sources
名前 概要
props maxTime$ Observable 最大時間 ( ms )
alertTime number アラート状態になる残り時間 ( ms )
active$ Observable カウントダウンタイマーを動かすかどうか
reset$ Observable カウントダウンタイマーをリセットする
Sinks
名前 概要
isActive$ Observable カウントダウンタイマーが動いているかどうか
timeSpent$ Observable 経過時間 ( ms )
DOM Observable レンダリングした仮想 DOM

まずは上記の型を定義します。

type Sources = {
  props: {
    maxTime$: Observable<number>
    alertTime: number;
    active$: Observable<boolean>
    reset$: Observable<undefined>
  }
}

type Sinks = {
  isActive$: Observable<boolean>
  timeSpent$: Observable<number>
  DOM: Observable<VNode>
}

reset$ は Observable として定義しましたが、ここでやりたいことは『リセットしたい』というのをイベントストリームを使っての伝達だけであり、何か具体的な値を送りたいわけではないので undefined と定義しています。

複雑な状態管理はオブジェクト志向っぽく Class 化してしまおう

次にコンポーネント本体を実装します。が、今回は少々状態管理が複雑なので全てを Observable 上だけでやろうとすると非常に難解なコードになってしまいます。そのため State という Class を作り、状態やカウントダウンそのものの処理を全てここにまとめるとしましょう。

class State {
  public isPlaying = false;  // カウントダウンタイマーが動作しているかどうか
  public timeSpent = 0;      // 経過時間 ( ms )

  private lastTime = -1;     // ひとつ前の時刻 ( ms )

  public tick(): State {
    const now = Date.now();
    if (this.lastTime > 0) {
      this.timeSpent += (now - this.lastTime);
    }
    this.lastTime = now;
    return this;
  }

  public setPlaying(playing: boolean): State {
    this.isPlaying = playing;
    if (!this.isPlaying) {
      this.lastTime = -1;
    }
    return this;
  }

  public reset(): State {
    this.isPlaying = false;
    this.timeSpent = 0;
    this.lastTime = -1;
    return this;
  }
}

tick() は残り時間を計算するこのコンポーネントの根幹とも言える関数です。Observable.timer() から毎回呼び出され、その都度 Date.now() で現在時刻を取得して直近の値との差分を timeSpent に加算していくことで経過時間を算出します。直近の値は lastTimeというメンバー変数に格納します。初期状態 ( リセット後の状態も含む ) は -1 が格納されます。

また、どの関数も最後に return this をしていますが、これにつきましては後述します。

ここだけ見るとちっとも Rx っぽさが無く、オブジェクト志向な方々には親しみやすいのではないでしょうか?なんでもかんでも Observable 上で完結させようとするのではなく、状態 ( State ) 管理などは思い切ってオブジェクト志向っぽく書いてしまったほうがスマート かと思います。

ベース部分を実装

リセット一時停止機能も兼ねていることから少々複雑なので、最初は開始一時停止だけを実装します。

⋮	
export function CountdownTimerComponent({props}: Sources): Sinks {
  const active$ = props.active$;

  const model$ = Observable.merge(
    // tick$
    active$
      .switchMap((active: boolean) => active ? Observable.interval(33) : Observable.of(0))
      .map(() => (acc: State) => acc.tick()),
    // setPlaying$
    active$.map((playing: boolean) => (acc: State) => acc.setPlaying(playing))
  ).startWith(
    (seed: State) => seed
  ).scan(
    (acc: State, fn: (acc: State) => State) => fn(acc), new State()
  );

  const vdom$ = Observable.combineLatest(
    model$,
    props.maxTime$,
    (state, maxTime) => {
      const remainTime = maxTime - state.timeSpent;
      return {
        time: remainTime < 0 ? 0 : remainTime,
        maxTime
      };
    }
  ).distinctUntilChanged().map(
    ({time, maxTime}): VNode => render({maxTime, time, alertTime: props.alertTime})
  );

  return {
    isActive$: model$.map((state: State): boolean => state.isPlaying),
    timeSpent$: model$.map((state: State): number => state.timeSpent / 1000),
    DOM: vdom$
  }
}

model$ ( ※ 少々難解です )

ここでのキモは model$ すなわち状態管理です。Observable の起点となるのはカウントダウンを開始するかしないかのフラグである active$ であり、この値を switchMap に受け渡して true なら interval(33) を実行して 33ミリ秒2)web ブラウザの描画を 60fps とすると、1フレームあたり約 16ミリ秒となります。ただしこれはあくまで理想値であって、カウントダウンタイマー程度ならその倍の 33ミリ秒でも差し支えません。ごとに値を流し、falseなら of(0) を実行して 0 を一回だけ流します。これらの値は次の map に受け渡し、そこで先ほど定義した State クラスの tick() 関数を実行します。interval の場合は 33ミリ秒ごとに値が流れてくるので、その度に tick() が実行されるというわけですね。

さて、ここから更に次のオペレータへと続くわけですが、先ほど『State クラスの各メソッドが最後に this すなわちクラスのインスタンスを返している』のを思い出してください。これは後続の scan オペレータに続くための処置なのです。scan は第二引数に流れてきた値を受け取り、それを元に行った『任意の処理』結果を次に scan が実行される際の第一引数として受け取ります。『任意の処理』は State クラスのいずれかの関数ですが、どの関数も最後にインスタンス ( this ) を返しているので、第一引数は直近の State インスタンスが来ます。つまり再帰処理がここで行われているわけです。ややこしいですね、はい3)弊社の某 Scala エンジニア がノリで Cycle.js と戯れている中で編み出したテクです ( ※ 関数型マンすげぇ )

とはいえ、この関数の最後にインスタンスを返す方法には欠点も潜んでいるため決してベストプラクティスではありません。その辺りの解決方法は追々別のエントリにてご紹介したいと思います。

ここまでが tick 処理に関する Observable で、これに setPlaying 処理に関する Observable を merge します。こちらは active$ から来るフラグを State インスタンスの setPlaying() 関数に引数として渡してあげれば OK です。この結果もまた State インスタンスが返ってくるので、tick Observable 同様 scan オペレータにつなげることが出来ます。

vdom$

ここは取り立て解説することはありません。先ほどの model$maxTime$ から仮想DOMを生成しますが、同じ値が立て続けに来ることがあるため、不要に仮想DOM生成処理を実行しないために distinctUntilChanged オペレータを挟んで連続して同じ値が来た時は処理を続けないようにします。

仮想DOMの生成処理は render()関数として切り出します。

function render({maxTime, time, alertTime}: {alertTime: number, maxTime: number, time: number}): VNode {
  const min = digit(Math.floor(time / 60000));
  const sec = digit(Math.floor(time / 1000) % 60);
  const msec = digit(Math.floor(Math.floor(time / 10)));
  const ratio = time / maxTime;
  const isAlert = time < alertTime;

  return div('.countdown-timer', &#91;
    div('.countdown-timer__col.countdown-timer__col--progress', &#91;
      div('.progress', &#91;
        div('.progress-bar', {
          style: {width: `${ratio * 100}%`},
          class: {
            'progress-bar-danger': isAlert,
          }
        })
      &#93;)
    &#93;),
    div('.countdown-timer__col', &#91;
      span('.countdown-timer__counter', {
        class: {
          'countdown-timer__counter--danger': isAlert
        }
      }, `${min}:${sec}:${msec}`)
    &#93;)
  &#93;);
}
⋮
&#91;/code&#93;

時間表示のための0埋め処理として <code>digit()</code>関数も定義します。

function digit(n: number): string { return (`0${n}`).substr(-2); }

リセット機能を実装

model$ を以下のように書き直します。

const model$ = Observable.merge(
  // tick$
  Observable
    .merge(
      active$,
      props.reset$ ? props.reset$.mapTo(false) : Observable.never()
    )
    .switchMap((active: boolean) => active ? Observable.interval(33) : Observable.of(0))
    .map((_) => (acc: State) => acc.tick()),
  // setPlaying$
  active$.map((playing: boolean) => (acc: State) => acc.setPlaying(playing)),
  // reset$
  props.reset$ ? props.reset$.map((_) => (acc: State) => acc.reset()) : Observable.never()
).startWith(
  (seed: State) => seed
).scan(
  (acc: State, fn: (acc: State) => State) => fn(acc), new State()
);

reset$ は停止を兼ねているので、active$ と merge する必要があります。それとは別に reset$ が流れてきたら State インスタンスの reset() 関数を実行したいので、そのための処理も追加します ( reset$ Observable ) 。

これでこのコンポーネントの全ての実装が完了しました。

呼び出し側を実装

先ほどのコンポーネントを main 関数から呼び出します。PlayPauseResetボタンからカウントダウンタイマーを制御します。また、カウントダウンタイマーは『動作しているか ( isActive$ )』と『経過時間 ( timeSpent$ )』を Sink させてくるので、その値も画面に表示させます。

function main(sources: Sources): Sinks {
  const countdownTimer = CountdownTimerComponent({
    props: {
      maxTime$: Observable.of(5000),
      alertTime: 3000,
      active$: Observable.merge(
        sources.DOM.select('#timer-gauge-play').events('click').mapTo(true),
        sources.DOM.select('#timer-gauge-pause').events('click').mapTo(false)
      ),
      reset$: sources.DOM.select('#timer-gauge-reset').events('click').mapTo(undefined)
    }
  });

  return {
    DOM: Observable.combineLatest(
      countdownTimer.DOM,
      countdownTimer.timeSpent$,
      countdownTimer.isActive$,
      (timerGaugeDOM, timeSpent, isActive) => {
        return div('.container', [
          h2('.page-header', 'Countdown Timer'),
          div('.row', [
            div('.col-sm-6', [
              timerGaugeDOM,
              hr(),
              div('.btn-group', [
                button('#timer-gauge-play.btn.btn-default', 'Play'),
                button('#timer-gauge-pause.btn.btn-default', 'Pause'),
                button('#timer-gauge-reset.btn.btn-default', 'Reset')
              ])
            ]),
            div('.col-sm-6', [
              pre([
                code([
                  JSON.stringify({
                    isActive: isActive,
                    result: timeSpent
                  }, null, 2)
                ])
              ])
            ])
          ]),
        ])
      }
    )
  };
}

const drivers = {
  DOM: makeDOMDriver('#app')
};

run(main, drivers);

締め

コンポーネントの実装パターンを二種類ご紹介しました。モーダルコンポーネントの content$ 部分は Angular の ng-content ( Upgrading from AngularJS - ts - GUIDE ) を参考にしたものですが4)AngularJS ( 1.x 系 ) では ngTransclude がこれに相当しました。、非常にシンプルに実装することが出来ました。カウントダウンタイマーの複雑な状態管理を State クラスにまとめてしまうという設計はかなり応用が効くので、是非とも身につけておきたいところです。

脚注

脚注
1 自分も数ヶ月前にこの方法を教わりました。
2 web ブラウザの描画を 60fps とすると、1フレームあたり約 16ミリ秒となります。ただしこれはあくまで理想値であって、カウントダウンタイマー程度ならその倍の 33ミリ秒でも差し支えません。
3 弊社の某 Scala エンジニア がノリで Cycle.js と戯れている中で編み出したテクです ( ※ 関数型マンすげぇ )
4 AngularJS ( 1.x 系 ) では ngTransclude がこれに相当しました。