Reactからの流れで、組み合わせての運用例の多いReduxを勉強してみました。
Fluxの概念、Reduxの基本機能、Reactとの連携について公式のドキュメントを中心にまとめています。
フロー自体が厳密でありながらも実装上の制約が少ない点が魅力的に思えましたが、
データモデルやUIツリーの設計、ファイル/クラス分割やディレクトリ構成などについても明確な規約が存在せず、(特にスケールを見越しての)運用方針は使い込みながら模索していく必要がありそうです。
概要
Redux = React Communityが開発しているJavaScriptフレームワーク
-
オープンソース, MITライセンス
-
Fluxの考え方/アーキテクチャにもとづいている
- 単方向にデータや処理をフローさせることで、わかりやすくフロントエンド作りましょう、という考え
-
ユーザの操作 → View → Action → Reducer → State → View → ユーザへの表示
の順
-
- 厳密にはReduxのアーキテクチャは少し異なる(らしい)
- 単方向にデータや処理をフローさせることで、わかりやすくフロントエンド作りましょう、という考え
-
比較的大きなSPAにおける、複雑な状態管理・データ管理をシンプルにすることが目的
- 逆にビューの制御には関わらないので、同じ開発元のReact.jsなどと組み合わせて利用する例が多い
- jQueryやAngularと組み合わせての運用も可能
概念
用語
-
State = 状態オブジェクト
- MVCでいうところのモデル
-
Action = 変更オブジェクト
- 状態に対する変更を記録する履歴のようなイメージ
-
ActionCreater = アクションを生成する関数
- パラメータを受取り、アクションオブジェクトを構築して返す
-
Reducer = 状態の変化を決定するための関数
- 内部的にこの関数を
array.reduce()
にコールバックとして渡すので、そう呼ぶ - 現在の状態と、それに対するアクションを引数にとる
- その結果として、更新された状態を返す
- 内部的にこの関数を
-
Store = Status/Action/Reducerを結びつけるオブジェクト
- 現在の状態を管理する
- イベントリスナを管理する
- 受け付けたアクションに対してリデューサを割り当て、状態を更新する
原則
Reduxには3つの原則がある。
-
すべての状態はひとつのストア(オブジェクト)にツリー状に格納される
- データが分散しないため、管理が容易になる
-
状態はアクションを介さない限り、変更できない
- 知らないうちに他のモジュールがデータを書き換えることがなくなる
- アクションを記録することで、その再現が容易になる
-
状態変更のみを目的としたリデューサ関数によってのみ、状態が変更される
- 正確には、状態を変えるのではなく新しい状態を返す
- リデューサはアプリの規模によっては、ネスト構成することもできる
導入
npmパッケージとしてnpm/yarnでインストールする。
本体のredux
のほか、後述するReactとの連携用のreact-redux
, 開発用のredux-devtools
など。
$ yarn add redux react-redux
yarn info redux
でパッケージ情報を見てみるとbabelに依存していることがわかる。
Reduxを使う側のコードもES6が推奨されている様子なので、これらをトランスパイルできる環境を構築する必要がある。
先日Rails上に環境構築したのでこれを使う:
React-Redux on Rails(w/webpack/yarn)環境を作ったメモ
基本
ストアを構築する
まず構成要素であるアクション(クリエイター)とリデューサを実装し、それらを組み合わせてストアをつくる。
状態(モデル)を考える
状態モデルはコードとして実装する必要はないが、どのような構成で管理すべきかを先に考えておく。
要点:
- ひとつのオブジェクト内に格納すること
- データ自体と、UIの制御状態は分離して管理すること
- オブジェクト内で参照が循環しないようにすること
{
data: {
tweets: [
{ id: 'eeab875587d2edefa27ddfce574dc8f7', user: 'alice', created_at : '2017-05-31 13:11:24', message: 'ほげほげ', },
{ id: 'e01096b9ffe3f416157f6ec46c467725', user: 'bob', created_at : '2017-05-31 13:08:39', message: 'ふがふが', },
{ id: '742330d6617e449e7bb460e802d50701', user: 'charlie', created_at : '2017-05-31 13:08:11', message: 'ぷるぷる', },
{ id: 'd2840cc81bc032bd1141b56687d0f93c', user: 'dave', created_at : '2017-05-31 13:06:38', message: 'ぴよぴよ', },
],
},
ui: {
colorscheme : THEME_BLACK,
picture : true,
},
}
アクション(クリエイター)を定義する
引数をとってアクションオブジェクトを返す、クリエイター関数を実装する。
要点:
- アクションは、普通のjsオブジェクト(
{ ... }
)であること - アクションは、その挙動を表すプロパティ
type
が__必ず__含まれること -
type
以外のデータ構造は任意に構成していい
//
// typeを表す定数
//
export const ActionType = {
RELOAD : 'RELOAD', // 色の変更
CHANGE_COLOR : 'CHANGE_COLOR', // 色の変更
TOGGLE_PICTURE : 'TOGGLE_PICTURE', // 画像表示状態の変更
REPLY : 'REPLY', // リプる
RETWEET : 'RETWEET', // RTする
TOGGLE_FAVORITE : 'TOGGLE_FAVORITE', // ふぁぼのON/OFF
};
//
// UI状態を表す定数
//
export const ColorScheme = {
DEFAULT : 'DEFAULT',
BLACK : 'BLACK',
WHITE : 'WHITE',
}
//
// アクションクリエイター
//
export function reload(){
return { type: ActionType.RELOAD };
}
export function togglePicture(){
return { type: ActionType.TOGGLE_PICTURE };
}
export function changeColor(colorScheme){
return { type: ActionType.CHANGE_COLOR, color: colorScheme };
}
export funcntion reply(tweetId){
return { type: ActionType.REPLY, id: tweetId };
}
export funcntion retweet(tweetId){
return { type: ActionType.RETWEET, id: tweetId };
}
export funcntion toggleFavorite(tweetId){
return { type: ActionType.TOGGLE_FAVORITE, id: tweetId };
}
リデューサを実装する
状態とアクションを引数にとり、更新された状態を返す関数を実装する。
要点:
- 引数を変更しないこと
- 純粋関数であること(引数が同じなら戻り値も必ず同じなこと)
- 副作用がないこと(戻り値以外の方法で外部に影響を与えないこと)
//
// 初期状態を定義
//
// 最初に考えた状態構成の通り
const initialState = {
data : {
tweets : [],
},
ui : {
colorscheme : ColorScheme.DEFAULT,
picture : false,
},
}
//
// リデューサ
//
// 初回はstateがundefinedで渡ってくるので、デフォルト値を設定する
function twitterImitationApp(state = initialState, action){
switch(action.type){
case CHANGE_COLOR:
// 新しい状態オブジェクトを作成して返す
return Object.assign({}, state, {
ui: {
colorscheme: action.color,
}
});
// 以下、アクションごとに処理して状態を返す
case TOGGLE_PICTURE:
case RELOAD:
case REPLY:
case RETWEET:
case TOGGLE_FAVORITE:
// ...
default:
return state;
}
}
// リデューサは復数定義できる
// ...
状態の不変性
Object.assign()
はES6追加のメソッドで、第1引数のオブジェクトに対して第2引数以降を順番にディープコピーする。
第1引数に空オブジェクト{}
を指定すれば、第2引数以降の内容をコピーした新しいオブジェクトができる。$.extend()
に似てる。
// stateを単純に複製した上で、変更する
var cloned = Object.assign({}, state);
cloned.ui.picture = true;
return cloned;
// あるいはstateの複製と変更を同時に行う
return Object.assign({}, state, {
ui: {
picture: true,
}
});
// 配列を変更する場合はfilter(), map()と組み合わせてもいい
return Object.assign({}, state, {
data: {
tweets: state.data.tweets.filter((elem, index, state) => {
// ...
}),
}
});
状態オブジェクトの不変性を担保する上では、同じくFacebook系のコレクションライブラリであるImmutable.jsを使うことが多い様子だった。
また別途調べる。
リデューサのスケール
リデューサが大きくなってきた際には分割を検討する。
データ単位に分解
状態オブジェクトに含まれるデータが互いに関連しない場合、そのデータごとのリデューサを定義することで、分割できる。
これは公式のチュートリアルでもReduxの基本的な分割手法(Reducer Composition)として紹介されていた。
// これがメイン
function someReducer(state={}, action){
// データ領域ごとにリデューサを呼んで組み立てる
return {
foo: foo(state.foo, action),
bar: bar(state.bar, action),
};
}
// それぞれのデータ領域用のリデューサが、アクションを解釈する
function foo(state={}, action){
swith(action){
// ...
}
}
function bar(state={}, action){
swith(action){
// ...
}
}
Reduxの標準機能を使ってこのパターンを実装することもできる。
// 上の例と意味は同じ
import { combineReducers } from 'redux'
const someReducer = combineReducers({
foo,
bar,
})
アクション単位に分解
あるいはアクション単位に分割する。
function someReducer(state={}, action){
switch(action){
case ActionA: return alphaReducer(state, action);
case ActionB: return bravoReducer(state, action);
// ...
}
}
function alphaReducer(state={}, action){
// ...
}
function bravoReducer(state={}, action){
// ...
}
特に言及されている資料が見つからなかったので、あまり使わないパターンなのかもしれない。
(アクションの種類よりもデータ構造のほうがスケールし得るからか)
ストアを作成する
アクションとリデューサが定義できたら、実際にストアを作成して状態の管理を開始できる。
要点:
- ストアは__1アプリに1つ__
-
createStore()
によってストアを作成する
import { createStore } from 'redux'
// 自分が作ったリデューサを定義するor読み込む
import someReducer from './reducers'
let store = createStore(someReducer)
ストアを操作する
オブジェクト化されたストアを、ReduxのAPIを使って操作することで、
Fluxの(ような)単方向ライフサイクルを回し、アプリとして機能させる。
ストアの外側がやるべきことは大きく2つだけ:
- ディスパッチ: アクションの発生時に、ストアにアクションを割り当てる
- コールバック: ストアが状態を変更した時に、その通知を受ける
ディスパッチする
ユーザによるUIの操作時や、Ajax通信の完了時などのイベントが発生したタイミングで、その内容をアクションとしてストアに伝える。
- ディスパッチには
store.dispatch()
を使う
$('#reload-button').click(function(){
var action = reload();
store.dispatch(action);
});
ディスパッチしたあとは、ストアの内部で以下が起こる:
- ストアがリデューサを実行する
- リデューサがアクションを解釈し、新しい状態を返す
- 新しい状態がストアに保存される
コールバックを受ける
状態が変更されたことをきっかけに行う処理があれば、それをリスナとして登録する。
- リスナの登録は
store.subscribe()
を使う。戻り値として__解除用の関数__を返す - リスナの解除は、登録時に得た解除用の関数を呼ぶ
- コールバック時にストアの状態を知りたい場合は、
store.getStatus()
を使う(コールバック時以外も呼べる)
// コールバックを登録する
let unsubscribe = store.subscribe(() => {
// 現在の状態を得る
let state = store.getState();
// ...
})
// ...
// 登録を解除する
unsubscribe();
Reactと連携する(react-redux)
npmパッケージreact-redux
を使うことで、Reactとの連携が簡単になる。
概念
連携する上では、Reactのコンポーネントを表現(Presentational)とコンテナ(Container)の2種類に分けて考える。
詳細は公式のチュートリアルと、Redux作者の説明を参照のこと。
公式のチュートリアルから抜粋すると以下:
| 表現コンポーネント | コンテナコンポーネント
--- | --- | ---
内容 | どう見えるか(UI) | どう動くか(データ制御)
Reduxとの関わり | ない | ある
データの取得方法 | propsを通じて | Reduxの状態を監視することで
イベントの通知方法 | propsで渡されたコールバックを呼ぶ | Reduxのアクションをディスパッチする
誰が作るのか | 自分で書く | React-Reduxが生成する
-
表現もコンテナも、他のコンポーネントを内包しうる
-
react-reduxはコンテナコンポーネントの生成と、その最適化をサポートする
- 自力でもコンテナを書けるが、実装・実行効率ともに悪くなりがち
-
UI/コンポーネントは、ツリー上の階層構造になるよう設計する
-
UI階層は、おおむねReduxの状態オブジェクトの構造に準ずる
-
対応する表現/コンテナがともに非常に小さい規模の場合は、両者を一緒にしてしまうのもあり
ReactとReduxを結合する
まずはReduxのストアをReactのコンポーネントに渡す。
連携させるためのルールとして、Reactのルートコンポーネントは<Provider>
になる。
Providerにストアを渡すことで、後述するコンテナコンポーネントが自動的にストアを監視(subscribe)できるようになる。
// Reduxの手順に従いストアを作成
let store = createStore(...)
// レンダリング
ReactDOM.render(
<Provider store={store}> // ルートコンポーネントのProviderにストアを渡す
<SomeContainer /> // 以下、実装した表現/コンテナコンポーネント
</Provider>,
document.getElementById('root')
)
コンテナを定義する
コンテナはconnect()
によって生成する。
まず以下を引数として渡すと、
-
mapStateToProps
: 状態の変更時に実行されるコールバック関数 -
mapDispatchToProps
: UIイベントの発生とディスパッチの対応を返す関数
ファクトリオブジェクト(?)が作られる。
ファクトリに、コンテナが内包すべきコンポーネントを渡すことで、コンテナが生成される。
import { connect } from 'react-redux'
// 状態の変更 → propsの変更 の対応を定義
// コンテナが含む下位のコンポーネントのpropsに渡す
const mapStateToProps = (state, ownProps) => {
return {
foo: state.foo,
bar: state.bar,
}
}
// UIイベント → アクションディスパッチ の対応を定義
// コンテナが含む下位のコンポーネントのpropsに渡す
// (第一引数はdispatch関数)
const mapDispatchToProps = (dispatch, ownProps) => {
return {
onClickToSomething: () => {
dispatch(...);
},
}
}
const SomeContainer = connect(
mapStateToProps,
mapDispatchToProps,
)(SomeUI);
表現を定義する
表現コンポーネントはReactの標準的な定義に同じ。
自身を内包するコンテナからpropsでパラメータが渡るので、それを使ってUIを実装する。
// 関数形式で表現コンポーネントを定義する
const SomeUI = (props) => (
<div>
<span>foo = { props.foo }, bar = { props.bar }</span>
</div>
<button onClick= { () => props.onClickSomething() }>do something</button>
)
表現/コンテナ混合型のコンポーネント
実装規模が小さい場合は、表現とコンテナを同じコンポーネント上に定義できる。
connect()
を引数なし(コンテナ未指定)で呼んだ上で、その結果に対象コンポーネントを読ませる。
混合型のコンポーネントはpropsにディスパッチ関数を含み、コンポーネント内で直接アクションディスパッチできる。
const SomeUI = ({ dispatch }) => {
return (
<button onClick={ () => dispatch(someAction()) }>do something</button>
)
}