はじめに
Redux で非同期処理を行うための Middleware である redux-thunk は、わずか 10 数行のこのようなコードです。
https://github.com/gaearon/redux-thunk/blob/v2.2.0/src/index.js
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
最初にこれを見たとき、「なんでこんなにアロー関数が連続してるんだろう?」とか「 next
ってなんだ?」とかさっぱりわかりませんでした。
というわけで公式のドキュメント
Redux Fundamentals, Part 4: Store | Redux
を読んでみたところ、Step by Step で Middleware の概念を説明していてわかりやすかったので、その流れにそって Middleware とは何かを自分用にメモしておきます。
コード
https://github.com/zaki-yama/learn-redux-middleware
公式ドキュメントを写経しただけですが、コードはこちらに置いてあります。
いちおう各章に対応したタグもあります。
ここでは redux-logger のような logging 機能の実装を例にして、Middleware の仕組みを学んでいきます。
すなわち、ある action が dispatch されたとき、action の内容と、それによって変更された後の state の情報を console に出力するような機能です。
最も単純な実装は、 dispatch(action)
のたびに手動で console.log を仕込むことでしょう。
(source)
let action = addTodo('Use Redux')
console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())
当たり前ですがこれを毎回やるのはつらいです。
次に、logging まで含めた一連の処理を wrap した関数を作るというのはどうでしょうか。
(source)
function dispatchAndLog(store, action) {
console.log('dispatching', action);
store.dispatch(action);
console.log('next state', store.getState());
}
dispatchAndLog(store, addTodo('Use Redux'));
先ほどよりはマシになりましたが、dispatch する箇所すべてでこの関数を import してやる必要があります。
store の dispatch
関数を別の関数で置き換えてはどうでしょうか?いわゆる monkeypatch です。
store はメソッドがいくつか定義された plain object なので可能です。
(source
let next = store.dispatch;
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
store.dispatch(addTodo('Use Redux'));
next
が登場しました。元の dispatch
を指すようですね。
dispatch
そのものの定義を書き換えたので、以後どこで dispatch
が呼ばれても logging されることになります。
先ほどの monkeypatch でうまくいったように見えましたが、logging と同じようなことを 2 つ以上やろうとするとどうなるでしょうか。
ドキュメントで挙げている例としては、Sentry というエラーレポートサービスへの Crash Reporting です。
こんな感じで patch 用関数を logging と crash reporting それぞれで用意し、順番に store に適用する必要があります。
(source)
function patchStoreToAddLogging(store) {
let next = store.dispatch;
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
}
const Raven = {
captureException: function(err, optional) {
console.error(err);
console.log(optional);
},
};
function patchStoreToAddCrashReporting(store) {
let next = store.dispatch;
store.dispatch = function dispatchAndReportErrors(action) {
try {
return next(action);
} catch (err) {
console.error('Caught an exception!', err);
Raven.captureException(err, {
extra: {
action,
state: store.getState(),
},
});
throw err;
}
}
}
patchStoreToAddLogging(store);
patchStoreToAddCrashReporting(store);
store.dispatch(addTodo('Use Redux'));
NOTE:Raven
というのが Sentry というサービスを使う際に利用するオブジェクトのようですが、サンプルなので適当なダミーオブジェクトとしています
patch 用関数を順番に store に適用しないといけないあたりがいまいちです。
メソッドを好きなように置き換えてしまう monkeypatch はハックのようなもので、置換するかわりに新しい dispatch
関数を返すようにしてはどうでしょうか。
(source)
function logger(store) {
let next = store.dispatch;
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
}
そして、実際に monkeypatch する部分をこのように定義します。
function applyMiddlewareByMonkeypatching(store, middlewares) {
middlewares = middlewares.slice();
middlewares.reverse();
middlewares.forEach(middleware => {
store.dispatch = middleware(store);
});
}
applyMiddlewareByMonkeypatching(store, [logger, crashReporter]);
middlewares.forEach
のところで順番に middleware を適用しており、これが結果的に順番に dispatch を置換するような処理になっています。
うまくまとまってきましたが、monkeypatch していることには変わりません。
そもそもどうして dispatch
を overwrite しないといけなかったかというと、(middleware を適用した) dispatch
を後で実行するからというのももちろんありますが、重要なのは「すべての middleware は直前の middleware が適用された後の dispatch
を参照できる」必要があったからでした。
※訳が怪しい。原文は
Why do we even overwrite dispatch
? Of course, to be able to call it later, but there's also another reason: so that every middleware can access (and call) the previously wrapped store.dispatch
:
つまり、先ほどの
function logger(store) {
let next = store.dispatch;
...
の store.dispatch
は、一番最初の dispatch
ではなく、直前の middleware が適用された結果になっていないと chaining が成立しません。
が、dispatch
を置き換える方法以外にもこれを実現する手段はあって、それは dispatch
を引数として受け取るようにしてしまうというものです。
function logger(store) {
return function wrapDispatchToAddLogging(next) {
return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}
}
そんでもってこれは ES2015 のアロー関数を使うとこのように書けます。
(source)
const logger = store => next => action => {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
最初に見た redux-thunk の実装とほとんど同じになりました。
ここまでで middelware は next
という dispatch
関数を取り、新しい dispatch
関数を返す関数になりました。
store
は別に使ってないので一番外側の関数はいらないんじゃないか?と思いますが
getState()
を使って state 全体にアクセスできた方が色々と都合がいいのでそうなっているそうです。
Attempt #4 で実装した applyMiddlewareByMonkeypatching()
のかわりに、monkeypatch を排除した middleware を適用するための applyMiddleware()
を書いてみます。
(source)
function applyMiddleware(store, middlewares) {
middlewares = middlewares.slice();
middlewares.reverse();
let dispatch = store.dispatch;
middlewares.forEach((middleware) => {
dispatch = middleware(store)(dispatch);
});
return Object.assign({}, store, { dispatch });
}
const storeWithMiddlewares = applyMiddleware(store, [logger, crashReporter]);
storeWithMiddlewares.dispatch(addTodo('Use Redux'));
middleware を順次適用している部分の書き方が変わりました。
NOTE:ドキュメントには書いてませんが、この時点で実際の動きを確かめる場合は applyMiddleware()
が新しい store を返していることに注意です。↑の例では
storeWithMiddlewares
に格納しています。
Redux の実際の applyMiddleware()
も似たような実装ですが、以下の3点が異なります。
1)middleware の引数になっているのは実際には store そのものではなく、store の API の一部である dispatch(action)
と getState()
です。
NOTE:なので、redux-thunk の一番外側は
return ({ dispatch, getState }) => next => action => {
となっていますね。
2)実際の applyMiddleware()
では、middleware 内で next(action)
ではなく store.dispatch(action)
が呼ばれたときも、それまでの middleware が適用された dispatch
が実行されるような考慮がされています。
3)store に対して middleware の適用が1回きりになるよう、実際の applyMiddleware()
は store そのものに対してではなく createStore()
を扱う関数になっています。
つまり
(store, middlewares) => store
(store と middlewares を受け取り、新しい store を返す関数)
ではなく
(...middlewares) => (createStore) => createStore
(middlwares を受け取り、「createStore()
関数を受け取ると middleware が適用された後の createStore()
を返す」関数を返す関数)
です。
ただ、実際には使う前に createStore()
に関数を適用するのは面倒なので、
createStore()
の最後の optional な引数として applyMiddleware()
を受け取れるようになっています。
const store = createStore(
todoApp,
applyMiddleware(logger, crashReporter),
);
NOTE:このへんいまいち理解できてませんが
というわけで最終的にはこのようになりました。
(source)
const logger = store => next => action => {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
import { createStore, applyMiddleware } from 'redux';
import { addTodo } from './actions';
import todoApp from './reducers';
const store = createStore(
todoApp,
applyMiddleware(logger, crashReporter),
);
store.dispatch(addTodo('Use Redux'));
NOTE:Attempt #6 で「1)実際の applyMiddleware()
は store そのものではなく dispatch
と getState()
を受け取る」とあるのにこれでうまく動いているのはなぜ?と思いましたが
インターフェースが store => next => ...
でも ({ dispatch, getState }) => next => ...
でも
一番外側は object を受け取ってることには変わりないですね。
おわりに
というわけで順を追っていくとなんとなく middleware の仕組みや middleware 関数の読み方が理解できました。
- middleware は
dispatch
関数を拡張する役割
next
は元の dispatch
関数
ということだけ覚えておいて、あとは「こういう書き方をするんだなー」ぐらいでもいいかなと思いました。
ちなみに公式ドキュメントの最後には Seven Examples という章があり、そこで logger や crashReporter 以外のサンプルも載っているので参考になります。