JavaScript
flux
redux
redux-saga

非同期 + Redux がつらいので Joqt という FW を作った

これは何か

Joqt は Flux アーキテクチャーにおける状態管理を行うための JavaScript フレームワーク。Joqt を使うことでアクションに応じて遷移するステートツリーが手に入る。状態遷移は Promise ベースの非同期関数であってもよい。

特徴として Redux における reducer にあたる部分に async generator function が使え、一つの action に対して複数の状態更新が行える。
またそれらの非同期の reducer の実行は Joqt により排他制御される。
(詳細は後述)

どう使うか

サンプル

以下に Joqt を使用したコードの一部を抜き出した。
コードの全体は joqt-sample リポジトリに置いている。

const { createStore } = require('joqt');

(async () => {
  const store = await createStore([
    {
      type: 'init',
      paths: [''],
      reducer: () => ({
        todos: [],
        light: true
      })
    },
    {
      type: 'ADD_TODO',
      paths: ['todos'],
      reducer: ({ todos }, message) => ({ todos: todos.concat([message]) })
    },
    {
      type: 'CLEAR_TODOS',
      paths: ['todos'],
      reducer: async function*({ todos }) {
        while (true) {
          await new Promise(r => setTimeout(r, 1000));
          todos.shift();
          if (todos.length === 0) break;
          yield { todos };
        }
        return { todos };
      }
    },
    {
      type: 'TOGGLE_LIGHT',
      paths: ['light'],
      reducer: ({ light }) => ({ light: !light })
    }
  ]);

  store.subscribe(() => render(store.getState()));
})();

joqt-sample.gif

Add ボタンではリストに即時反映されるが、Clear ボタンではリストから 1 秒間隔で削除している。Clear 実行中は Add は遅延実行されている。
また、リストに関係のない Light は Clear 実行中であっても即時反映されている。

インターフェース

Redux と似ている。
まずアプリケーションの状態を表現する単一のステートツリーの型を定義する。そのステートツリーを変化させるいくつかの action を定義し、以前までのステートツリーと action を受け取って次からのステートツリーを返す reducer を実装すれば、action が渡されるたびに状態が遷移していく store が作成できる。

Redux と異なるのは reducer に function, async function, generator function, async generator function のいずれかを指定できることだ。
これらは resolve を待って、yield される度にステートツリーを更新する。

非同期処理に伴う競合の問題を解決するため、Joqt は並行処理を直列化する。
競合の範囲を決めるため、reducer がステートツリーのどの部分を扱うかを reducer と共に paths に定義しなければならない。

paths に定義したステートツリーの 一部 しか reducer の引数として受け取れない。また、reducer が更新できるステートツリーも paths に定義した 一部 に制限される。

Joqt は競合の直列化を賢く制御する。
異なる reducer がステートツリーの親子関係を持つ一部を取っていれば直列化するし、親子関係がなければ並行に実行する。

なぜ作ったか

Redux への不満

Redux を使って非同期処理を書いているときにものすごい不毛感があったから作った。

Redux の純粋関数縛りについてその崇高な理由を私は理解できていない。
強力な制約なのでミドルウェア製作者にとっては嬉しくエコシステムは発展しやすそうとは思う。

Redux が純粋関数で予測しやすい状態管理を行っていても、副作用を扱うミドルウェア周りが予測できなければ私達は嬉しくない。
適切なミドルウェアを使って予測可能なまま非同期を扱えるようにすることはできるのだろうが、私達はなんのために純粋関数縛りで reducer を書いているのか……という気分になってしまう。

制御フローの状態も管理したい

今まで Redux を使うとき、私は一緒に redux-promise を使うことが多かった。Promise の前、成功後、失敗後にそれぞれ action を発行することが多く、Promise が状態を管理しており、状態管理とは???と感じていた。
Redux-Saga は使ったことがないが、恐らくよりいっそう制御フローの状態を抱えるだろうと見ている。

制御フローの状態は他に漏れないのであれば内部で管理するのに何も不満はないが、大抵 view に反映する。ローディング中など。
それはアプリケーションの状態に他ならず、状態管理の範疇だと考えた。

言語機能での解決案

Redux-Saga の API を眺めていると面白い仕組みで動いていることはわかった。
眺めている中で「おや、これなら JavaScript の文法でよいのでは?」と思ったところがあったので組み込んだ。

(私には言語機能、特にシンタックスが複雑さ解決することに強い尊敬と憧れがある。言語機能がうまく使えそうならなるべく使いたかった)

どのように動くか

実装順に追っていく方法が説明しやすそうだったので、3つのバージョンでのそれぞれの主要な実装内容を説明する。

v0.1.0 シンプルな Job Queue Tree

(Joqt という名前はここから)

非同期処理を扱いつつ reduce できる、というのは mizchi さんの flumpt に影響を受けた。reducer が promise を返し Promise chain で queue を作ればよい。

問題は Promise chain を1つにしてしまうと、処理したい内容と全く関係のない非同期処理のための待ち時間が発生し、実アプリケーションでは使いづらそうという予想があったことだ。

そこでステートツリーと同様のジョブ待ち用のツリーを作り、そこで Promise chain を扱うようにした。

v0.2.0 賢い直列化

v0.1.0 での Promise chain による直列化はツリーの親子関係を考慮しておらず、reducer tree のリーフノードでしか action を受け取れなかった。

これがどういうことかというと、a と b という状態をそれぞれ扱う reducer があったときに、a と b を同時に扱う reducer が定義できないということだ。

Redux であれば a, b それぞれの reducer が反応すれば良いだけだが、Joqt は非同期という複雑さを持ち込んでしまったため a, b の順序制御が必要となる可能性が高い。

そこでステートツリーの一部を表す path をキーとした Promise chain のマップを作った。path を頼りに親子関係のある reducer 同士は直列になるようにした。

v0.3.0 generator 対応

v0.2.0 を作って Promise でいい感じになったぞ、と満足していたが、よくよく考えると Promise の resolve 後しか状態の更新ができない実装になっており、当初の目的を解決できていなかった。

少なくとも、よくある Promise の前、成功後、失敗後は状態を更新したい。

そこで generator を1つのトランザクションとみなし、generator の done までにも yield によって状態を更新できるようにした。

ちなみに async generator function は 2018/01/01 現在、stage-3 の構文だ。babel か、async を使わずに yield に Promise を渡せば大丈夫だ。

まとめ

  • Joqt 作ったで!
  • みんなを悩ませてる Redux + 非同期へのアプローチやで!!
  • ES2018 の構文を活用するイケてるフレームワークやで!!!
  • 褒めて!!!!