redux への 不満を解消する為に, flumptというFlux実装を作った

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

社内のハックウィークで作った。
https://github.com/mizchi/flumpt

npm install flumpt --save

名前はダークソウルのフラムト(frampt)から。FLux Minimum なんたらかんたら。

なんかTwitterで色々言ってたら naoyaさんにまとめられたので、ここに僕の考えを詳しく書いておく。
mizchi の Redux 考 - Togetterまとめ

やりたかったこと

基本的なアイデアは、stateをpushする方式(setStateみたいな)だと非同期間で参照したときの値がズレて非同期の終わる順番次第では状態の遷移ステップが壊れてしまう可能性があるんだけど、前のstateをとって次のstateを返す非同期をキューに並べて順に実行することで、トランザクションみたいなものを保証することができるだろう、というもの。

  • 軽量(pubsubインターフェースはEventEmitterそのまま)
  • 複数の状態更新関数の待ち合わせ
  • reduxで感じた不満の解消
  • TypeScriptフレンドリー

インターフェースは index.d.ts にて

サンプルコード

ほぼ最小なコード

import * as React from "react";
import {Flux, Component} from "flumpt";
import {render} from "react-dom";

class MyComponent extends Component {
  render() {
    return (
      <div> {this.props.counter} </div>
    );
  }
}

class App extends Flux {
  render(state) {
    return <MyComponent {...state}/>;
  }
}

// Setup renderer
const app = new App({
  renderer: el => {
    render(el, document.querySelector("#root"));
  },
  initialState: {count: 0},
});

app.update(_initialState => ({count: 1})) // it fires rendering

概要

  • 1つのstate、stateによって生成される、1つのrender関数
  • flumpt.Fluxを継承してstateから構築されるrender を実装する
  • newしてupdateするとrenderによってつくられたvdomがrendererによって描かれる
  • flumpt.Flux はEventEmitterであり、内部状態に応じてイベントを発火する

実装は160行と非常に薄いので興味ある人はこちらで https://github.com/mizchi/flumpt/blob/master/src/flumpt.js

状態更新をさばく update 関数の実装が全体の半分を占める。

update関数の非同期キューイング

このような状態更新があるとする。

const app = new Flux({renderer: ..., initialState: {n: 0}});
app.update(s => wait(100).then(()=> ({n: s + 1}))); // n = 1
app.update(s => wait(100).then(()=> ({n: s + 2}))); // n = 3
app.update(s => wait(100).then(()=> ({n: s + 3}))); // n = 6

これらはシーケンシャルに実行されるが、描画は一回しか行われないようになっている。

  • 一つ目のupdate関数がpromiseを返していたら待受状態にする
  • 待受状態で次のupdateがきたら実行待ちキューにいれる
  • promiseが終わった時、次の実行待ちキューがあればrenderせずに次のキューを実行する
  • 更新キューがなければ render する
  • update関数はpromiseを返すが、同じキューに入っているupdateのpromiseは同時に終了する。(上の例の1, 2, 3 のupdateが返すpromiseの参照は全て同一)

イベント周りの仕様として

  • 待ち受け状態の開始時には app.emit(":start-async-updating"); を投げる
  • 待ち受け状態解除時には app.emit(":end-anync-updating"); を投げる
  • 待ちキューに入らず同期更新を行うときはイベントを投げない

という実装にしている。(イベント周りはまだドキュメンテーションしてない)

何故このような機構を作ったか

シンタックス上こう書けるようにしたかった、という単純な話ではなく、実際にアプリケーションを作っていると、ユーザー入力と同時に別の更新が同時発火することがある。comonentDidMountから始まる初期化処理や、時間経過によるポーリング処理などがそれに当たる。

UX上、細かい更新が何度も走ると、画面がガクガクと更新され非常に不格好であり、また大きな状態を抱えた時に、仮想DOMのdiffコスト/レンダリングコストが重く、クリック判定がUIスレッドのフリーズに待たされたりする事態が多発した。Reactで描画コストを下げてもこればっかりはどうしようもない。

これを、更新キューを非同期なPromiseキューでfoldしてやって、状態が確定してから一度だけrenderすることで解決した。その為にFlux#updateの型は次のようになっている。

<T> (t: T) => T | Promise<T>

同期更新状態/非同期更新状態の区別

その更新が非同期、非同期でないという状態をユーザー側に伝えることで、非同期中はフォグをかけてUIロックを行ったり、というのが容易になる。(非同期を区別しないと一瞬フォグが出て消える、という気持ち悪いものになるだろう)

reduxへの不満

redux は dispatch(action) => reducer 間で状態が変わってしまうと状態更新のトランザクションが崩れるので、 非同期を受け付けないことになっている。(とどこかのIssueで読んだ)

僕はここに大きな不満を持っていて、現実的に状態更新にまつわるプロパティの取得は非同期で行わることが多い。WebAPIへのアクセスだったり、SPAだとIndexedDbも選択肢に入る。(自分は100ms~300msのWebAPIほどではない、処理時間20~50ms相当のIndexedDxを処理することが多い、という特殊な事情も多少あるのだが)

そのせいでreduxは、dispatchする側のactionCreacter側に大量の状態を抱えることになり、状態の更新を担うはずの reducer がアプリケーションのビジネスロジックを担えない、ということになりがちになる。

flumpt はむしろ reducer 相当の部分で非同期を受け取れるようにしている。終端がpromiseを受け付けることで、これらの問題を解決した。状態更新の方法が、「前の状態を受け取って次の状態を返す関数のキュー」なので、登録時のstateに依存しない。(もちろんupdate関数スコープ外の状態は保証されないが…)

興味が無いこと

  • 状態の分割。reduxでいうreducer。update関数でinとoutで適当にやればいい。

reducerは要は関係があるプロパティのeventとlisterの関心の分離なのだが、結果的にそれらを束ねる関数を1個用意してあげないといけないので、要は結局1枚のjsonを吐いている、ということになる。

flumptはjsonを受けてからの状態更新には興味があるが、その状態が作られる過程には興味が無い。アプリケーションの規模によって適切なパラダイムは変わる。

Ardaとの違い

僕が作った他のフレームワークとしては Ardaがあるんだけど、あれはシーン遷移とそのスタック管理に重きを置いてて、そのせいでコード量が膨れたりAPIが複雑になったりしていた。そのサブセット抽出 + update周りの非同期強化が主。