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

前回のエントリで Cycle.js におけるコンポーネントの概要について学びました。今回はより実践的かつ本格的なコンポーネントを作ってみようということで、『モーダル』と『カウントダウンタイマー』に挑戦したいと思います。この二つの基本的な実装方法を押さえておけば、他のコンポーネントを作るときに応用が効くのでオススメです。

サンプルコードはこちらから取得いただけます。

モーダル

完成イメージ

おおまかな設計概要

モーダルというと画面全体を薄暗い半透明レイヤーで覆い、その前面に表示される小さなウィンドウのことだと思われがちですが、それはあくまで『ダイアログウィンドウ』です。モーダルとは『それまでの操作を遮る』という意味であり、ここでは画面全体を半透明レイヤーで覆って何かしら別の操作を促すというものが該当します。

よって今回作るモーダルは画面全体を半透明レイヤーで覆うだけで、前面に表示させたい要素は全て呼び出し側から引数として渡せるように実装します。もちろん従来のようにダイアログウィンドウまでコンポーネント側に作ってしまっても良いですが、用途やデザインが固定されてしまうため今回の設計の方が使い勝手が優れています。

型定義

Sources
名前 概要
props content$ Observable モーダルの前面に表示する DOM 要素
visiblity$ Observable モーダルを表示するかどうか
Sinks
名前 概要
DOM Observable レンダリングした仮想 DOM

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

type Sources = {
  props: {
    content$: Observable<VNode>
    visibility$: Observable<boolean>
  }
}
type Sinks = {
  DOM: Observable<VNode>
}

I / O の名称は Cycle.js のお作用に則って Sources / Sinks とします1)特にそうしろとは明文化されていませんが、これはこれで筋は通っているので個人的には問題ないと思います。。Sources の中に props というオブジェクト形式のプロパティを定義し、その中に上記のプロパティを持たせます。

ベース部分を実装

次にコンポーネント本体を実装します。

export function ModalComponent({props}: Sources): Sinks {
  const vdom$ = props.content$.map(content => {
    return div('.panel', [
      div('.panel__content', [content])  // 表示させたい要素がここに展開される
    ]);
  });
  return {
    DOM: vdom$
  };
}

content$ を受け取ってモーダルコンポーネント内に取り込み、仮想 DOM 構造を生成しています。そしてその結果を最後に Sink ( 返す ) すれば OK です。ここまではなんてことはありませんね。

ただしこのままでは常にモーダルが表示されてしまうので、もう一つのプロパティである visibility$ を使って表示 / 非表示を制御します。先ほどのコードを以下のように書き換えます。

export function ModalComponent({props}: Sources): Sinks {
  const vdom$ = Observable.combineLatest(
    props.visibility$.startWith(false),
    props.content$,
    (visible, content) => {
      return div('.panel', {
        class: {
            'panel--visible': visible
        }
      }, [
        div('.panel__content', [visible ? content : null])
      ]);
    }
  );
  return {
    DOM: vdom$
  };
}

content$visibility$combineLatest オペレータでまとめてから仮想 DOM 生成を行います。表示 / 非表示は CSS 側で定義し、そのクラスを snabbdom で動的に付け外すことで実現します。これで8割型完成しました。

ちなみに 仮想 DOM の生成部分は何かと肥大化しやすいので、下記のように関数を分割しておくとコードの可読性が向上します。

function render(visible: boolean, content: VNode): VNode {
  return div('.panel', {
    class: {
      'panel--visible': visible
    }
  }, [
    div('.panel__content', [visible ? content : null])
  ]);
}
export function ModalComponent({props}: Sources): Sinks {
  const vdom$: Observable<VNode> = Observable.combineLatest(
    props.visibility$.startWith(false),
    props.content$,
    (visible, content) => render(visible, content)
  );
  return {
    DOM: vdom$
  };
}

呼び出し側を実装

先ほどのコンポーネントを main 関数から呼び出します。ボタンをクリックするとモーダルが表示し、モーダルウィンドウ内のクローズボタンを押すと非表示になるようにします。

function main(sources: Sources): Sinks {
  const modal = ModalComponent({
    props: {
      // モーダルの前面に表示する DOM 要素
      content$: Observable.of(
        div('.container', [
          div('.row', [
            div('.col-sm-6.col-sm-offset-3', [
              div('.panel.panel-default', [
                header('.panel-heading', [
                  button('#dialog-close.close', [
                    span('×')
                  ]),
                  h4('.panel-title', 'Dummy text')
                ]),
                div('.panel-body', [
                  p('Vivamus suscipit ...')
                ])
              ])
            ])
          ])
        ])
      ),
      // モーダルを表示するかどうか
      visibility$: Observable.merge(
        sources.DOM.select('#dialog-open').events('click').mapTo(true),
        sources.DOM.select('#dialog-close').events('click').mapTo(false)
      ).startWith(false)
    }
  });
  return {
    DOM: Observable.from(modal.DOM).map((modalDOM) => {
      return div('.container', [
        h2('.page-header', 'Modal'),
        div([
          button('#dialog-open.btn.btn-default', 'Open'),
          modalDOM,
        ])
      ])
    })
  };
}
const drivers = {
  DOM: makeDOMDriver('#app')
};
run(main, drivers);

ModalComponent 関数を呼び出して content$visibility$ を引数に渡します。content$ には前面に表示したい要素を VNode で渡し、visibility$ にはそのモーダルを表示するかどうかのフラグを渡します。フラグは二つのボタンのイベントを merge した結果を返します。

isolate を使って複数扱えるようにする

複数のモーダルを同時に表示することはまず無いと思いますが、同一画面上で複数『種類』を表示させることはあるかと思います。その場合に対応させるには isolate を使えば OK です。

⋮
function Component({props}: Sources): Sinks {
  const view$: Observable<VNode> = Observable.combineLatest(
    props.visibility$.startWith(false),
    props.content$,
    (visible, content) => render(visible, content)
  );
  return {
    DOM: view$
  };
}
export function ModalComponent({props}: Sources): Sinks {
  const component = isolate(Component);
  return component({props});
}

コンポーネント処理の実態を Component 関数に隠蔽し、ModalComponent関数はそれを isolate で呼び出して返すだけす。これで複数対応が出来ました。

⋮
function main(sources: Sources): Sinks {
  const modal1 = ModalComponent({
    props: {
      content$: Observable.of(
        ⋮
      ),
      visibility$: Observable.merge(
        sources.DOM.select('#dialog-open1').events('click').mapTo(true),
        sources.DOM.select('#dialog-close1').events('click').mapTo(false)
      ).startWith(false)
    }
  });
  const modal2 = ModalComponent({
    props: {
      content$: Observable.of(
        ⋮
      ),
      visibility$: Observable.merge(
        sources.DOM.select('#dialog-open2').events('click').mapTo(true),
        sources.DOM.select('#dialog-close2').events('click').mapTo(false)
      ).startWith(false)
    }
  });
  return {
    DOM: Observable.combineLatest(
      modal1.DOM,
      modal2.DOM,
      (modalDOM1, modalDOM2) => {
        return div('.container', [
          h2('.page-header', 'Modal'),
          div([
            div('.btn-group', [
              button('#dialog-open1.btn.btn-default', 'Open 1'),
              button('#dialog-open2.btn.btn-default', 'Open 2'),
            ]),
            modalDOM1,
            modalDOM2,
          ])
        ])
      }
    )
  };
}

次のページでは『カウントダウンタイマー』を作ってみます。

脚注

脚注
1 特にそうしろとは明文化されていませんが、これはこれで筋は通っているので個人的には問題ないと思います。