Cycle.js の Drivers を理解する - Cycle.js を学ぼう #5

今回は Cycle.js の『革新的』なアーキテクチャを決定づけている Drivers についてご紹介します。Cycle.js はひとつの web アプリケーションを状態を管理するアプリケーション層 ( main関数 ) と副作用を伴う処理を担うドライバ層 ( Driver 関数たち ) の二つに分割し、これらを一方向のデータストリームで結んで循環させるという設計となっています。つまり Drivers を理解することが出来れば Cycle.js の半分は理解できるということになるわけです ( ※ オレ調べ ) 。

Drivers は副作用を伴う処理を行う

main 関数は基本的にリアクティブ・プログラミングの世界です。引数は Observable として受け取り、その値を様々なオペレータを駆使して加工しながら状態を管理して最終的な結果値を外の世界に放出 ( Sink ) します。その放出された値を受け取るのが Drivers です。Drivers は DOM の描画や HTTP 通信など副作用のある手続き処理をカプセル化 ( 隠蔽 ) したものです。

http://cycle.js.org/drivers.html より引用

main 関数内をリアクティブ・プログラミングな世界に保つことが出来るのは、副作用のある処理を全てカプセル化出来ているからです。これにより、main 関数は同じ値を持つ Sources を引数に取れば必ず同じ結果値を Sink させる事ができるわけです。main 関数内はよりシンプルになり、アプリケーションに関するビジネスロジックにフォーカスすることが出来ます。

Drivers に適しているもの

ひとまず大きなものだと以下が挙げられます。

  • 仮想 DOM のレンダリング
  • HTTP 通信
  • LocalStorage の読み込み / 書き込み
  • ブラウザ履歴の操作
    • history.pushState() / history.popState()
    • history.back() / history.forward()
  • Canvas 操作
  • Flash コンテンツの操作
  • WebAudio や HTML5Audio の操作
  • scroll や resize といったウィンドウ操作関連イベントのフック

仮想 DOM のレンダリングは Cycle.js 公式 Driver である DOMDriver が担います。main 関数側から Sink した仮想 DOM 構造 ( Snabbdom ) を HTML ツリーとしてレンダリング / View として描画する処理は、最も Driver らしいものと言えます。

HTTP 通信や LocalStorage、ブラウザ履歴といったアプリケーションの外部にある『データの置き場』との疎通は、DDD ( ドメイン駆動設計 ) における『インフラ層』に該当します。これらもまたアプリケーションの外側の世界に関することなので、 Driver として定義します。なお、HTTP 通信も Cycle.js 公式 Driver である HTTPDriver が担います。

Canvas、Flash オブジェクト、WebAudio や HTML5Audio などもアプリケーションからは隔離された外側の世界なので、Driver として定義します。

scrollresize イベントは window 操作によって発生するイベントであり、当然これもアプリケーションの外側で発生するものです。よって Driver 側でこれらのイベントをリッスンして値を取得し、任意の処理を行います。

ウィンドウのスクロール値を取得する - はじめての Driver を作ってみよう

公式チュートリアルにある Drivers のサンプルは少々難解なので1)というか正直全く意味不明です… 、より簡単で分かりやすいデモをご紹介します。ウィンドウの現在のスクロール値を取得し、また任意の値を指定するとその位置までスクロールするというものです。非常に単純な機能ですが、これで Drivers の仕組みをあらかた理解することが出来ます。

こちらから実際の動きをご覧いただけます。

See the Pen Cycle.js - Scroll Driver by wakamsha (@wakamsha) on CodePen.

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

1. Driver の書き方の基本

まずは Driver の雛形を書いていきます。

export function makeScrollDriver(options) {
  // オプションオブジェクトから値を抽出します
  const {element, duration}: {element: HTMLElement, duration: number} = options;
  // Driver の実態を宣言します
  function ScrollDriver() {}
  // Driver の実態を返します
  return ScrollDriver;
}

まだ何もありませんが、ひとまず Driver の雛形が出来ました。Driver はその実態である関数を返すファクトリ関数として定義します2)慣習としてファクトリ関数には Driver の実態名にmakeという接頭辞を付けます。。Driver は幾つかのオプションを受け入れるファクトリ関数内で作成します。ここで渡されたオプションを用いて Driver の実態を設定・作成し、最後にそれを返します。

例えば Cycle.js 公式の DOMDriver はアプリケーションの展開先である HTML 要素 ( CSS セレクタ ) を引数として渡す必要があります。


2. main からの出力 ( Sinks ) を受け取る

続けて雛形にスクロール処理を付け加えましょう。以下のようにコードに追記します。

export function makeScrollDriver(options) {
  const {element, duration}: {element: HTMLElement, duration: number} = options;
  function ScrollDriver(sink$): Subject<string> {
    Observable.from(sink$)
      .subscribe(
        (offsetTop: number) => window.scrollTo(0, offsetTop)
      );
  }
  return ScrollDriver;
}

ScrollDriver() は main 関数からの出力( Sink$ ) を引数として受け入れます。これを元に subscribe 内で副作用のある処理を行い、ここではウィンドウをスクロールさせる処理を行います。

2017年2月10日リリースのバージョンから Driver が引数に取る Sink$ は xstream の Stream というクラスになりました3)RxJS で言うところの
Observable に相当。
。main 関数からの出力時点では RxJS であっても Cycle.run() を経由する中で強制的に xstream とされているため、そのままでは RxJS のオペレータを使うことが出来ません。そのため RxJS ベースで書くには5行目にあるように Observable.from(sink$) と書いて RxJS の Observale クラスに強制変換してあげる必要があります。

非常に小規模ですが、初めての Driver が出来ました。これをアプリケーション層から呼び出してみましょう。main 関数を作成します。

type Sources = {
  DOM: DOMSource;
}
type Sinks = {
  DOM: Observable<VNode>
  Scroll: Observable<number>
}
function main(sources: Sources): Sinks {
  // Intent
  const input$: Observable<Event> = sources.DOM.select('.scrollable__input').events('input');
  // Model
  const offsetTop$: Observable<number> = Observable.from(input$)
    .map((ev: Event): number => Number((ev.currentTarget as HTMLInputElement).value));
  // View
  const vdom$ = Observable.combineLatest(
    offsetTop$.startWith(0),
    (offsetTop) => {
      return div('.scrollable', [
        input('.scrollable__input.form-control', { attrs: { type: 'number', value: offsetTop } })
      ]);
    }
  );
  return {
    DOM: vdom$,
    Scroll: offsetTop$
  };
}
const drivers = {
  DOM: makeDOMDriver('#app'),
  Scroll: makeScrollDriver({ element: document.body, duration: 600 })
};
run(main, drivers);

input 要素に入力した数値 ( offsetTop$ ) を Sinks として出力します。入力する度に値が ScrollDriver に流れていき、subscribe 内の処理が実行されます。makeScrollDriver()Cycle.run()の引数にその実行結果を渡します。実行時に{element, duration} というオプションを渡すことで ScrollDriver の設定を行っています。

ここまでで Cycle.js のイベントストリーム循環のうち半分 ( アプリケーション層 => ドライバ層 ) が出来上がりました。

3. Driver からの出力 ( Source ) を main 関数側で受け取る

今度は Driver からアプリケーション層に値を渡す仕組みを実装します。以下のようにコードに追記します。

function makeScrollDriver(options) {
  const {element, duration}: {element: HTMLElement, duration: number} = options;
  function ScrollDriver(sink$): Subject<string> {
    const source$ = new Subject();
    window.addEventListener('scroll', () => {
      source$.next(`${window.scrollY}px`);
    });
    Observable.from(sink$)
      .subscribe(
        (offsetTop: number) => window.scrollTo(0, offsetTop)
      );
    return source$;
  }
  return ScrollDriver;
}

ここではウィンドウが実際にスクロールした値を出力しています。出力は Subject クラスの next()にて行います。最後に Subject インスタンスを戻り値として return し、これを main 関数が受け取ることで Driver からの出力を受け取ることが出来るというわけです。これで Driver の基本的な実装は完了しました。

続けて main 関数側でこの出力を受け取れるようにします。

type Sources = {
  DOM: DOMSource;
  Scroll: Observable<string>
}
⋮
function main(sources: Sources): Sinks {
  // Intent
  const input$: Observable<Event> = sources.DOM.select('.scrollable__input').events('input');
  // Model
  const offsetTop$: Observable<number> = Observable.from(input$)
    .map((ev: Event): number => Number((ev.currentTarget as HTMLInputElement).value));
  // View
  const vdom$ = Observable.combineLatest(
    sources.Scroll.startWith('0px'),
    offsetTop$.startWith(0),
    (scroll, offsetTop) => {
      return div('.scrollable', [
        input('.scrollable__input.form-control', { attrs: { type: 'number', value: offsetTop } }),
        p('.scrollable__counter', scroll)
      ]);
    }
  );
  return {
    DOM: vdom$,
    Scroll: offsetTop$
  };
}
⋮

ScrollDriver からの戻り値は Subject クラスのインスタンスです。Subject は値を流す Observable であると同時に値を受け取れる Observer でもあります。Driver からの戻り値は全て main 関数の引数として受け取ることが出来るので、上記のように値を参照することが出来るようになります。

これで Driver は完成です。ついでに以下のコードを ScrollDriver 内に追加してスクロール時にアニメーションさせてみましょう。

const scrollTo = (element, to, duration = 600) => {
  if (duration <= 0) return;
  const difference = to - element.scrollTop;
  const perTick = difference / duration * 10;
  setTimeout(() => {
    element.scrollTop = element.scrollTop + perTick; 
    if (element.scrollTop === to) return;
    scrollTo(element, to, duration - 10);
  });
};

締め

Driver は入出力部分こそ Observable ですが、副作用処理は従来の見慣れたロジックで記述出来るため、Cycle.js のなかでは比較的習得が容易な領域かと思います4)僕個人の見解です。 。リアクティブプログラミングはとても洗練されたアーキテクチャですが、こと web フロントエンドはどうしても副作用を伴う場面がいたるところにあります。それならばそういうところは思い切ってリアクティブな世界から隔離してしまうことで状態管理などモデル層は Observable ベースのアプリケーション層で副作用とは無縁のコードを書き、DOM操作やインフラ層の操作など副作用を伴う処理はドライバ層に書くことで双方クリーンなコードに仕上げることが可能となります。

Cycle.js 自体は非常に小さく薄ーいフレームワークですが、Driver を追加することで無限に拡張することが出来るのです。

脚注

脚注
1 というか正直全く意味不明です…
2 慣習としてファクトリ関数には Driver の実態名にmakeという接頭辞を付けます。
3 RxJS で言うところの
Observable に相当。
4 僕個人の見解です。