本文へスキップ

ミドルウェア

ミドルウェアは、"Redux Fundamentals" チュートリアルで実際に動作しているのをご覧になったと思います。ExpressKoaのようなサーバーサイドライブラリを使用されたことがある場合は、ミドルウェアの概念に既に馴染みがあるかもしれません。これらのフレームワークでは、ミドルウェアとは、フレームワークがリクエストを受信してからレスポンスを生成するまでの間に配置できるコードのことです。例えば、ExpressやKoaのミドルウェアは、CORSヘッダー、ロギング、圧縮などを追加できます。ミドルウェアの最も優れた機能は、チェーンで構成できることです。単一プロジェクトで複数の独立したサードパーティ製ミドルウェアを使用できます。

Reduxミドルウェアは、ExpressやKoaのミドルウェアとは異なる問題を解決しますが、概念的には同様です。アクションのディスパッチと、そのアクションがReducerに到達する瞬間の間に、サードパーティの拡張ポイントを提供します。 Reduxミドルウェアは、ロギング、クラッシュレポート、非同期APIとの通信、ルーティングなどに使用されます。

この記事は、概念を理解するための詳細なイントロと、いくつかの実践的な例に分割されています。退屈と刺激を感じながら、それらの間を行き来すると役立つ場合があります。

ミドルウェアの理解

ミドルウェアは非同期API呼び出しなど、さまざまな用途に使用できますが、それがどこから来ているのかを理解することが非常に重要です。ロギングとクラッシュレポートを例として使用して、ミドルウェアにつながる思考プロセスを説明します。

問題:ロギング

Reduxの利点の1つは、状態の変化を予測可能で透明にすることです。アクションがディスパッチされるたびに、新しい状態が計算され保存されます。状態はそれ自体では変化せず、特定のアクションの結果としてのみ変化します。

アプリで発生するすべてのアクションとその後の状態をログに記録できたら便利ではありませんか?何か問題が発生した場合、ログを確認して、どのアクションが状態を破損したかを突き止められます。

Reduxでどのようにアプローチしますか?

試行1:手動でのロギング

最も単純な解決策は、store.dispatch(action)を呼び出すたびに、アクションと次の状態を自分でログに記録することです。これは真の解決策ではなく、問題を理解するための最初のステップにすぎません。

注記

react-reduxまたは同様のバインディングを使用している場合、コンポーネントでストアインスタンスに直接アクセスできない可能性があります。次の数段落では、ストアを明示的に渡すと仮定します。

ToDoを作成する際に、次のように呼び出すとします。

store.dispatch(addTodo('Use Redux'))

アクションと状態をログに記録するために、次のように変更できます。

const action = addTodo('Use Redux')

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

これは目的の効果を生み出しますが、毎回行うのは望ましくありません。

試行2:ディスパッチのラップ

ロギングを関数に抽出できます。

function dispatchAndLog(store, action) {
console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())
}

その後、store.dispatch()の代わりにどこでも使用できます。

dispatchAndLog(store, addTodo('Use Redux'))

ここで終わらせることもできますが、毎回特別な関数をインポートするのはあまり便利ではありません。

試行3:ディスパッチのモンキーパッチ

ストアインスタンスのdispatch関数を置き換えるだけはどうでしょうか?Reduxストアはいくつかのメソッドを持つプレーンオブジェクトであり、JavaScriptを書いているので、dispatchの実装をモンキーパッチできます。

const next = store.dispatch
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}

これは既に私たちが望むものに近づいています!どこでアクションをディスパッチしても、確実にログに記録されます。モンキーパッチは決して適切な感じがしませんが、今のところはこれで我慢できます。

問題:クラッシュレポート

dispatch複数のこのような変換を適用したい場合はどうでしょうか?

私が思いつく別の便利な変換は、本番環境でのJavaScriptエラーのレポートです。グローバルなwindow.onerrorイベントは、一部の古いブラウザではスタック情報が提供されないため信頼性がなく、エラーが発生している理由を理解するにはスタック情報が不可欠です。

アクションのディスパッチの結果としてエラーが発生するたびに、スタックトレース、エラーの原因となったアクション、現在の状態を付けて、Sentryなどのクラッシュレポートサービスに送信できたら便利ではありませんか?これにより、開発環境でエラーを再現しやすくなります。

ただし、ロギングとクラッシュレポートは別々に維持することが重要です。理想的には、異なるモジュール、できれば異なるパッケージにする必要があります。そうでなければ、このようなユーティリティのエコシステムを持つことはできません。(ヒント:ミドルウェアとは何かが徐々にわかってきました!)

ロギングとクラッシュレポートが個別のユーティリティの場合、次のようになります。

function patchStoreToAddLogging(store) {
const next = store.dispatch
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}

function patchStoreToAddCrashReporting(store) {
const 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)

それでも、これは良くありません。

試行4:モンキーパッチの隠蔽

モンキーパッチはハックです。「好きなメソッドを置き換える」とはどういうAPIですか?代わりに、その本質を見つけ出しましょう。以前は、関数はstore.dispatchを置き換えていました。代わりに新しいdispatch関数を返すとしたらどうでしょうか?

function logger(store) {
const 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
}
}

実際のモンキーパッチをインプリメンテーションの詳細として適用するヘルパーをRedux内に提供できます。

function applyMiddlewareByMonkeypatching(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()

// Transform dispatch function with each middleware.
middlewares.forEach(middleware => (store.dispatch = middleware(store)))
}

これを使用して、次のように複数のミドルウェアを適用できます。

applyMiddlewareByMonkeypatching(store, [logger, crashReporter])

しかし、それでもモンキーパッチです。それをライブラリ内に隠しているという事実が、この事実を変えることはありません。

試行5:モンキーパッチの削除

そもそもなぜdispatchを上書きするのでしょうか?もちろん、後で呼び出すためですが、もう1つの理由もあります。すべてのミドルウェアが以前ラップされたstore.dispatchにアクセスして(呼び出す)ためです。

function logger(store) {
// Must point to the function returned by the previous middleware:
const next = store.dispatch

return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}

ミドルウェアのチェーン化に不可欠です!

applyMiddlewareByMonkeypatchingが最初のミドルウェアの処理後すぐにstore.dispatchを代入しないと、store.dispatchは元のdispatch関数のままになります。すると、2番目のミドルウェアも元のdispatch関数にバインドされます。

しかし、チェーン化を有効にする別の方法もあります。ミドルウェアは、storeインスタンスから読み取るのではなく、パラメータとしてnext()ディスパッチ関数を取得できます。

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
}
}
}

これは“もっと深く潜る必要がある”瞬間なので、これが理解できるようになるまでしばらく時間がかかるかもしれません。関数のカスケードは威圧的に感じられます。アロー関数は、このカリー化をより見やすくします。

const logger = store => next => action => {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}

const crashReporter = store => next => action => {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}

これがまさにReduxミドルウェアです。

これで、ミドルウェアはnext()ディスパッチ関数を取得し、ディスパッチ関数を返します。その関数は、左側のミドルウェアへのnext()として機能するなどします。getState()などのストアメソッドにアクセスすることも有用なので、storeは最上位レベルの引数として利用可能です。

試行6:ミドルウェアの単純な適用

applyMiddlewareByMonkeypatching()の代わりに、最終的に完全にラップされたdispatch()関数を取得し、それを用いてストアのコピーを返すapplyMiddleware()を書くことができます。

// Warning: Naïve implementation!
// That's *not* Redux API.
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 })
}

Reduxに付属するapplyMiddleware()の実装は似ていますが、3つの重要な点で異なります

  • ミドルウェアには、ストアAPIのサブセットのみを公開します:dispatch(action)getState()

  • ミドルウェアからnext(action)ではなくstore.dispatch(action)を呼び出した場合、アクションが実際には現在のミドルウェアを含むミドルウェアチェーン全体を再び移動するようにするトリックを実行します。これは非同期ミドルウェアに役立ちます。セットアップ中にdispatchを呼び出す際の注意点が以下に記載されています。

  • ミドルウェアを一度だけ適用できるようにするために、store自体ではなくcreateStore()で動作します。(store, middlewares) => storeではなく、シグネチャは(...middlewares) => (createStore) => createStoreです。

使用前にcreateStore()に関数を適用するのは面倒なので、createStore()はオプションの最後の引数を受け入れ、そのような関数を指定します。

注意点:セットアップ中のディスパッチ

applyMiddlewareがミドルウェアを実行して設定している間、store.dispatch関数はcreateStoreによって提供されるプレーンなバージョンを指します。ディスパッチすると、他のミドルウェアは適用されません。セットアップ中に別のミドルウェアとの相互作用を期待している場合、おそらくがっかりするでしょう。この予期しない動作のため、applyMiddlewareは、セットアップが完了する前にアクションをディスパッチしようとするとエラーをスローします。代わりに、共通のオブジェクト(API呼び出しミドルウェアの場合、これはAPIクライアントオブジェクトかもしれません)を介して別のミドルウェアと直接通信するか、コールバックを使用してミドルウェアが構築された後まで待つ必要があります。

最終的なアプローチ

今書いたこのミドルウェアを考えると

const logger = store => next => action => {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}

const crashReporter = store => next => action => {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}

Reduxストアに適用する方法は次のとおりです。

import { createStore, combineReducers, applyMiddleware } from 'redux'

const todoApp = combineReducers(reducers)
const store = createStore(
todoApp,
// applyMiddleware() tells createStore() how to handle middleware
applyMiddleware(logger, crashReporter)
)

これで完了です!ストアインスタンスにディスパッチされたアクションはすべて、loggercrashReporterを通過するようになります。

// Will flow through both logger and crashReporter middleware!
store.dispatch(addTodo('Use Redux'))

7つの例

上記のセクションを読んでいるうちに頭が混乱したなら、それを書いた時のことを想像してみてください。このセクションは、あなたと私にとってリラックスできるものであり、思考を活性化させる助けとなります。

以下の各関数は、有効なReduxミドルウェアです。有用性はそれぞれ異なりますが、少なくともどれも同様に面白いです。

/**
* Logs all actions and states after they are dispatched.
*/
const logger = store => next => action => {
console.group(action.type)
console.info('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
console.groupEnd()
return result
}

/**
* Sends crash reports as state is updated and listeners are notified.
*/
const crashReporter = store => next => action => {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}

/**
* Schedules actions with { meta: { delay: N } } to be delayed by N milliseconds.
* Makes `dispatch` return a function to cancel the timeout in this case.
*/
const timeoutScheduler = store => next => action => {
if (!action.meta || !action.meta.delay) {
return next(action)
}

const timeoutId = setTimeout(() => next(action), action.meta.delay)

return function cancel() {
clearTimeout(timeoutId)
}
}

/**
* Schedules actions with { meta: { raf: true } } to be dispatched inside a rAF loop
* frame. Makes `dispatch` return a function to remove the action from the queue in
* this case.
*/
const rafScheduler = store => next => {
const queuedActions = []
let frame = null

function loop() {
frame = null
try {
if (queuedActions.length) {
next(queuedActions.shift())
}
} finally {
maybeRaf()
}
}

function maybeRaf() {
if (queuedActions.length && !frame) {
frame = requestAnimationFrame(loop)
}
}

return action => {
if (!action.meta || !action.meta.raf) {
return next(action)
}

queuedActions.push(action)
maybeRaf()

return function cancel() {
queuedActions = queuedActions.filter(a => a !== action)
}
}
}

/**
* Lets you dispatch promises in addition to actions.
* If the promise is resolved, its result will be dispatched as an action.
* The promise is returned from `dispatch` so the caller may handle rejection.
*/
const vanillaPromise = store => next => action => {
if (typeof action.then !== 'function') {
return next(action)
}

return Promise.resolve(action).then(store.dispatch)
}

/**
* Lets you dispatch special actions with a { promise } field.
*
* This middleware will turn them into a single action at the beginning,
* and a single success (or failure) action when the `promise` resolves.
*
* For convenience, `dispatch` will return the promise so the caller can wait.
*/
const readyStatePromise = store => next => action => {
if (!action.promise) {
return next(action)
}

function makeAction(ready, data) {
const newAction = Object.assign({}, action, { ready }, data)
delete newAction.promise
return newAction
}

next(makeAction(false))
return action.promise.then(
result => next(makeAction(true, { result })),
error => next(makeAction(true, { error }))
)
}

/**
* Lets you dispatch a function instead of an action.
* This function will receive `dispatch` and `getState` as arguments.
*
* Useful for early exits (conditions over `getState()`), as well
* as for async control flow (it can `dispatch()` something else).
*
* `dispatch` will return the return value of the dispatched function.
*/
const thunk = store => next => action =>
typeof action === 'function'
? action(store.dispatch, store.getState)
: next(action)

// You can use all of them! (It doesn't mean you should.)
const todoApp = combineReducers(reducers)
const store = createStore(
todoApp,
applyMiddleware(
rafScheduler,
timeoutScheduler,
thunk,
vanillaPromise,
readyStatePromise,
logger,
crashReporter
)
)