このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →
ミドルウェア
"Redux Fundamentals" チュートリアルでミドルウェアの動作を確認しました。Express や Koa のようなサーバーサイドライブラリを使ったことがあれば、ミドルウェア の概念にもすでに馴染みがあるでしょう。これらのフレームワークでは、ミドルウェアはフレームワークがリクエストを受信してからレスポンスを生成するまでの間に配置できるコードです。例えば Express や Koa のミドルウェアは CORS ヘッダーの追加、ロギング、圧縮などを実装します。ミドルウェアの最大の特徴はチェーンで合成可能な点です。複数の独立したサードパーティ製ミドルウェアを単一プロジェクトで使用できます。
Redux ミドルウェアは Express や Koa とは異なる問題を解決しますが、概念的には似たアプローチを取ります。アクションをディスパッチしてからレデューサーに到達するまでの間のサードパーティ拡張ポイントを提供します。 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: dispatch のラッピング
ロギング処理を関数に抽出できます:
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 のモンキーパッチ
ストアインスタンスの 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を上書きするのでしょう? 後で呼び出すため当然ですが、もう一つの理由があります:各ミドルウェアが以前にラップされた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)
)
以上です!これでストアインスタンスにディスパッチされる全アクションがloggerとcrashReporterを通過します:
// 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
)
)