この記事はReact #1 Advent Calendar 2017の5日目です。
はじめに
私はなんとなくReduxが好きなのですが、
初めて手を出した時はreact-reduxのボイラープレートやディレクトリ構成やstore、Provider、middlewareのセットアップなど初見ではどれも意味不明で、最初はReact専用のフレームワークだと思っていました。
しかしソースコードを読んでみるとRedux自体は実装にflowもTypeScriptも使っていない非常に小さなJSライブラリに過ぎず、Reactとは独立した存在であることが分かりました。
ここではreact-reduxや他のJSフレームワークの事を一旦忘れてVanilla JSでReduxを動かし、純粋にReduxの動作原理について学んでみたいと思います。
FluxアーキテクチャとReudx1
ReduxはFluxアーキテクチャに作者のdan abramovさんが一部アレンジを加えた実装であるため、
Fluxアーキテクチャを知らない方の為に前置きとしてその説明から入ります。
Fluxとは2014年4月にFacebookがF8
という開発者向けカンファレンスで発表したクライアントサイドアプリ設計のパターンです。
Facebookは彼らの巨大なコードベースにMVCを適用すると主に双方向データバインディングの複雑性が障壁となりスケールが難しくなるため、それを避けるために単一方向データフローのFluxパターンを利用し始めたようです。2
Reactが普及し始めると以下の図を用いてFluxが紹介される事が増えました。
私個人はRuby on RailsなどのサーバサイドWebフレームワークでMVCを覚えたので上のMVCの画像でModelとViewが相互にやりとりしているのがよくわからなかったのですが、
それらは元来のMVCをサーバサイドWebフレームワークに適用するためアレンジしたもので、MVC2と呼ばれているようです。
典型的なサーバサイドWebフレームワークの動きはこのように、
- Controller -> Model -> Controller(Modelで取得した値をテンプレートエンジンに渡す) -> View
実質的に1方向のデータフローでありMVCだと思っていたので、当時はクライアントサイドの(元来の)MVCやFluxという概念を提唱した理由などが良く分からず色々と調べた記憶があります。
Facebookは当初Fluxの実装をオープンソースで公開していなかったためいくつかのOSSコミュニティによるFlux実装が出回っていました。2
その中の1つであるReduxが今日人気を博しています。
Reduxが注目を浴びたのはReactEurope Conference 2015のHot Reloading with Time Travelという発表がきっかけでした。
発表を行った時期はReduxのリリース直前であり、
作者のdan abramovさんはここでReactとWebPack HMRを組み合わせたホットリロード、Reudxを使った状態のタイムトラベルといったインパクトの大きいデモを行います。
ReduxとFluxアーキテクチャの違いで特徴的なのがDispatcher
がReducer
と名付けられている点です。
ReduxはElmなどの関数型言語から着想を受けており、
reducer()は第一引数にstate、第二引数にactionを受け取って新しいstateを返すだけの純粋関数としてデザインされています。
このシグネチャがArray.prototype.reduce()と同じである事に由来してると発表では述べられています。
私個人は関数型言語はScalaを少し触ったくらいしか経験が無いのですが、
複雑になりがちなGUIのアプリ状態遷移をあくまでも引数と戻り値というシンプルな関数として表現しているところが気に入っています。
デザインにこうした関数型の背景があるのでactionに渡せるのはプレーンなオブジェクトのみであり、
状態が未解決の関数(典型的にはAPIへのリクエストを解決しないと値が定まらない処理)はMiddleareという仕組みを用いてreducerへ渡す前に解決してあげる必要があります。
Vanilla JSでReduxを動かす
さて、ここから本題に入っていきましょう。
幸いな事にRedux公式リポジトリにはcounter-vanilla
という素晴らしいサンプルが含まれています。
CDNからReduxを読み込んでいる以外は標準のDOM APIのみで構成されており、
Reduxそのものにフォーカスして学ぶ事ができます。
以下がcounter-vanillaのソースコードです。(公式リポジトリより引用)
<!DOCTYPE html>
<html>
<head>
<title>Redux basic example</title>
<script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>
</head>
<body>
<div>
<p>
Clicked: <span id="value">0</span> times
<button id="increment">+</button>
<button id="decrement">-</button>
<button id="incrementIfOdd">Increment if odd</button>
<button id="incrementAsync">Increment async</button>
</p>
</div>
<script>
function counter(state, action) {
if (typeof state === 'undefined') {
return 0
}
switch (action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}
var store = Redux.createStore(counter)
var valueEl = document.getElementById('value')
function render() {
valueEl.innerHTML = store.getState().toString()
}
render()
store.subscribe(render)
document.getElementById('increment')
.addEventListener('click', function () {
store.dispatch({ type: 'INCREMENT' })
})
document.getElementById('decrement')
.addEventListener('click', function () {
store.dispatch({ type: 'DECREMENT' })
})
document.getElementById('incrementIfOdd')
.addEventListener('click', function () {
if (store.getState() % 2 !== 0) {
store.dispatch({ type: 'INCREMENT' })
}
})
document.getElementById('incrementAsync')
.addEventListener('click', function () {
setTimeout(function () {
store.dispatch({ type: 'INCREMENT' })
}, 1000)
})
</script>
</body>
</html>
ボタンで増減する数字がreduxのstateです。
+
ボタンを押すと'INCREMENT'
actionがdispatchされ値が1増加し、
-
ボタンを押すと'DECREMENT'
actionがdispatchされ値が1減少します。
実際の動作についてはcodepenの動くデモを確認して頂ければと思います。
早速コードを上から順に解説していきましょう。
1. HTMLの記述
カウンターの数字と各種ボタンをHTMLで記述します。
JSで扱う要素にはidが与えられています。
<!DOCTYPE html>
<html>
<head>
<title>Redux basic example</title>
<script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>
</head>
<body>
<div>
<p>
Clicked: <span id="value">0</span> times
<button id="increment">+</button>
<button id="decrement">-</button>
<button id="incrementIfOdd">Increment if odd</button>
<button id="incrementAsync">Increment async</button>
</p>
</div>
2.reducerの作成
Reduxアプリを作成する際にまず着手するのがreducerでしょう。
こういう命令(action)を受け取った時、このようにstateを更新する(switch文の内部)、
というアプリの挙動を定義する場所だからです。
ここでは以下の振る舞いを定義しています。
- stateが未定義の時は0をstateとして返却する(initialState)
-
'INCREMENT'
action.typeがdispatchされた時は今のstateを+1したものを返却する -
'DECREMENT'
action.typeがdispatchされた時は今のstateを-1したものを返却する
function counter(state, action) {
if (typeof state === 'undefined') {
return 0
}
switch (action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}
reducerは第一引数に現在のstate、第二引数にactionを受け取る純粋な関数です。
Reduxの公式ページトップを開き一行目を見ると
Redux is a predictable state container for JavaScript apps.
と書かれていますが、
その純粋関数経由でしかstateの変更を受け付けない構造とそこから生まれる一貫したアプリ開発の取り決めがReduxの中核なのではと個人的には感じています。
3.storeオブジェクトを作成する
createStore()関数でstoreオブジェクトを作成します。
第一引数には先ほど作成したreducerであるcounter関数を渡しています。
var store = Redux.createStore(counter)
このstoreオブジェクトが実質的にReduxライブラリそのものと言っても差し支えないと思います。
というのもReduxって何?という疑問を一番直接的に解決するのがcreateStore()関数の実装を読む事、
と言えるくらいReduxの基本要素がほとんど詰まっているのでReduxの実像が掴めずモヤモヤしている方にはぜひソースコードリーディングをおすすめします。
コメント含めて270行なのでぜひ
コメントがドキュメント並に充実しており、「読んで分かるソースコード」はこんなに素晴らしのかと感心しました。
step by stepで解説したいところではありますが、
counter-vanillaのサンプルで扱っていない要素も出現するのでcreateStore()関数の中で行っていることを要約します。
・第一引数に渡したcounter reducerを登録する
・store.getState()メソッドの定義
・store.subscribe()メソッドの定義
・store.dispatch()メソッドの定義
・store.replaceReducer()メソッドの定義(counter-vanillaサンプルでは呼び出しません)
・store.observable()メソッドの定義(counter-vanillaサンプルでは呼び出しません)
・dispatch({ type: ActionTypes.INIT })
ここではstateを初期化するため定義したばかりのdispatch()関数を早速利用しています。
既にReduxを利用されている方はReduxDevToolsで'@@redux/INIT'
の文字列を見た事があると思いますが、
これはcreateStore()関数内部でdispatchされたactionなのです。
// When a store is created, an "INIT" action is dispatched so that every
// reducer returns their initial state. This effectively populates
// the initial state tree.
dispatch({ type: ActionTypes.INIT })
しかしどうしてdispatchする事がstateの初期化に繋がるのでしょうか。
stateはcreateStore()関数スコープ内のcurrentState変数に格納されているのですが、
通常initialStateとして設定したいオブジェクトがあればcreateStore()の第二引数にpreloadedStateとして渡して
export default function createStore(reducer, preloadedState, enhancer)
それをcurrentStateとして格納します。
let currentState = preloadedState
しかし今回createStore()関数に引数を一つしか渡しておらずpreloadedStateはnull、
currentStateもnullとなりstateが存在しない状態となっています。
そこでdispatch()関数の実装を確認してみるとreducerを実行し、
結果をcurrentStateに格納しているのが分かります。
currentState = currentReducer(currentState, action)
これにより第二引数にinitialStateを与えない場合でもreducerがstateが得られるようになっています。
余談ですがこのcurrentState変数がcreateStore()関数のローカルスコープ内に存在しているため通常外部からstateを変更する事はできず、
外部へ提供しているstateの変更手段はdispatch()メソッドのみであるという実装によりFluxアーキテクチャを担保しています。
ここは外部へ公開する、しない部分の制御でアーキテクチャを形作るOOPっぽさを感じますね。
最後にこれまで定義した関数達をプロパティに格納しreturnします。
return {
dispatch,
subscribe,
getState,
replaceReducer,
}
4. render()関数の作成
stateから数字を取得してHTMLに反映する処理を記述しています。
Reactのお仕事をそのままプログラミングしているように見えますね。
render()関数は以下のsubscribe()で利用します。
var valueEl = document.getElementById('value')
function render() {
valueEl.innerHTML = store.getState().toString()
}
render()
5. render()関数をsubscribe()する
render()関数を引数に渡してsubscribe()を起動します。
もちろんstateの更新をトリガーにrender()関数がされviewの更新されるようにするための準備です。
store.subscribe(render)
ここでrenderがどのように登録され、state更新時に呼び出される仕組みとなっているのか見ていきましょう。
まずsubscribe()の処理を見ると第一引数が関数ならnextListenersとして登録する処理を行っている事が分かります。(nextListenersはArrayです)
function subscribe(listener) {
if (typeof listener !== 'function') {
throw new Error('Expected listener to be a function.')
}
if (isDispatching) {
throw new Error(
'You may not call store.subscribe() while the reducer is executing. ' +
'If you would like to be notified after the store has been updated, subscribe from a ' +
'component and invoke store.getState() in the callback to access the latest state. ' +
'See http://redux.js.org/docs/api/Store.html#subscribe for more details.'
)
}
let isSubscribed = true
ensureCanMutateNextListeners()
nextListeners.push(listener)
Reduxにおいてstateが更新されるタイミングはstoreへactionをdispatchした時なので、次はdispatch()の内部を見ていきましょう。
function dispatch(action) {
if (!isPlainObject(action)) {
throw new Error(
'Actions must be plain objects. ' +
'Use custom middleware for async actions.'
)
}
if (typeof action.type === 'undefined') {
throw new Error(
'Actions may not have an undefined "type" property. ' +
'Have you misspelled a constant?'
)
}
if (isDispatching) {
throw new Error('Reducers may not dispatch actions.')
}
try {
isDispatching = true
currentState = currentReducer(currentState, action)
} finally {
isDispatching = false
}
const listeners = currentListeners = nextListeners
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i]
listener()
}
return action
}
onst listeners = currentListeners = nextListeners
の部分に注目です。
プログラミングに慣れている方ならすぐに典型的なイベントリスナー機構である事に気がつくでしょう。
dispatch()の内部ではつまりreducerを実行して新しいcurrentStateを算出した後にsubscribe()で登録していた関数を呼び出す構造になっているということです。
const listeners = currentListeners = nextListeners
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i]
listener()
}
他にもReduxらしいコードが以下の部分です。
おそらくReduxを使い出した頃にmiddlewareを知らず以下のコンソールメッセージに遭遇された方も多いかと思います。
if (!isPlainObject(action)) {
throw new Error(
'Actions must be plain objects. ' +
'Use custom middleware for async actions.'
)
}
これは結局reducerの引数に未解決の、不定のものは渡せないのでバリデーションをしています。
reducerはプレーンなcurrentStateオブジェクトとactionオブジェクトを受け取って新しいstateを返すだけの純粋な関数ですよ、というコアコンセプトを保証しています。
ちなみにisPlainObject()のコードはこちらです。
/**
* @param {any} obj The object to inspect.
* @returns {boolean} True if the argument appears to be a plain object.
*/
export default function isPlainObject(obj) {
if (typeof obj !== 'object' || obj === null) return false
let proto = obj
while (Object.getPrototypeOf(proto) !== null) {
proto = Object.getPrototypeOf(proto)
}
return Object.getPrototypeOf(obj) === proto
}
初期時点のprototypeとルートオブジェクトのprototypeを比較して同じならtrueとするようですね。
もしプログラマがprototypeを弄ったりしているとfalseになるようにしているのでしょうか。
あとはisDispatchingというbool値で一度に1つまでのdispatchしか受け付けないように制御しているコードも見られます。
至ってシンプルですね、たまたまかもしれませんがisDispatchingの制御処理が目線を動かさなくても俯瞰出来る分量になっているのが読み手にやさしいなと思いました。
if (isDispatching) {
throw new Error('Reducers may not dispatch actions.')
}
try {
isDispatching = true
currentState = currentReducer(currentState, action)
} finally {
isDispatching = false
}
6. Storeへactionをdispatchする処理の作成
最後に各種ボタンにstoreへactionをdispatchする処理をバインディングするコードを記述します。
document.getElementById('increment')
.addEventListener('click', function () {
store.dispatch({ type: 'INCREMENT' })
})
document.getElementById('decrement')
.addEventListener('click', function () {
store.dispatch({ type: 'DECREMENT' })
})
document.getElementById('incrementIfOdd')
.addEventListener('click', function () {
if (store.getState() % 2 !== 0) {
store.dispatch({ type: 'INCREMENT' })
}
})
document.getElementById('incrementAsync')
.addEventListener('click', function () {
setTimeout(function () {
store.dispatch({ type: 'INCREMENT' })
}, 1000)
})
ここがHTML・DOMのGUIとReduxが結合されているポイントです。
Reactで使う際は普通react-reduxというライブラリを使ってmapStateToProps()、connect()などでこれ相当するような処理を記述します。
これでcounter-vanillaのソースコードは全部です、
ブラウザーで読み込めばカウンターが動きます。
まとめ
ソースコードを読んでみたことで利用する際の不安感が大幅に減ったのはもちろん、
ドキュメントやブログポストをいくら読んでもしっくり来なかったがソースコードという実物を見る事で解決出来た部分がたくさんあったように思います。
読む前はdispatchしないでstate変更出来るの?したらどうなるの?とか素朴な疑問を色々抱えながら使っていたので同じように皆さんの理解の一助になる部分があれば幸いです。
以上、お疲れ様でした。
-
「FluxアーキテクチャとReudx」の内容はこちらの書籍を参考にしています。The Complete Redux Book ↩
-
Flux | Application Architecture for Building User Interfaces ↩