Redux っぽく Rx で実装しようとして結果的に Cycle.js に惹かれる話

  • 52
    いいね
  • 4
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

  • Flux のような unidirectional なアーキテクチャに興味があって、評判のよいものに触ってみた感じです。
  • 実際に業務でやってみたことはないので、ガチ勢の方から見たらまだまだ甘いと思われる点が多々あるかもしれません。

TL;DR (投稿から一日たって少し考えがまとまってきたので追記)

Redux の印象 before

  • 思想は分かりやすい (Three Principles)
    • アプリケーションの状態 (state) を単一の store で管理
    • state は read-only (直接更新できない), 更新は action の発行を介して行う
    • state を更新する reducer は純粋な関数
  • でも action.type で switch する書き方が好きになれない
    • Rx で簡単に re-implement できるとのことなので、 switch なしで書けるようにちょっとやってみよう

Redux の印象 after

  • 非同期処理が実装しづらい
    • 公式のドキュメントにある、 action の中で dispatch するやり方は、同期/非同期の action で処理の内容がかけ離れてしまって、「結局 action て何なの?」というところがよく分からなくなってしまう
    • reducer により state が更新された結果、サーバーサイドの API へのリクエストが必要になりました、というような場合、何をトリガーとしてリクエストを実行すればいいのか?このとき、「ボール」は view が持っている状態だが、 view がユーザーの操作によらない action をトリガーするのは違和感がある。
    • [2015/11/24] コメントをいただいて、勘違いがあったことが分かったので修正。 component == view ではないので、このような処理は view でない component にやらせればよい。

それに対して Cycle.js では

  • HTTP request を、 DOM と同様に扱う
    • どちらも副作用がある
    • どちらも (アプリケーションから見て) 非同期にレスポンスが返ってくる
    • 違いは、サーバーに対するインターフェースか、ユーザーに対するインターフェースか
  • そのような、外部に対して副作用を及ぼすような処理は、 Drivers と呼ばれるモジュールで実装する
    • HTTPDriver, DOMDriver
  • Redux っぽく作ろうとしていたアプリケーションに、この考え方を取り入れて非同期処理を実装したら、とてもしっくりくるようになった
    • Polymer とかだと、 <foo-api></foo-api> みたいに DOM っぽく書けるけど、 DOM としてはレンダリングされずにただサーバーサイドの API 実行を担うコンポーネントを作るような発想があった気がする (なかったでしたっけ?) ので、そういうのでもよさそう
    • [2015/11/24] 上で書いたように Redux にもある。ただし、例えば HTTP request を送信するためにそのような component を使うのであれば、 HTTP request, response の処理まで component 内で処理するほうが、 view として使われる component と責務が揃っていて分かりやすいと感じる (下表参照)。同期/非同期の action で処理内容がかけ離れるという問題も無くなる。
component type 外部への output 外部からの input の処理
view DOM のレンダリング DOM に対するユーザー操作を受けて action を実行
http HTTP request の送信 HTTP response を受けて action を実行

なんだか Redux dis みたいになってしまいましたが、 Redux を愛用されている方々がこのあたりのことをどう考えているのかはとても興味があるので、ご意見いただければ幸いです。

作ろうとしたもの

実装方針

Redux っぽく作ろう

Redux っぽいとは?

  • Redux は、現時点で Flux フレームワークの中で一番人気がありそう。
  • しかし、個人的に (本家 Flux 以来) action.typeswitch するところがあまり好きになれない。
    • こんなの↓
function todoApp(state = initialState, action) {
  switch (action.type) {
    case SET_VISIBILITY_FILTER:
      return Object.assign({}, state, {
        visibilityFilter: action.filter
      })
    case ADD_TODO:
      return Object.assign({}, state, {
        todos: [
          ...state.todos,
          {
            text: action.text,
            completed: false
          }
        ]
      })    
    default:
      return state
  }
}

引用元: http://rackt.org/redux/docs/basics/Reducers.html

  • 「Redux は Rx で簡単に再実装できるよ!」みたいなことが本家のドキュメントに書いてある。

The question is: do you really need Redux if you already use Rx? Maybe not. It’s not hard to re-implement Redux in Rx. Some say it’s a two-liner using Rx.scan() method. It may very well be!

引用元: http://rackt.org/redux/docs/introduction/PriorArt.html

  • ならば、 siwtch なしで実装できる Redux っぽいものを、 Rx で作ってみよう。 Rx の勉強にもなるし。
  • ちなみに
    • 最初から、オチに出てくる Cycle.js に触ってみるという案もありました。
    • ただ、 Cycle.js は DefinitelyTyped に型定義がなく、自分で書いてみようともしたのですが、わりと早い段階で挫折。
    • 最近は TypeScript でないと JS が書けない体になっているので、ひとまず使わないことに。

まずは TodoMVC

reducer

  • TypeScript の "Overload on constants" の機能を利用し、 action の名前ごとに型を定義しつつ reducer を作る。
  • 下のように、 switch のかわりに for(actionType).register(reducer) のような形式で reducer を登録していく。
  • Redux の reducer の基本的なルールとして、引数で与えられた state を変更してはならないというのがあり、それには当然則る。

TodoReducers.ts

export interface TodoReducers {
  // 'create' という action の payload は {text: string}
  for(action: 'create'): TodoReducerAccesor<{text: string}>;
  // 'complete' という action の payload は {index: number}
  for(action: 'complete'): TodoReducerAccesor<{index: number}>;
  // ...以下略
}

export namespace TodoReducersFactory {
  export function create(): TodoReducers {
    // Register reducers
    return (<TodoReducers>new Reducers<TodoState>())
    .for('create').register(({text}, state) => {
      const _text = text.trim();
      return _text === '' ? state : [...state, new Todo(false, _text)];
    })
    .for('complete').register(({index}, state) =>
      // todo.setCompleted(true) は、指定された completed を持った新しい todo のインスタンスを生成する副作用のないメソッドなので、引数の state は更新されない
      state.map((todo, _index) => _index === index ? todo.setCompleted(true) : todo) 
    )
    // ... 以下略
  }
}

export default TodoReducers;

action

  • Redux というよりは Flux の action っぽい感じになっている。
  • 各 action 用の reducer を取り出して payload を部分適用 (内部的には Function.prototype.bind を利用) し、 Rx.Observer.prototype.onNext に渡す。
  • actions の中で reducer を取り出すのは少し微妙なのですが、 "Overload on constants" の機能で action ごとに型を解決するためにこうせざるを得ない感じです><

TodoActions.ts

export default class TodoActions {
  private subject = new Rx.Subject<(state: TodoState) => TodoState>();
  observable = this.subject.asObservable();
  constructor(private todoReducers: TodoReducers) {
  }
  create(text: string): void {
    this.subject.onNext(this.todoReducers.for('create').applyPayload({text}));
  }

  toggleComplete(index: number, todo: Todo): void {
    if (todo.isCompleted()) {
      this.subject.onNext(this.todoReducers.for('undoComplete').applyPayload({index}));
    } else {
      this.subject.onNext(this.todoReducers.for('complete').applyPayload({index}));
    }
  }
  // ... 以下略
}

store

  • 本家のドキュメントにあったように、 Rx.Observable.prototype.scan で (payload が部分適用済みの) reducer を使って state を更新。
  • Rx.Observable.prototype.scan は、 Array.prototype.reduce のようなイメージで、 observable の stream 一つ一つから state を更新するための関数を作ってこれを順次適用していく。
    • Rx.Observable.prototype.reduce という関数もあるのですが、これは stream が完了した最終結果のみを後続の observer に渡すもので、途中経過を随時取得するには scan を使う必要があります。

Stores.ts

export default class Store<State> {
  observable: Rx.Observable<State>;
  constructor(
    observable: Rx.Observable<(state: State) => State>,
    initialState: State
  ) {
    this.observable = observable.scan(
      (state, partiallyAppliedReducer) => partiallyAppliedReducer(state),
      initialState
    ).startWith(initialState);
  }
}

TodoStores.ts

import Store from './store';

export default class TodoStore extends Store<TodoState> {
}

export class Todo {
  // ... 略
}

export type TodoState = Todo[];

view

  • React で実装。
  • root となるコンポーネントで、以下のように reducers, actions, store のインスタンスを生成し、 store の state の更新を observe する。

TodoApp.tsx

export default class TodoApp extends React.Component<{}, {state: TodoState}> {
  private actions: TodoActions;

  constructor(props: {}) {
    super(props);

    const reducers = TodoReducersFactory.create();
    this.actions = new TodoActions(reducers);
    const store = new TodoStore(this.actions.observable, []);

    store.observable.subscribe(
      (state) => this.state ? this.setState({state}) : this.state = {state}
    );
  }

  render() {
    // ... 略
  }

TodoMVC まとめ

  • reducer があるというだけでそんなに Redux っぽくないというご批判もあるかと思います。すみません。
    • 特に action のところは、 action.type で switch するのを回避した代わりに、また別の微妙さを持ち込んでしまった感。
  • 全体の流れは以下のような感じ。
                    Reducers
                      ^ |
                      | |
                      | | fetch reducer
                      | |
                      | v   observe
                --> Actions <------ Store <--
imperative call |                           | observe
                ----------- View ------------

続いて Flux Challenge

要件

  • 詳細な要件はこちらの README にあります
  • ポイントは、
    • サーバーサイドの API を実行する (TodoMVC は、サーバーへのリクエストがない)
    • ユーザーの操作等に伴って、 API の実行をキャンセルしなければならない場合がある

reducer

  • TodoMVC のとき同様なので、割愛

store

  • こちらも TodoMVC のときと同様だが、上記の要件を満たすため、リクエストを状態として保持しなければならない

SithLordsStore.ts

export interface SithLordsState {
  currentPlanet: string;
  sithList: Array<Sith | SithToBeFetched | EmptySith>;
  onGoingRequests: {[url: string]: JQueryXHR}; // 現在実行中のリクエスト
}

export default class SithLordsStore extends Store<SithLordsState> {
  // ... 略
}

action

  • Redux の場合、非同期はここで処理する
  • 本家ドキュメントに記載の例
    • 下記のコードのうち、 requestPosts, receivePosts は同期な action で、 fetchPosts は非同期な action
    • dispatch は action を発行するためのメソッドで、同期の場合は view から dispatch(requestPosts) などとして使う。非同期のときにこのメソッドを action の中で呼び出しているの、個人的にはかなり違和感があるのですが、どうなんでしょう??
import fetch from 'isomorphic-fetch'

export const REQUEST_POSTS = 'REQUEST_POSTS'
function requestPosts(reddit) {
  return {
    type: REQUEST_POSTS,
    reddit
  }
}

export const RECEIVE_POSTS = 'RECEIVE_POSTS'
function receivePosts(reddit, json) {
  return {
    type: RECEIVE_POSTS,
    reddit,
    posts: json.data.children.map(child => child.data),
    receivedAt: Date.now()
  }
}

export function fetchPosts(reddit) {

  // Thunk middleware knows how to handle functions.
  // It passes the dispatch method as an argument to the function,
  // thus making it able to dispatch actions itself.

  return function (dispatch) {

    // First dispatch: the app state is updated to inform
    // that the API call is starting.

    dispatch(requestPosts(reddit))

    // The function called by the thunk middleware can return a value,
    // that is passed on as the return value of the dispatch method.

    // In this case, we return a promise to wait for.
    // This is not required by thunk middleware, but it is convenient for us.

    return fetch(`http://www.reddit.com/r/${reddit}.json`)
      .then(response => response.json())
      .then(json =>

        // We can dispatch many times!
        // Here, we update the app state with the results of the API call.

        dispatch(receivePosts(reddit, json))
      )

      // In a real world app, you also want to
      // catch any error in the network call.
  }
}

引用元: http://rackt.org/redux/docs/advanced/AsyncActions.html

  • 「違和感がある」などと言ったものの、ひとまず Redux と同様に作ろうとしてみる

ところが

  • 困ったことになる。
  • 正確には、 actions を作る段階では特に困らなかったのだが、 view とつなぎこむ段階で困る。
  • 何に困ったのか、文章だけで正確に伝えられるか自信がないですが、以下に説明します。

Flux Challenge のアプリで、 API へのリクエストが発生する流れ

  1. ユーザーが、画面に表示された一覧に対して、 scroll up/down のボタンを押して上下にスクロールしようとする
  2. view から scroll up/down の action を発行 (実行) する
  3. reducer で、 scroll 後の新しい状態を生成する (scroll により新たに表示される行は最初は空となる)
  4. store の state が更新され、それに伴って view も更新される
  5. このタイミングで、空の行の情報を取得するためのリクエストを実行する必要がある

で、 5 で実行しなければならないリクエストって、どこからどうトリガーすればいいの?

  • 5 の時点では、「ボール」は view にある状態なので、 Flux 的には view から非同期な action を実行することになる??
  • でもそれって気持ち悪くないですか?
    • view はユーザーとのインターフェースであるべきなのに対し、 5 の処理は単体では完全にアプリケーションの内部的な処理。
    • そのような処理を view の責務としてしまうのはかなりの違和感。

Cycle.js 的なソリューション

  • 外部への副作用を持つという点で、 HTTP request と DOM は、アプリケーションから見て同様のものとして捉える。
  • HTTP request を担うモジュールを新たに作る。
    • 5 の処理はこのモジュールの責務。
    • API の実行も、 action からではなくこのモジュールから行う。
    • 結果、 action から非同期処理は消え、実装は TodoMVC のときと同様に。 (なのでコードは割愛)

http

  • 上記で紹介した、 HTTP request を担うモジュール。
  • 概要としては以下のような感じ。 (コードは少し長いので割愛します)
    1. store の state の更新を監視し、更新された state の内容に応じて、新たに API を発行したり、既存のリクエストをキャンセルしたりする。
    2. API のレスポンスが返ってきたら、それに応じた action を実行する。

view

  • 基本的には TodoMVC と同様だが、 http モジュールのインスタンスの生成を追加。
  • view と http の関係を考えると、 view のコンストラクタで http のインスタンスを生成するのは違和感があるが、 React の場合厳密には render メソッドの中身が view であって、 root となるコンポーネントのコンストラクタは、そのコンポーネント全体の初期化を担っていいはず、という考え方。

SithLordsUI.tsx

export default class SithLordsUI extends React.Component<{scrollSize: number}, SithLordsState> {
  private actions: SithLordsActions;

  constructor(props: {scrollSize: number}) {
    super(props);

    this.actions = new SithLordsActions(SithLordsReducersFactory.create());
    const store = new SithLordsStore(this.actions.observable, initialState);
    const http = new SithLordsHttp(this.actions, store);

    store.observable.subscribe((state) =>
      this.state ? this.setState(state) : this.state = state
    );
  }

Flux challenge まとめ

  • 全体の流れは以下のような感じ。
                      Reducers
                        ^ |
                        | |
                        | | fetch reducer
                        | |
                        | v   observe
                ----> Actions <------ Store <----
                | |                           | |
imperative call | ----------- View ------------ | observe
                |                               |
                ------------- HTTP --------------

感想

  • Redux っぽいものを Rx で実装してみようと思って初めてみたら問題にぶつかり、結局 Cycle.js の考え方分かりやすくていいな、というところに落ち着いた。
    • HTTP request と DOM を同様に扱う。
    • 違いは、前者がサーバーに対するインターフェースで、後者がユーザーに対するインターフェースであること。
  • そもそも Flux challenge 自体 Cycle.js の作者が主催なため、このような感想を持つに至ったのは彼の術中にはまった感はある。
    • 彼は Flux は非同期処理に弱いと考えていて、 Flux ユーザーがどのようにこの問題に対処するかを見るためにこのお題を考えたらしい。
    • 詳細は https://github.com/staltz/flux-challenge#purpose
    • まあでも確かに、 Flux や Redux の非同期処理ってきれいじゃないなあと思う。
  • Cycle.js への興味が深まるが、自前の実装も今のところそんなに悪い気がしないので、今すぐ触らなくてもいいかなという感じ。
    • Virtual DOM と Rx への依存だけで作れているところがいい。