dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

作って学ぶRedux Middleware

はじめに

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

公式ドキュメントを写経しただけですが、コードはこちらに置いてあります。
いちおう各章に対応したタグもあります。


Problem: Logging

ここでは redux-logger のような logging 機能の実装を例にして、Middleware の仕組みを学んでいきます。
すなわち、ある action が dispatch されたとき、action の内容と、それによって変更された後の state の情報を console に出力するような機能です。


Attempt #1: Logging Manually

最も単純な実装は、 dispatch(action) のたびに手動で console.log を仕込むことでしょう。

(source)

let action = addTodo('Use Redux')

console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())

当たり前ですがこれを毎回やるのはつらいです。


Attempt #2: Wrapping Dispatch

次に、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 してやる必要があります。


Attempt #3: Monkeypatching Dispatch

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 されることになります。


Problem: Crash Reporting

先ほどの 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;
  }
}

// Dummy
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 に適用しないといけないあたりがいまいちです。


Attempt #4: Hiding Monkeypatching

メソッドを好きなように置き換えてしまう monkeypatch はハックのようなもので、置換するかわりに新しい dispatch 関数を返すようにしてはどうでしょうか。

(source)

function logger(store) {
  let next = store.dispatch;

  // Previously:
  // store.dispatch = function dispatchAndLog(action) {

  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 していることには変わりません。


Attempt #5: Removing Monkeypatching

そもそもどうして 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 #6: Naïvely Applying the Middleware

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), // createStore() 時に middleware を適用してる
);

NOTE:このへんいまいち理解できてませんが


The Final Approach

というわけで最終的にはこのようになりました。

(source)

// middleware
const logger = store => next => action => {
  console.log('dispatching', action);
  let result = next(action);
  console.log('next state', store.getState());
  return result;
}
// crashReporter は省略
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 そのものではなく dispatchgetState() を受け取る」とあるのにこれでうまく動いているのはなぜ?と思いましたが
インターフェースが store => next => ... でも ({ dispatch, getState }) => next => ... でも
一番外側は object を受け取ってることには変わりないですね。


おわりに

というわけで順を追っていくとなんとなく middleware の仕組みや middleware 関数の読み方が理解できました。

  • middleware は dispatch 関数を拡張する役割
  • next は元の dispatch 関数

ということだけ覚えておいて、あとは「こういう書き方をするんだなー」ぐらいでもいいかなと思いました。

ちなみに公式ドキュメントの最後には Seven Examples という章があり、そこで logger や crashReporter 以外のサンプルも載っているので参考になります。