この記事は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アーキテクチャとRedux1
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を組み合わせたホットリロード、Reduxを使った状態のタイムトラベルといったインパクトの大きいデモを行います。
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行なのでぜひ
※ちょっと長いですがv4.0.4時点のリンクとコード全文を掲載しています、7割ほどはコメントなので手っ取り早くコードだけ確認したい方はJS部分だけサラッ読んで頂ければ良いかと思います!
createStore()のソースコード
import $$observable from 'symbol-observable'
import ActionTypes from './utils/actionTypes'
import isPlainObject from './utils/isPlainObject'
/**
* Creates a Redux store that holds the state tree.
* The only way to change the data in the store is to call `dispatch()` on it.
*
* There should only be a single store in your app. To specify how different
* parts of the state tree respond to actions, you may combine several reducers
* into a single reducer function by using `combineReducers`.
*
* @param {Function} reducer A function that returns the next state tree, given
* the current state tree and the action to handle.
*
* @param {any} [preloadedState] The initial state. You may optionally specify it
* to hydrate the state from the server in universal apps, or to restore a
* previously serialized user session.
* If you use `combineReducers` to produce the root reducer function, this must be
* an object with the same shape as `combineReducers` keys.
*
* @param {Function} [enhancer] The store enhancer. You may optionally specify it
* to enhance the store with third-party capabilities such as middleware,
* time travel, persistence, etc. The only store enhancer that ships with Redux
* is `applyMiddleware()`.
*
* @returns {Store} A Redux store that lets you read the state, dispatch actions
* and subscribe to changes.
*/
export default function createStore(reducer, preloadedState, enhancer) {
if (
(typeof preloadedState === 'function' && typeof enhancer === 'function') ||
(typeof enhancer === 'function' && typeof arguments[3] === 'function')
) {
throw new Error(
'It looks like you are passing several store enhancers to ' +
'createStore(). This is not supported. Instead, compose them ' +
'together to a single function.'
)
}
if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
enhancer = preloadedState
preloadedState = undefined
}
if (typeof enhancer !== 'undefined') {
if (typeof enhancer !== 'function') {
throw new Error('Expected the enhancer to be a function.')
}
return enhancer(createStore)(reducer, preloadedState)
}
if (typeof reducer !== 'function') {
throw new Error('Expected the reducer to be a function.')
}
let currentReducer = reducer
let currentState = preloadedState
let currentListeners = []
let nextListeners = currentListeners
let isDispatching = false
/**
* This makes a shallow copy of currentListeners so we can use
* nextListeners as a temporary list while dispatching.
*
* This prevents any bugs around consumers calling
* subscribe/unsubscribe in the middle of a dispatch.
*/
function ensureCanMutateNextListeners() {
if (nextListeners === currentListeners) {
nextListeners = currentListeners.slice()
}
}
/**
* Reads the state tree managed by the store.
*
* @returns {any} The current state tree of your application.
*/
function getState() {
if (isDispatching) {
throw new Error(
'You may not call store.getState() while the reducer is executing. ' +
'The reducer has already received the state as an argument. ' +
'Pass it down from the top reducer instead of reading it from the store.'
)
}
return currentState
}
/**
* Adds a change listener. It will be called any time an action is dispatched,
* and some part of the state tree may potentially have changed. You may then
* call `getState()` to read the current state tree inside the callback.
*
* You may call `dispatch()` from a change listener, with the following
* caveats:
*
* 1. The subscriptions are snapshotted just before every `dispatch()` call.
* If you subscribe or unsubscribe while the listeners are being invoked, this
* will not have any effect on the `dispatch()` that is currently in progress.
* However, the next `dispatch()` call, whether nested or not, will use a more
* recent snapshot of the subscription list.
*
* 2. The listener should not expect to see all state changes, as the state
* might have been updated multiple times during a nested `dispatch()` before
* the listener is called. It is, however, guaranteed that all subscribers
* registered before the `dispatch()` started will be called with the latest
* state by the time it exits.
*
* @param {Function} listener A callback to be invoked on every dispatch.
* @returns {Function} A function to remove this change listener.
*/
function subscribe(listener) {
if (typeof listener !== 'function') {
throw new Error('Expected the 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 https://redux.js.org/api-reference/store#subscribe(listener) for more details.'
)
}
let isSubscribed = true
ensureCanMutateNextListeners()
nextListeners.push(listener)
return function unsubscribe() {
if (!isSubscribed) {
return
}
if (isDispatching) {
throw new Error(
'You may not unsubscribe from a store listener while the reducer is executing. ' +
'See https://redux.js.org/api-reference/store#subscribe(listener) for more details.'
)
}
isSubscribed = false
ensureCanMutateNextListeners()
const index = nextListeners.indexOf(listener)
nextListeners.splice(index, 1)
}
}
/**
* Dispatches an action. It is the only way to trigger a state change.
*
* The `reducer` function, used to create the store, will be called with the
* current state tree and the given `action`. Its return value will
* be considered the **next** state of the tree, and the change listeners
* will be notified.
*
* The base implementation only supports plain object actions. If you want to
* dispatch a Promise, an Observable, a thunk, or something else, you need to
* wrap your store creating function into the corresponding middleware. For
* example, see the documentation for the `redux-thunk` package. Even the
* middleware will eventually dispatch plain object actions using this method.
*
* @param {Object} action A plain object representing “what changed”. It is
* a good idea to keep actions serializable so you can record and replay user
* sessions, or use the time travelling `redux-devtools`. An action must have
* a `type` property which may not be `undefined`. It is a good idea to use
* string constants for action types.
*
* @returns {Object} For convenience, the same action object you dispatched.
*
* Note that, if you use a custom middleware, it may wrap `dispatch()` to
* return something else (for example, a Promise you can await).
*/
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
}
/**
* Replaces the reducer currently used by the store to calculate the state.
*
* You might need this if your app implements code splitting and you want to
* load some of the reducers dynamically. You might also need this if you
* implement a hot reloading mechanism for Redux.
*
* @param {Function} nextReducer The reducer for the store to use instead.
* @returns {void}
*/
function replaceReducer(nextReducer) {
if (typeof nextReducer !== 'function') {
throw new Error('Expected the nextReducer to be a function.')
}
currentReducer = nextReducer
// This action has a similiar effect to ActionTypes.INIT.
// Any reducers that existed in both the new and old rootReducer
// will receive the previous state. This effectively populates
// the new state tree with any relevant data from the old one.
dispatch({ type: ActionTypes.REPLACE })
}
/**
* Interoperability point for observable/reactive libraries.
* @returns {observable} A minimal observable of state changes.
* For more information, see the observable proposal:
* https://github.com/tc39/proposal-observable
*/
function observable() {
const outerSubscribe = subscribe
return {
/**
* The minimal observable subscription method.
* @param {Object} observer Any object that can be used as an observer.
* The observer object should have a `next` method.
* @returns {subscription} An object with an `unsubscribe` method that can
* be used to unsubscribe the observable from the store, and prevent further
* emission of values from the observable.
*/
subscribe(observer) {
if (typeof observer !== 'object' || observer === null) {
throw new TypeError('Expected the observer to be an object.')
}
function observeState() {
if (observer.next) {
observer.next(getState())
}
}
observeState()
const unsubscribe = outerSubscribe(observeState)
return { unsubscribe }
},
[$$observable]() {
return this
}
}
}
// 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 })
return {
dispatch,
subscribe,
getState,
replaceReducer,
[$$observable]: observable
}
}
step by stepで解説したいところではありますが、
counter-vanillaのサンプルで扱っていない要素も出現するので↑のcreateStore()
の中で行っていることを要約すると...
createStore()
の中で行っていること
※該当の処理を行なっている行へリンクしています。
・store.replaceReducer()メソッドの定義(counter-vanillaサンプルでは呼び出しません)
・store.observable()メソッドの定義(counter-vanillaサンプルでは呼び出しません)
・dispatch({ type: ActionTypes.INIT })
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
}
const 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アーキテクチャとRedux」の内容はこちらの書籍を参考にしています。The Complete Redux Book ↩
-
Flux | Application Architecture for Building User Interfaces ↩