メインコンテンツへ移動

Redux

無料2017-06-24#Front-End#JS#Redux入门#Redux guide#Redux与Flux#Flux与Redux#Redux vs Flux

FluxからReduxへ

1. 役割

Fluxと同様に、状態管理レイヤーとして単方向データフローに強い制約を課します。

2. 出発点

MVCにおいて、データ(Model)、表現層(View)、ロジック(Controller)の間には明確な境界がありますが、データフローは双方向であり、大規模なアプリケーションでは特に顕著になります。一つの変化(ユーザー入力や内部APIの呼び出し)がアプリケーションの複数の状態に影響を与える可能性があり、例えば双方向データバインディングでは保守やデバッグが困難になります。

あるモデルが別のモデルを更新できる場合、あるビューがモデルを更新し、そのモデルが別のモデルを更新し、それがさらに別のビューの更新を引き起こす可能性があります。ある時点でアプリケーションで何が起きているのか分からなくなります。なぜなら、いつ、なぜ、どのように状態が変化したのかが分からないからです。システムが不透明になり、バグの再現や新機能の追加が難しくなります。

強制的な単方向データフローを通じて複雑さを軽減し、保守性とコードの予測可能性を向上させることを目指しています。

3. コアコンセプト

Reduxは*一つの不変な状態ツリー(State Tree)*でアプリケーション全体のステートを管理します。直接変更することはできず、変化が生じる際は、actionreducer を通じて新しいオブジェクトを作成します。具体的には以下の通りです:

  • アプリケーションのステートオブジェクトには setter がなく、直接の変更は許可されません。

  • dispatch action を通じてステートを変更します。

  • reducer を通じて actionstate を結びつけます。

  • 上位の reducer が下位のものを組織し、reducer ツリーを形成して、階層的に計算することで state を得ます。

関数型の reducer が鍵となります:

  • 小さい(単一責任)

  • 純粋(副作用がなく、環境に影響を与えない)

  • 独立(環境に依存せず、固定の入力に対して固定の出力を返す。テストが容易であり、与えられた入力に対する戻り値が正しいかどうかにのみ注力すればよい)

純粋関数の制約により、いくつかの強力なデバッグ機能が実現可能になります(さもなければ、状態のロールバックはほぼ不可能です)。DevTools を通じて変化を正確に追跡できます:

  • 現在の state、過去の action および対応する state を表示

  • 特定の action をスキップし、手動で準備することなくバグシーンを素早く再現

  • 状態のリセット(Reset)、コミット(Commit)、ロールバック(Revert)

  • ホットリロード。reducer の問題を特定し、修正を即座に反映

4. 構造

action  与Flux一样,就是事件,带有type和data(payload)
    同样手动dispatch action
---
store  与Flux功能一样,但全局只有1个,实现上是一颗不可变的状态树
    分发action,注册listener。每个action经过层层reducer得到新state
---
reducer  与arr.reduce(callback, [initialValue])作用类似
    reducer相当于callback,输入当前state和action,输出新state

reducer のコンセプトは Node のミドルウェアや Gulp のプラグインに相当します。各 reducer は状態ツリーの一部を担当し、一連の reducer連鎖させることで(前の reducer の出力を現在の reducer の入力とする)、最終的な出力である state を得ます。

reducerstate を変更するたびに、新しい state オブジェクトが作成されます。旧い値は元の参照を指し、新しい値が生成されます

厳格な単方向データフロー:

                  call             new state
action --> store ------> reducers -----------> view

action も(Fluxと同様に)トップレベルのすべての reducer に渡され、対応するサブツリーへと流れます。

store が調整を担当します。まず action と現在の statereducer ツリーに渡し、新しい state を得て現在の state を更新し、それからビューに更新を通知します(React の場合は setState() です)。

action

action は何が起きたかを記述します(ニュースの見出しのようなものです)。

actionaction creator は、それぞれ従来の eventcreateEvent() に対応します。action creator が必要なのは、移植性とテストのしやすさのためです。

設計上 action creatorstore を分離しているのは、サーバーサイドレンダリングを考慮しているためです。これにより、各リクエストが独立した store に対応し、外部で action creatorstore のバインディングを行います。

注意:実践においては、action の作成と dispatch action を切り離すべきです。必要なシーン(例えば子コンポーネントに渡す際に dispatch を隠蔽したい場合など)のために、Redux はこれら2つを結びつける bindActionCreators を提供しています。

また、非同期シナリオを考慮すると:

  • action の数

    一つの非同期操作には、開始/成功/失敗という3つの action(または3つの状態を持つ1つの action)が必要になる場合があります。対応するUI状態は、ローディング表示/ローディングを非表示にして新しいデータを表示/ローディングを非表示にしてエラー情報を表示となります。

  • view 更新のタイミング

    非同期操作の終了後、dispatch action によって state を変更し、view を更新します。

    複数の非同期操作の時系列を気にする必要はありません。なぜなら action の履歴ログから見れば順番は固定されており、同期か非同期の過程で dispatch されたかは重要ではないからです。

同期シナリオと大きな違いはありません。単に action が増えるだけです。いくつかのミドルウェア(redux-thunk、redux-promise など)は、非同期制御の形式をよりエレガントにするためのものであり、dispatch action の観点からは違いはありません。

reducer

具体的な状態更新を担当します(action に基づいて state を更新し、action の記述を事実に変えます)。

Fluxと比較して、Redux は event emitter の代わりに純粋関数である reducer を使用します:

  • 分解と合成

    reducer を分割することで状態を分解し、それらを組み合わせる(combineReducers() ユーティリティ関数)ことで状態ツリーを形成します。reducer の組み合わせは Redux アプリケーションにおいて非常に一般的です(定石)。

    通常、1つの reducer を一連の類似した reducer に分割しま��(あるいは reducer factory を抽象化します)。

  • 単一責任

    reducer はグローバルステートの一部のみを担当します。

純粋関数 reducer の具体的な制約(関数型プログラミングにおける純粋関数の概念と同じ)は以下の通りです:

  • 引数を変更しない

  • 純粋な計算のみを行い、ルーティングの切り替えなどの他のAPI呼び出しといった副作用を混ぜない

  • 不純な(出力が入力のみに依存せず、環境にも依存する)メソッドを呼び出さない(例:Math.random()new Date()

また、reducerstate は密接に関係しています。statereducer ツリーの計算結果であるため、まずアプリケーション全体の state 構造を設計する必要があります。いくつか非常に便利なテクニックがあります:

  • state をデータ状態とUI状態に分ける

    UI状態はコンポーネント内部で管理することも、状態ツリーに持たせることもできますが、データ状態とUI状態を区別することを検討すべきです。

    (単純なケースやUI状態の変化は store の一部にする必要はなく、コンポーネントレベルで管理すべきです)

  • state をデータベースと見なす

    複雑なアプリケーションでは、state をデータベースとして扱うべきです。データを保存する際にインデックスを作成し、関連データ間は ID で参照します。これにより独立性が保たれ、入れ子になった状態を減らすことができます(入れ子構造は state サブツリーを肥大化させますが、データテーブル + 関係テーブル ならそうなりません)。

Store

action と reducer を組織し、listener をサポートする接着剤です。

3つの役割を担います:

  • state を保持し、読み書きをサポートする(getState() で読み取り、dispatch(action) で書き込み)

  • action を受け取った際、reducer をスケジュールする

  • listener の登録/解除(状態が変化するたびにトリガーされる)

5. 3つの基本原則

アプリケーション全体で1つの state ツリー

これにより、別の state(履歴バージョン)を生成することが容易になり、redo/undo も簡単に実現できます。

state は読み取り専用

  • action を発行することによってのみ state を更新できます。

  • 変更は一箇所に集中し、厳格な順序で発生します(注意が必要なレースコンディションはありません)。

  • action は単なるプレーンなオブジェクトであるため、ログの記録やシリアライズが可能で、後で再生(デバッグ/テスト)することもできます。

reducer はすべて純粋関数

stateaction を入力し、新しい state を出力します。常に新しいものを返し、入力された state を維持(変更)しません。

そのため、reducer の実行順序を自由に変更でき、映画を再生するようなデバッグ制御が可能になります。

6. react-redux

ReduxとReactには直接の関係はありません。Reduxは状態管理レイヤーとして、Backbone、Angular、Reactなど、あらゆるUIソリューションと組み合わせて使用できます。

react-reduxは new state -> view の部分、つまり、新しい state ができたときにどのようにビューを同期させるかを担当します。

container

Fluxと同様に containerview のコンセプトがあります。

container はビューのロジックを持たず、store と密接に関係する特殊なコンポーネントです。ロジック機能としては、store.subscribe() を通じて状態ツリーの一部を読み取り、props として下層の普通のコンポーネント(view)に渡す役割を果たします。

connect()

一見魔法のようなAPIですが、主に3つのことを行います:

  • dispatchstate データを props として下層の普通コンポーネントに注入します。

  • 仮想DOMツリーに自動的にいくつかの container を挿入します。

  • 不要な更新を避けるためのパフォーマンス最適化が組み込まれています(shouldComponentUpdate の内蔵)。

7. Redux と Flux

共通点

  • Model の更新ロジックを独立したレイヤーとして抽出している(Redux の reducer、Flux の store)。

  • model を直接更新することを許可せず、すべての変化を action で記述することを要求する。

  • (state, action) => state という基本的な考え方は一致している。

相違点

  • Redux は具体的な実装であり、Flux はパターンである

    Redux は1つですが、Flux には十数種類の実装が存在します。

  • Redux の state は1つのツリーである

    Redux はアプリケーションの状態を1つのツリーにまとめ、グローバルに1つの store しか持ちません。

    一方、Flux は複数の store を持ち、状態の変化をイベントとしてブロードキャストし、コンポーネントがそれらを購読することで現在の状態を同期します。

  • Redux には dispatcher の概念がない

    イベントエミッターではなく純粋関数に依存しているためです。純粋関数は自由に組み合わせることができ、実行順序を別途管理する必要がありません。

    Flux では dispatcheraction をすべての store に渡す役割を担います。

  • Redux は state を手動で変更しないことを前提としている

    道徳的な制約であり、reducer 内で state を変更することは許可されません(新しい属性を追加することはできますが、既存のものを変更してはいけません)。

    強い制約としないのは、特定のパフォーマンスシナリオを考慮しているためであり、技術的には不純な reducer を書くことで解決できます。

    もし reducer が不純であれば、純粋関数の組み合わせ特性に依存する強力なデバッグ機能が破壊されるため、そうすることは強くお勧めしません

    state に不変なデータ構造を強制しないのは、パフォーマンス(不変性に関連する追加処理)と柔軟性(constimmutablejs などと組み合わせて使用できる)を考慮したためです。

8. 問題と思考

1. state 変化の購読メカニズムの粒度制御はどうなっていますか?

subscribe(listener) ではグローバルな完全な state しか得られません。では React の setState() の粒度はどうなっており、どのようにサブツリーを分けているのでしょうか?

手動で処理します。state ツリーに変化があればすべての listener に通知され、listener 内で自分が注目している state の一部が変化したかどうかを手動で判断します。つまり、購読メカニズムは振り分けを行わず、手動で行う必要があります。

2. react-redux の <Provider> とは何ですか?

推測ですが、hostContainerInfo を通じた黒魔術でしょう。外れました)そのため、render root 時に Provider を最上位コンテナとして置くことが要求されます:

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

hostContainerInfo は以下のようになっています:

function ReactDOMContainerInfo(topLevelWrapper, node) {
  var info = {
    _topLevelWrapper: topLevelWrapper,
    _idCounter: 1,
    _ownerDocument: node ? node.nodeType === DOC_NODE_TYPE ? node : node.ownerDocument : null,
    _node: node,
    _tag: node ? node.nodeName.toLowerCase() : null,
    _namespaceURI: node ? node.namespaceURI : null
  };
  if ("development" !== 'production') {
    info._ancestorInfo = node ? validateDOMNesting.updatedAncestorInfo(null, info._tag, null) : null;
  }
  return info;
}

(ReactDOM v15.5.4 のソースコードより引用)

仮想DOMツリー上のすべてのコンポーネントは hostContainerInfo を共有するため、すべての container 内で store にアクセスできます。サンプルコードは Usage with React を参照してください。

react-redux の実際の実装

推測は外れました。こちらを見てみましょう

内部インスタンスはプライベート属性(ランダムなキー __reactInternalInstance&<random>)であるため、コンポーネントは hostContainerInfo にアクセスできません。しかし、React は context と呼ばれる、深い階層で手動で props を渡す必要があるシーンに対応するための拡張版 hostContainerInfo を提供しています。大まかには以下の通りです:

// Provider
class Provider extends React.Component {
    constructor(props) {
        super(props);
    }
    // 把顶层手动传入的store prop作为context属性
    getChildContext() {
        return {store: this.props.store};
    }
    render() {
        return this.props.children;
    }
}

// container
class Container extends React.Component {
    // 把context里的store取出来,作为container的prop
    // container里就可以通过this.props.store访问store了
    getDefaultProps() {
        return {
            store: this.context.store;
        }
    }
}

最上位からすべてのコンポーネントを突き抜けて store が通っているように使えます。技術的には普通のコンポーネント(view、非 container)内でも this.context.store を通じて直接アクセスできてしまいますが(context は下層へ無条件に自動伝播するため)、そうすることはあまり推奨されません。

P.S. context が何の役に立つのかずっと分かりませんでしたが、ようやく理解できました。

3. ツリー形式(無限階層の展開)のシナリオはどう処理しますか?

典型的な業務シナリオである無限階層のツリー構造において、処理のコツは state をデータベースと見なす ことです(前述のテクニックです)。

Redux の理念に従えば、treenodes にフラット化すべきです。粗い粒度なら nodeId - children、細かい粒度なら nodeId - nodechildrenchildrenIdList になり、全体の ID テーブルを参照して children を得ます)となります。

フラット化することで問題は解決し、入れ子構造の状態よりもはるかに保守しやすくなります。もしツリーコンポーネントが1つの tree オブジェクトに対応している場合(すべての nodetree 上にある場合)、巨大なツリーの一部を更新するのは非常に困難になります。

P.S. 3NF(第3正規形)がフロントエンドに応用できるなんて、信じがたいことです!

参考資料

コメント

コメントはまだありません

コメントを書く