Middleware: How middleware enable adding additional capabilities to the Redux store">Middleware: How middleware enable adding additional capabilities to the Redux store">
跳至主要內容

中間件

您已在 "Redux Fundamentals" 教學 中看到中間件的實際應用。如果您使用過像是 ExpressKoa 等伺服器端程式庫,您可能已經熟悉中間件的概念。在這些框架中,中間件是您可以在框架接收要求和框架產生回應之間放置的程式碼。例如,Express 或 Koa 中間件可能會新增 CORS 標頭、記錄、壓縮等等。中間件最棒的功能是它可以在鏈中組成。您可以在單一專案中使用多個獨立的第三方中間件。

Redux 中介軟體解決的問題與 Express 或 Koa 中介軟體不同,但在概念上類似。它提供一個第三方擴充點,介於發送動作與動作到達 reducer 之間。人們使用 Redux 中介軟體進行記錄、錯誤回報、與非同步 API 溝通、路由等。

本文分為深入的簡介,幫助你了解概念,以及 幾個實務範例,在最後展示中介軟體的強大功能。你可能會發現,在感到無聊和受到啟發之間來回切換,有助於你理解。

了解中介軟體

雖然中介軟體可用於各種事物,包括非同步 API 呼叫,但了解其來源非常重要。我們將使用記錄和錯誤回報為範例,引導你了解導致中介軟體的思考過程。

問題:記錄

Redux 的好處之一是,它讓狀態變更可預測且透明。每次發送動作時,都會計算並儲存新的狀態。狀態本身無法變更,它只能因為特定動作而變更。

如果我們記錄應用程式中發生的每個動作,以及其後計算的狀態,那不是很好嗎?當某些事情出錯時,我們可以回顧記錄,找出哪個動作損壞了狀態。

我們如何使用 Redux 來解決這個問題?

嘗試 #1:手動記錄

最天真的解決方案就是每次呼叫 store.dispatch(action) 時,自行記錄動作和下一個狀態。這並非真正的解決方案,而只是了解問題的第一步。

注意

如果你正在使用 react-redux 或類似的綁定,你可能無法在元件中直接存取儲存體執行個體。對於接下來的幾段落,請假設你明確傳遞儲存體。

比方說,你在建立待辦事項時呼叫這個

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:Monkey Patching Dispatch

如果我們只替換 store 執行個體上的 dispatch 函式,會怎樣?Redux store 是具有 一些方法 的純粹物件,而我們正在撰寫 JavaScript,所以我們可以只 monkey patching 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
}

這已經接近我們想要的!無論我們在何處發送動作,都保證會記錄下來。Monkey patching 永遠不會感覺正確,但我們現在可以接受它。

問題:崩潰報告

如果我們想要對 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
}
}
}

如果這些函式作為獨立模組發布,我們稍後可以使用它們來修補我們的 store

patchStoreToAddLogging(store)
patchStoreToAddCrashReporting(store)

儘管如此,這並不好。

嘗試 #4:隱藏 Monkey Patching

Monkey patching 是一種駭客手法。「隨意替換任何你喜歡的方法」,這是什麼樣的 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 中提供一個 helper,將實際的 monkeypatching 作為實作細節套用

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

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

我們可以用它來套用多個 middleware,如下所示

applyMiddlewareByMonkeypatching(store, [logger, crashReporter])

然而,這仍然是 monkeypatching。我們將它隱藏在函式庫中,並不會改變這個事實。

嘗試 #5:移除 Monkeypatching

我們為什麼要覆寫 dispatch?當然,為了稍後可以呼叫它,但還有另一個原因:讓每個 middleware 都可以存取(並呼叫)先前封裝的 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
}
}

這對於串連 middleware 至關重要!

如果 applyMiddlewareByMonkeypatching 沒有在處理第一個 middleware 後立即指定 store.dispatchstore.dispatch 將會持續指向原始的 dispatch 函式。然後第二個 middleware 也會繫結到原始的 dispatch 函式。

但是,還有一個不同的方法可以啟用串連。middleware 可以接受 next() dispatch 函式作為參數,而不是從 store 執行個體讀取它。

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 middleware 的樣子。

現在 middleware 會取得 next() dispatch 函式,並傳回一個 dispatch 函式,而這個函式又會作為左側 middleware 的 next(),以此類推。仍然可以存取一些 store 方法,例如 getState(),因此 store 會作為頂層參數提供。

嘗試 #6:天真地套用 Middleware

我們可以撰寫 applyMiddleware(),而不是 applyMiddlewareByMonkeypatching(),它會先取得最後的、完全封裝的 dispatch() 函式,並使用它傳回 store 的副本

// 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() 實作類似,但在三個重要面向有不同

  • 它只會將 store API 的子集公開給 middleware:dispatch(action)getState()

  • 它會做一些小技巧,以確保如果你從 middleware 呼叫 store.dispatch(action) 而不是 next(action),這個動作實際上會再次遍歷整個 middleware 鏈,包括目前的 middleware。 這對非同步 middleware 很實用。在設定期間呼叫 dispatch 時有一個注意事項,如下所述。

  • 為確保你只能套用一次中間件,它會在 createStore() 上運作,而不是在 store 本身上。它的簽章是 (...middlewares) => (createStore) => createStore,而不是 (store, middlewares) => store

由於在使用 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'))

七個範例

如果你讀完上述章節後頭腦沸騰,想像一下寫它的人是什麼感覺。本節旨在讓你和我放鬆,並幫助你開始思考。

以下每個函式都是有效的 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
)
)