NHN Cloud Meetup 編集部
Reduxの分析
2017.05.02
970
ReduxはEvent SourcingパターンとFunctional programmingを組み合わせて、ライブラリ形式で実装したコンテナです。このコンテナは、アプリケーションの状態を保存し、簡単に予測できるようにして、一貫性のある実装を維持しつつ、テスト、メンテナンス、デバッグなどを簡単に処理できるようにサポートします。
Reduxは「Fluxの実装体か否か」と多く議論されましたが、結論としては、ReduxはFluxの影響を受けて新たに実装されたコンテナのライブラリであり、「Fluxの実装体か否か」はそれほど重要ではありません(厳密にはReduxはFluxではない)。つまり、Fluxの構造によってReduxがどのように実装されたかは重要ではなく、「Fluxの大きな特徴がReduxによく溶け込んでいる」ことを知るべきでしょう。さらに詳しい内容は、Redux文書のPrior Art – fluxから確認できます。
そして、ReduxはReactと直接的な関連はありません。ただ相性が良く、React-Reduxのフレームワークもあります。ここでは、Reduxの構造を扱い、Reduxの使用例やReact-Reduxのチュートリアルは説明しません。それらは、Redux文書とRedux開発者のビデオ講義をご参考ください。
3つの原則
Reduxの3つの基本原則を見てみよう。(Redux 3-principles)
- Sinlge source of truth(SSOT)
- Read-only state
- Changes from pure functions
これらの原則により、Reduxが管理する状態が読み取り専用のSSOTであり、純粋関数を使って変化させることができます。
ここで、読み取り専用の状態を純粋関数に変更させることが、矛盾するように思われるかもしれません。これは実際のReactのエレメントに類似した形態で、すべての変化に新しい状態を作成し、これに切り替えるということです。ReduxのcreateStoreコードを確認してみよう。
// Redux - v3.6.0
// Reducerは純粋関数で、`(state, action) => state`の形式で、
// 状態変化を起こそうとするアクションによって、参照が異なる新しい状態を返却する。
currentState = currentReducer(currentState, action)
基本的な概念
Reduxの構造を分析する前に、基本概念を理解する必要があります。
- アクション(Action):アプリケーションの状態をどのように変更させるか抽象化した表現である。単純オブジェクト(Plain object)で
type
プロパティを必ず持つ必要がある。 - レデューサー(Reducer):アプリケーションの次の状態を返す関数。以前の状態とアクションを受けて処理し、次の状態を返却する。
- ストア(Store):アプリケーションの状態を保存して読み取り可能にし、アクションを送信したり、状態の変化を検出できるようにAPIを提供するオブジェクトである。
- Redux用語:Reduxで使用する用語の定義
Reduxはアプリケーションの状態管理をストアという概念で抽象化して、状態ツリーを内部で管理します。アプリケーションは、変化を表現するアクションをストアに伝達し、ストアはReducerを使って状態のツリーを形象化して変更します。そして、ストアは再びアプリケーションに状態ツリーの変更を伝達します。アプリケーションは状態ツリーの変更を認知し、これに伴うUI変更や他のサービスロジックを実行します。
ストア
ストアは、内部的にReducerとアプリケーションの状態、イベントリスナー、現在のディスパッチ(Dispatching)有無を示す値(isDispatching: boolean)を管理します。外部的にはdispatch、subscribe、getState、replaceReducer APIを表示します。
Reduxは、次のような単方向データフローになっています。(参考:Reduxデータフロー)
以下は、ストア(createStore)の簡単な実装を表します。単方向データフローを理解すると、次の実装はそれほど難しくありません。
/**
* createStore API内部
* (root) Reducerと初期状態を引数で受け取る。
**/
// モジュールパターン - private変数
(private) currentState: Object,
(private) currentReducer: Function - (state, action) => state,
(private) listeners: Array.<Function>
(private) isDispatching: Boolean
// API:
// 現在の状態を返却
getState() {
return currentState;
}
// API:
// Change listenerを登録
subscribe(listener) {
listeners.push(listener);
return function unsubscribe() {
var index = listeners.indexOf(listener);
listeners.splice(index, 1);
}
}
// API:
// アクションを受けてReducerで処理する。 次の3つの動作に分離できる。
// 1. 前処理:Actionの有効性、現在Dispatchingかどうか
// 2. Reducer:currentReducerで返却する次の状態を適用
// 3. イベント:登録されたリスナーを順番通りに実行
dispatch(action) {
if (!isPlainObject(action)) throw Error
if (typeof action.type === 'undefined') throw Error
if (isDispatching) throw Error
// isDispatchingはReducerで再度dispatchを呼び出すのを防ぐために使う。
try {
isDispatching = true;
currentState = currentReducer(currentState, action);
} finally {
isDispatching = false;
}
// `slice`はリスナーでsubscribeとunsubscribe APIを使う場合、
// 現在のリスナー実行に影響を与えないためである。
//
// しかし、`slice`コストが高いので、実際の実装は若干異なるが、動作自体は同じである。
listeners.slice().forEach(listener => listener());
return action;
}
// API:
// コード分離(Code splitting)、hot reloadingなどの技法や
// Reducer自体を動的に使う際、必要なこともある。
replaceReducer(reducer) {
currentReducer = reducer;
dispatch({type: INIT});
}
// 状態初期化コード実行
dispatch({type: INIT});
// API返却
return {
getState,
subscribe,
dispatch,
replaceReducer
};
Reduxのストアは、最終的にcreateStore関数に基づいて動作するため、高階関数(High order function)を適用できます(Reduxではenhancerと呼びます)。そのため次のように適用できます。
const enhancer = createStore => {
//.. enhance logics
return createStore(reducer, initialState, enhancer);
}
const createStoreEnhanced = enhancer(createStore)
const enhancedStore = createStoreEnhanced(reducer, initialState)
// また下記のように、enhancerを引数で使用することもできる。
const enhancedStore = createStore(reducer, initialState, enhancer)
Reduxは基本的にapplyMiddlewareというenhancerを提供します。次にミドルウェアを調べてみよう。
ミドルウェア
Reduxのミドルウェアは、dispatchingの過程でアクションがReducerに到達する前に、アプリケーションのロジックが割り込む隙間を作ってくれます。
compose
ミドルウェアを分析するために、まず重畳関数(compose)を理解しよう。ReduxはcomposeをメインAPIとして公開していますが、Reduxとは独立した有用性が近い関数で高階関数を実装できるようにサポートします。
f, g, hという関数があり、fとgは先の関数の返却値を引数で受け取ることを定義されているとき、下記のような形が考えられます。
x = h(...args)
y = g(x) = g(h(...args))
z = f(y) = f(g(x)) = f(g(h(...args)))
最終的に(…args)をもって、f, g, hを適切に組み合わせて、z結果値を取得する場合、 f(y)を簡単に表現するために、composeという関数を使います。
compose(f, g, h) = (...args) => f(g(h(...args)))
原理
ミドルウェアは、外部で生成されたアクションがReducerに到達する前に、まず受信して、システムが適切に特定のタスクを事前処理できるようにします。アクションを検証したり、フィルタリング、モニタリング、外部APIとの連動、非同期処理などを追加的に実行できるようにします。ミドルウェアがない場合、このようなジョブをすべてアクションのコンストラクタで処理するか、dispatchをモンキーパッチして処理する必要がありますが、この場合、重複、複雑度、非純粋関数などのメンテナンスが難しく、多くの問題が発生します。ミドルウェアは、このような問題を簡単に解決し、より強力なdispatch関数を作ります。
Reduxはミドルウェアの介入がない基本的なdispatch関数とミドルウェアにより、高階関数となったdispatching関数を区別しています。このような高階関数は、前述のcomposeを活用して実装します。これからは、baseDispatchとdispatchを区別して使用します。
ミドルウェアは、store APIの{getState, dispatch}を引数として受け取ります。そしてnextという関数を受け取る新しいwrapDispatch関数を返します。つまりミドルウェアは、3つの重畳関数を実装する必要があります。
function middleware({getState, dispatch}}) {
return function wrapDispatch(next) {
return function dispatchToSomething(action) {
// do something...
return next(action);
}
}
}
かなり複雑に見えますが、arrow-syntaxを使うと、少し見やすくなります。
const middleware = ({getState, dispatch}) => next => action => {
// do something...
return next(action);
}
next関数は、現在のチェーンで初めに戻らず継続接続しているdisptachプロセスを実行するために必要です。ストアに複数のミドルウェアが入れ子になっているとき、次のミドルウェアのdispatching関数に進入するために必要な関数です。nextではなく、store APIのdispatchを呼び出すと、アクションが再び最初に戻るので注意しよう。
では、applyMiddlewareAPIを見てみよう。ちなみにapplyMiddlewareはcreateStore自体を包む高階関数です。applyMiddleware
とmiddlewareは正確に区別しなければなりません。
function applyMiddleware(...middlewares) {
// applyMiddlewareは、既存のcreateStoreの高階関数を返却する。
return (createStore) => (reducer, preloadedState, enhancer) => {
const store = createStore(reducer, preloadedState, enhancer)
let dispatch = store.dispatch
let chain = []
// ミドルウェアに引数で伝達されるオブジェクトである。
// dispatchが単純にstore.dispatchの参照を剪断するのではなく
// 関数をもう一度包んで使う点を覚えよう。
const middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
}
// ミドルウェアが返却する関数チェーン(=wrapDispatch)をもたらす。
chain = middlewares.map(middleware => middleware(middlewareAPI))
// ミドルウェアが返却する関数チェーンを重畳させた後、新しいディスプレイを作る。
dispatch = compose(...chain)(store.dispatch)
// applyMiddlewareを通じて返却されたcreateStoreの高階関数は
// 既存のストアと同一のAPI、新規作成されたdispatch関数を返却する。
return {
...store,
dispatch
}
}
}
興味深いのはmiddlewareAPIの部分で、dispatch関数をもう一度包んでいるところです。単に以下のように表現すると何が問題になるのか考えてみよう。
let dispatch = store.dispatch;
const middlewareAPI = {
getState: store.getState,
dispatch
}
上記のように実装すると、ミドルウェアでは常にbaseDispatchだけ見るようになります。しかし、非同期アクション/ミドルウェアの場合、アクションを再び最初のミドルウェアチェーンに戻す必要があります。つまりnext(action)ではなく、store.dispatch(action)が必要なケースがあるということです。このような場合には、クロージャ変数でミドルウェア全体が接続している(=dispatch = compose(…chain)(store.dispatch))dispatchを参照する必要があります。次のコードの違いを確認してみよう。
let foo = () => console.log('foo')
const a = {foo};
const b = {
foo: () => foo()
}
foo = () => console.log('new foo')
a.foo() // foo
b.foo() // new foo
ミドルウェアの簡単な使用例としてRedux-thunkライブラリを参照してみよう。アクションが一般オブジェクト(Plain object)ではない関数のとき、その関数を実行しておくことで、非同期ロジックや外部API連動のような動作が可能になります。
Reducer
Reduxという名前は、Reducer+Fluxを意味します。ReduxはReducerが重要な概念であることから、Reducerについて詳しく調べる必要があります。
これまでアクションがdispatchされミドルウェアを通過する過程までを把握しました。外部から入ってきたアクションは、ストアの状態(state)と一緒にReducerに転送され、ストアはReducerから新しい状態を受け取ります。そのためReducerは、以前の状態とアクションを受けて、新しい状態を返す純粋関数として定義されます。
reducer: (previousState, action) => newState
意味
どのアプリケーションにせよ、単一Reducerを構成するのは非常に難しいでしょう。アプリケーションの状態は、数十、数百種類の値を持つが、すべての状態を1つのReducerで管理するのはほぼ不可能だと思われます。もし単一Reducerにアプリケーションを設計できれば、最初からReducerとは呼ばなかったでしょう。Reducerは、基本的に単位の状態に対する単位Reducerの組み合わせを構成できます。アプリケーション開発者は、小さな単位のReducerを定義し、最後にcombineReducersAPIを通じて、各Reducerを組み合わせて1つの大きなルートReducerを作ることができます。
combineReducersを簡単に表すと、次のとおりです。
function combineReducers(reducers) {
return (state = {}, action) => {
return Object.keys(reducers).reduce((result, key) => {
const reducer = reducers[key]
const prevState = state[key]
result[key] = reducer(prevState, action)
return result
}, {})
}
}
reducers = {
a: aReducer,
b: bReducer,
c: cReducer
};
const combinedReducer = combinReducers(reducers);
/*
組み合わせた最終リデューサ(combinedReducer)は以下のとおり。
function(state, action) {
return {
a: aReducer(state.a, action),
b: bReducer(state.b, action),
c: cReducer(state.c, action)
}
}
combinedReducerを呼び出すと、aReducer, bReducer, cReducer がすべて呼び出され、新しいオブジェクトを返却する。
*/
各ReducerがcombineReducerでArray.prototype.reduceAPIに配信される形で使用されるためReducerと呼びます。実際の実装コードはreduceを使いませんが、概念として様々に分散した状態を1つの状態に減少させるという意味に合致します。
コードを見るとわかるように、combineReducerは基本的に1dpethの状態を持って処理します。しかしcombineReducerを再帰的に組み合わせると、n-depthの状態を実現できる。このため、アプリケーション開発者は、アプリケーションの状態が深くても、それぞれの単位の状態だけを処理する単位Reducerを作成し、これらを組み合わせて全体のルートReducerを作成し、アプリケーションの全体的な状態を管理することができます。
アクションによる状態変化の有無
前述したように、combineReducer状態の変化の有無に関わらず、常に新しいオブジェクトを返却します。もしイベントリスナーが実行され、実際の状態に変更がなかったとしたら、アプリケーション開発者はそれをどのように検知できるでしょうか?各プロパティで1つずつ深さを比較するしかありません。したがって、Reducerは変化がない場合には、常に参照が同じ既存の状態を返す必要があり、これはcombineReducerAPIも同様です。以下は、実際に実装したコードの一部です。
let hasChanged = false const nextState = {} for (let i = 0; i < finalReducerKeys.length; i++) { const key = finalReducerKeys[i] const reducer = finalReducers[key] const previousStateForKey = state[key] const nextStateForKey = reducer(previousStateForKey, action) nextState[key] = nextStateForKey hasChanged = hasChanged || nextStateForKey !== previousStateForKey } return hasChanged ? nextState : state
combineReducerで返却されるReducerも、状態のすべてのプロパティに対してhasChanged = hasChanged || nextStateForKey !== previousStateForKey構文を実行し、最後にhasChangedするかによって、新しい状態または既存の状態を返却するかを決定します。アプリケーション開発者も、単位Reducerで状態の変化がある場合に限り、新しい参照を持つオブジェクトを返却して、そうでない場合は、以前の状態をそのまま返却するルールを守る必要があります。
おわりに
Reduxは簡単なライブラリでありながらも非常に重要な概念を含んでおり、技術的にも洗練された手法を用いています。今回、Reduxを分析し、単方向データフロー、Event-Sourcingパターン、重畳関数、Reducer等をどのように活用して組み合わせるか、学ぶことが非常に多かったです。特にReducerを純粋関数として実装し、これらを組み合わせて1つの状態ツリーを構成するのは、一度試してみるとよいでしょう。時間に余裕があれば、Reduxのコードを読んでみるのも参考になると思います。