React/Fluxにおける問題とReducerが切り開く道

  • このエントリーをはてなブックマークに追加

      2015/11/06

私がReact/Fluxアプリケーションを書いてきて、もう1年になる。Flux開発の1年を振り返ってみると、このFluxというものが便利だと、はっきり言うことができる。過去は「こんなイベントチェーンなんて触ってないよ」という日々だった。取り組んできた全てのFluxコードベースは綺麗だったし、デバッグ可能で、メンテナンスも容易にできた。

Fluxに関しては、インタラクティブな金融チャート、リアルタイムのテーブル絞込み、いつものCRUD系のものから複雑な非同期要求チェーンにいたるまで、遭遇してきたあらゆる問題を解決することができた。Flux最大の長所の一つは、その普遍性だ。絶対的に簡潔で美しくなることは決してないが、決してハッキングすることもない。大規模コードベースにおいては良いことだ。複雑なUIに取り組む際にも、同じ考え方を当てはめると非常に効果的となる。

良いことづくめ?

もちろん、そんなことはない。思い通りに動くFluxコードを書くのは難しすぎると、私も再三気づかされてきた。問題はいつも同じだ。

あるアクションを起こすと、残念ながら、それが複数ストアへの書き込みをトリガしてしまう。そして一つのストア内のどこかで、そのアクションへの処理を忘れてしまうか、またはその他どこかのストアでwaitFor(実行可能までの待機)をしそびれてしまう。これで、ほぼいつもテストスイートを掻い潜ってしまうし、コードレビューをすり抜けてしまうこともたびたび起こる。これは大抵、極めて特殊なケースなので、恐らく製作中にミスがうまく解決されることはないだろう。特にwaitForは非常にややこしくなりがちだ。これに関する問題を持ち掛ける人は他にもいる。だが、waitForなしでも、複雑な記述にはたびたびバグが発生する。

アクションにトリガされた複雑な状態更新は、FluxとReact関連で事実上最も避けるべきことだ。

リデューサーは問題を解決してくれない

最近、「ステートレス・ストア」、別名「リデューサー (“Reducers”)」がReactコミュニティでヒットしている。この「リデューサー」は、「The Evolution of Flux Frameworks(Fluxフレームワークの変遷)」redux(Fluxのフレームワーク)ライブラリの著者であるDan Abramov氏によって有名になった。その他、Tomas Weiss氏がリデューサーについて書いた記事もある(あなたがFluxのリデューサーをよく知らないなら、今リデューサーについてざっと読んでからこの記事に戻ってきてほしい)。

私はリデューサーのコンセプトがすごく好きだ。エレガントで、標準のストアインスタンスより多くの点において優れている。しかし実装された例を見てみると、上の方で私が述べた欠点は、部分的ではあるがなくなってしまった。

それがどういうことか説明するために、なぜwaitForに問題があるのか明確に述べてみよう。

開発者としては、状態の更新順序を次から次へと明確に宣言したい。多重モジュールに宣言の順番を分割したくない。waitForのやり方では、「これはこれの前に起こるべきだ」と常に考える必要があり、骨が折れる。ステートメントを一行下に書くだけでは演算順序を宣言できないのは非常に残念なことだ。(皮肉なことに、それはまさにFluxがすべての更新を極小のアクションや、同期処理が必要なアクションに分けてしまう理由だ)

例えばReduxにおいては、リデューサーを使うことでこれが可能になるのだが、ここでは完全な解決策にはならない。

私は直感的に状態の更新順序を宣言したが、それだけでは足りない。
開発者としては、アクションをトリガするあらゆる状態の更新を概観したいのだ。一か所のコードで、各行ごとに。
コード内のどこかに包括的な概要を持っておくことは、正常な状態更新を行える可能性を最大化する。書き込み条件の順序が重要視される場面では特に!

これまで私が見てきたあらゆるFluxの実装においては、アクションタイプによってグレップしなければならず、複雑な書き込み条件を理解するために、恐らく複数のファイルに目を通さなければならない。まったく気にかけていないアクションにも目を通す必要があるだろう。開発者にとってもコードレビューをする人にとっても、そんな最低なことは尚更したくない。私のプロジェクトでは、ミスの大きな原因は、分散されて書かれたアクションだった。

今日現在のFluxがストアまたはアクション関係マトリックスを間違った軸上に分けているのだと思う。つまり、状態の一部に作用するあらゆるアクションは容易にわかるが、一つのアクションが作用する状態をすべて見つけ出すのは難しい。

私の意見では、その逆であるべきだ。

当然のことながら、開発者は何よりもまず、極小の観点から考えるものだと思う。彼はアクションを次から次へと実装しながら、そのアクションがどの状態の更新を引き起こすかを考えている。もし私が、どのアクションが状態のどの時に作用するかを調べることができる場合、あなたはそれほど得るものがない。しかし、あなたのアクションが状態にどのような更新をしたのか、ぜひとも全体を見渡したくはないだろうか?それは機能を実装している時やコードレビューの時には尚更、目に飛びついてくるはずのものだ。

それは、私がこれまでに見てきたどのFluxアプリにもないだろう。書き込み条件にビジネスロジックが含まれているため、誰もが小規模なストアまたはリデューサーを書く傾向がある。整理された状態を保つために、コードを小さい別々のモジュールに分けたいと思うだろう。小規模な状態処理モジュールというのは、いくつかのアクションがそのモジュール内の多くで処理されることを意味し、あなたは、「このアクションは何を徹底的に起こすのだろう?」とたびたび自分に問いかけることになる(たとえ書き込む順序はどうでもいいとしても)。

私の見てきた限り、皆小さな状態の一部分を処理するリデューサーを書くが、それは同時に、昔からのFluxのストアのように、可能な限りありとあらゆるアクションでもある。例えばあなたがアプリにToDoリストを持っているとしたら、todoの状態部分に影響する全てのアクションを減らすために、どこかでtodo(state, action) { … }と宣言するだろう。

私の質問はこうだ。

なぜ?

FacebookのFluxサイトが、その理由を説明している:

MVCアプリケーションでは、モデル間の連鎖更新によって不安定な状態となり、正確なテストが非常に困難になることがあるが、ストアに自動更新をまかせると、MVCアプリケーションにありがちな多くのもつれを取り除いてくれる。

まあ、リデューサーは外から状態を変えても動く。Fluxは確かにMVCアプリケーションにありがちな多くのもつれを取り除いてくれるが、MVCアプリケーションがそれ自身を変更しているからだとは思わない。私の経験から思うに、名付けたアクションのコンテキストに正確に書き込んでいるからだ。私には、これがFluxのもたらす大きな拡張であるように思える。

UI状態の大きな問題は、異なった状態のオブジェクト間における依存関係が、劇的にアクションのコンテキストを変えてしまうことだ。あるアクションのコンテキストにおいて、状態オブジェクトAは状態オブジェクトBに依存する。もう一つのアクションコンテキストでは、同じ状態オブジェクトAはBから完全に分離しているかもしれない。

例えば、これはまさにBackboneスタイルのモデルチェンジイベントを使う際に陥りがちな罠だ。モデルAが、アクションのコンテキストを考慮に入れずに、モデルBからの更新をリッスンする。モデル間オブザーバのような依存関係を示す静的パターンは、UIプログラミングにおいてはうまく働かない。要求された変更のコンテキストを、依存関係が劇的に変えるからだ。

あるアクションの全書き込みに関する概観を求めることは、関連した状態の変更を決定するアクションコンテキストの重要性や状態オブジェクト間の依存関係に起因する。あなたが全ストア内の名付けたアクションに対処しなければならない時、Fluxはこのことをある程度うまくやってくれる。だが、なぜいつもこの調子でいかないのか?グローバルな状態ツリーとリデューサーがあれば、さらに踏み込むことだってできる。

リデューサーはやっぱり問題を解決してくれる!

なぜ私たちは、リデューサーが状態を潜在的に更新できるようにしないのだろうか?そうすれば、必要に応じて様々な状態オブジェクト間の依存関係を築きながら、少し書いただけでアクションの更新工程を一か所に全部宣言できたかもしれない。これはつまり、全てのアクションを一回だけ処理するということだ。

あなたが、リデューサーの使用に必要な前提であるグローバルなAppStateを持っていたとしよう。ただ、起こり得る全てのアクションとあらゆる状態の処理をするリデューサーを一つ登録するだけだ。

export function appStateReducer(state, action) {
    switch (action.type) {
        case ADD_TODO:
            return TodoState.addTodo(state, action.text);

        case DELETE_TODO:
            return TodoState.deleteTodo(state, action.id);

        case COMPLEX_ACTION:
            var state = FooState.writeSomeState(state, action.fooId);
            state = BarState.writeStateGivenFooState(state);
            // some complex reducer that resets a a bunch of state to initial state
            return CompositeReducers.resetFiveStateObjectsToInitialState(state)

        default:
            return state;
    }
}

BarState の書き込みがFooStateの書き込みに依存しているCOMPLEX_ACTIONに注意。

もしかしたら、これはアプリほどには拡張しないと思うかもしれないが、そんなことはないと思う。更新の表現を豊かにしたり、実装やビジネスロジックを隠したりするために、他のリデューサーを使うのがコツだ。あなたはそれでも、別々のモジュールにおける特定の状態の断片の書き込みに関係するリデューサーを実装するかもしれない。

だから、全てのアクションを切り替える「状態の断片モジュール」ごとに一つのリデューサーを書く代わりに、書き込み条件ごとにリデューサーを書けばいい。例えば、TodoStateモジュールの中は以下のようになる。

export function addTodo(appState, text) {
    var todos = state.todos;
    state.todos = [{
            id: (todos.length === 0) ? 0 : todos[0].id + 1,
            marked: false,
            text: text
        }, ...todos];
    return state;
}

todoReducer ではなく、todoを追加する実際のオペレーションの実装をする。それからaddTodoリデューサーをグローバルなアプリ状態リデューサーにおいて使う。これの素晴らしいところは、使うリデューサーのインターフェイスを自由に選べることだ。それにより、ブーリアン引数における依存関係がより明確になる。

このアプローチには、たくさんの良いところがある:

  • アクションをトリガする全書き込みの要約文書を一か所に持つことができる。
  • 2つの異なった集約レベルにおいて、何がどの順番から、どのように変更されるかという宣言を明確に分けることができる。
  • 望む場面において、関連するビジネスロジックを含む状態の更新をするリデューサーを分けることができる。「これ全部、同じアクションの影響を受けるから1つのストアになきゃダメだな」という日々はもう遠い昔だ。
  • 再利用でき、複合的かつ名前付きの状態の更新を作成するリデューサーを書くことができる。(例:「reset half of my stores(ストアを半分リセットする)」)
  • どのアクションがどの状態の断片に影響するかということに依存しない、グローバルな状態構造を保つことができる。

これは、Elmとかなり似ている。このサイトを見て、アクションの明確な概観とそれが与える状態への影響をチェックしてみてほしい。

そう、Reduxでこれをやるのは簡単だ。グローバルなアプリリデューサーを一つ登録すれば、他の未登録リデューサーも作成してくれる。しかし、おそらく依存関係すら必要ないだろう。ただ一つのリデューサーを処理するグローバルな状態ストアを書くためのコードは、ほんの2~3行だからだ。

上述のプロパティでシステムを実装する方法は、数え切れないほどある。つまり、全アクションではなく特定のアクショングループを処理する多重リデューサーを実装する可能性がある。アクションごとのリデューサーの宣言までしてしまい、ステートメントを切り替えてしまう可能性すらある。重要なポイントは、状態の断片によってではなく、アクションによって書き込みを概念的に一次配置することだ。この方法で、全てのアクションが1回だけ処理されることになり、あなたの複合的な書き込みを一層包括的にしてくれる。

これについて考えれば考えるほど、全更新を記述するために、一つのリデューサー内にある特定の小さい状態の断片にアクションを切り替えることがますますおかしく思えてくる。私はある程度大きいアプリを除いて、状態の断片を処理するアクションを「小さくする」ことはそこまで役に立たないと考えている。言い換えると、起こり得る全てのアクションに切り替えるストアのような実体は、たった一つだけ、もしくはごくわずかしか持つべきでない。
リデューサーですごくわかりづらい書き込みをすれば、システムの悪用も難しくないと思う。だがそれは、多くのチームにとって管理しやすい課題のように思える。

一方で、アクションのコンテキストには、たとえベテラン開発者であっても注意が必要だ。このことはできる限り強調しておきたいし、集中型のコンテキスト設定は、非集中型のそれよりもずっと理解しやすい。リデューサーを作ることで、全てのコンテキストの依存関係を非常に美しく処理できるようになるだろう。

もしかしたら欠点をいくつか書き忘れたかもしれないけれど、それについては今から1年以内に書くつもりだ。だが一つ確かなことがある。それは、リデューサーを使うことでたくさんの自由を手に入れることができるということだ。私たちは多分、古いFluxのやり方にただ固執するのではなく、リデューサーを体系化する様々なやり方を探っていくべきなのだと思う。

原文:http://www.code-experience.com/problems-with-flux/(2015-7-28)
※元記事の筆者には直接翻訳の許可を頂いて、翻訳・公開しております。

 -Tech, プログラミング

FAworksではプロのコンサルタントが案件をお探しします

  関連記事

Twitterはどうやって1秒に3,000もの画像を処理しているのか

Twitterはどうやって1秒に3,000もの画像を処理しているのか

現在、Twitter は1秒間あたり3,000枚の画像(約200GB)を作成し持続している。 しかし

生まれてから100種以上に騎乗してきた僕が選ぶ本命ワーキングチェア。

どーも!こんにちは! 最近よく目にする「iPadのCM」の最後に出てくる都市、ベトナムはホーチミンで

【企業別】Advent Calendar2014 はてブ数ランキングまとめ

皆さん、2014年も残す所1週間を切りましたが、いかがお過ごしでしょうか。もはや師走の風物詩となった

良いプログラマの条件とは?

良いプログラマの条件とは?

野球の打者は、少なくとも30%の確率でヒットを打つことに成功すれば良いとされている。ゴルファーなら、

フリーエンジニアにおすすめの交流会・勉強会の存在

【関連リンク】 ❏フリーエンジニアの生活はどんな感じ?時間管理を上手にするには ❏フリーエンジニアに

Javaアプリケーションのパフォーマンスを(ほぼ)自動的に上げる方法

Javaアプリケーションのパフォーマンスを(ほぼ)自動的に上げる方法

コードを書き換えずに簡単な手順をいくつか踏むだけで、複雑なJavaアプリケーションを10%以上スピー

Crystalの紹介:Cのように速く、Rubyのように滑らか

Crystalの紹介:Cのように速く、Rubyのように滑らか

僕は、Rubyistだ。Rubyと、そのコミュニティ、その生産性など、Rubyにまつわる多くのものが

未経験からフリーエンジニアになるためのポイント

【関連リンク】 ❏フリーエンジニアに必要なプログラミング以外のスキル ❏フリーエンジニアの仕事にはど

コスパ最強ラップトップ

エンジニア的コスパ最強laptop3選

どうもどうも。 「3度の飯より価格コム」で同じみの私です。 PC買う時ってすごい迷うよね。 CPUの

Uberがリアルタイムマーケットプラットフォームをスケールしている方法

Uberがリアルタイムマーケットプラットフォームをスケールしている方法

Uberは、たった4年で38倍という目覚ましい成長を遂げたという。今回が恐らく初めてだと思うが、Ub