Middleware
你已經在 Async Action 的範例中看過 middleware 的實際運用了。如果你使用過一些伺服器端的 library,像是 Express 或者是 Koa,你應該對 middleware 的概念很熟悉。在這些框架裡,middleware 是一些你可以放到框架 接收請求和產生回應之間的程式碼。舉例來說,Express 和 Koa 的 middleware 可以添加 CORS 標頭、log、壓縮,還有其他更多的功能。middleware 的最大特點在於它可以在鏈上組合串接。你可以在一個專案中使用多個獨立的第三方 middleware。
Redux middleware 解決了跟 Express 和 Koa middleware 不同的問題,不過概念上是類似的。它在 dispatch action 和 action 到達 reducer 的時間點之間提供了一個第三方的擴充點。人們可以使用 Redux middleware 來 log、回報當機、跟非同步 API 溝通、routing,還有其他更多的功能。
這篇文章被分成深入的介紹以幫助你理解概念的部分,以及在最後面有幾個實際的範例以展示 middleware 的力量的部分。你可能會發現當你在感到無聊與有靈感之間翻轉時,在它們之間前後切換很有幫助。
了解 Middleware
雖然 middleware 可以用於許多不同種類的事情,包括非同步的 API 呼叫,但是了解它從何而來真的非常重要。我們將會藉由使用 logging 和當機回報作為範例,引導你走一遍 middleware 形成的思考過程。
問題:Log
Redux 的其中一個好處是可以讓 state 的改變變成可預測且透明的。當每一次 action 被 dispatch 的時候,會計算出新的 state 並儲存下來。state 無法自己改變,它只能因應特定 action 的結果而改變。
如果我們能把發生在應用程式中的每個 action,和在那之後計算出來的 state 一起被 log,那豈不是很棒?當有東西出錯了,我們可以回去看我們的 log,並找出是哪一個 action 破壞了 state。
我們如何運用 Redux 達到這個呢?
嘗試 #1:手動地 Log
最天真的解决方案就是在你每次呼叫 store.dispatch(action)
的時候自己 log action 和下一個 state。這不算是一個真正的解決方案,只是我們理解問題的第一步而已。
附註
如果你是使用 react-redux 或是類似的綁定,你不太會在你的 component 裡面直接存取 store 實體。不過為了接下來幾個段落,就直接假設你把 store 明確地傳遞下去了。
假如說,你在建立一個 todo 的時候呼叫了這個:
store.dispatch(addTodo('Use Redux'))
為了要 log action 和 state,你可以把它改成像是這樣:
let action = addTodo('Use Redux')
console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())
這會產生你所希望的效果,不過你不會想要每次都做這些事。
嘗試 #2:把 Dispatch 包起來
你可以把 log 放進一個 function 裡:
function dispatchAndLog(store, action) {
console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())
}
接著,你可以在每個地方使用它來取代 store.dispatch()
:
dispatchAndLog(store, addTodo('Use Redux'))
我們可以選擇在這裡結束,但是每次都要 import 一個特別的 function 還不是非常方便。
嘗試 #3:Monkeypatch Dispatch
那如果我們直接置換掉 store 實體上的 dispatch
function 呢?Redux 的 store 只是一個有幾個 method 的一般物件,而且我們正在寫 JavaScript,所以我們可以直接 monkeypatch dispatch
的實作:
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
}
這已經跟我們想要的東西更接近了!無論我們在哪裡 dispatch action,都保證會被 log 下來。Monkeypatch 的感覺總是不對,不過我們現在已經可以藉由它達成目標了。
問題:當機回報
那如果我們想要使用超過一個這樣的轉換到 dispatch
上呢?
浮現在我的腦海裡的另一個有用的轉換是在產品環境中回報 JavaScript 的錯誤。全域的 window.onerror
事件並不可靠,因為它在一些舊的瀏覽器中沒有提供堆疊資訊,而這是了解為什麼會發生錯誤的關鍵。
如果當任何時候 dispatch 一個 action 的結果丟出來錯誤,我們把它、堆疊追溯資訊、導致錯誤的 action,和當下的 state 一起傳送到一個當機回報服務,例如 Sentry,那豈不是很有用?這個方式會比較容易在開發環境中重現錯誤。
然而,讓 log 和當機回報的部分分開是非常重要的。理想上我們希望它們是不同的模組,也可能在不同的套件中。不然,我們無法擁有一個這樣的 utility 的生態系。(提示:我們正在慢慢的了解什麼是 middleware!)
如果 log 和當機回報是分開的 utility,它們看起來可能會像這樣:
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
}
}
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
}
}
}
如果把這些 function 作為獨立的模組發佈,我們可以在之後使用它們來 patch 我們的 store:
patchStoreToAddLogging(store)
patchStoreToAddCrashReporting(store)
但這依舊不夠好。
嘗試 #4:隱藏 Monkeypatch
Monkeypatch 是一種 hack。「置換掉任何你中意的 method」,那 API 會是怎樣呢?讓我們弄清楚它的本質。先前,我們的 function 取代了 store.dispatch
。那如果它們回傳新的 dispatch
function 來取代呢?
function logger(store) {
let next = store.dispatch
// 先前:
// 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,它會把實際運用 monkeypatch 的部分作為實作細節:
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 ])
不過,這仍然是 monkeypatch。 我們把它隱藏在 library 裡面並不能改變這個事實。
嘗試 #5:移除 Monkeypatch
為什麼我們要覆寫掉 dispatch
呢?當然,為了能夠在之後呼叫它,不過還有另一個理由:這樣可以讓每個 middleware 都能存取 (和呼叫) 先前被包起來的 store.dispatch
:
function logger(store) {
// 必須指向前面的 middleware 回傳的 function:
let next = store.dispatch
return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}
串接 middleware 是不可缺少的!
在處理完第一個 middleware 之後,如果 applyMiddlewareByMonkeypatching
沒有立刻賦值給 store.dispatch
,store.dispatch
還是會指向原本的 dispatch
function。然後第二個 middleware 也會綁到原本的 dispatch
function。
不過也有一個不一樣的方式可以啟用串接。這些 middleware 可以接收一個 next()
dispatch function 作為參數而不用從 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
}
}
}
現在是「我們該更深入一點」的那種時刻,所以可能會花一段時間讓它變得更合理一些。這些層層疊在一起的 function 很嚇人。ES6 arrow function 可以讓這個 currying 看起來舒服一點:
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 function,並回傳一個 dispatch function,它往左依序作為 middleware 的 next()
,照此類推。如果能存取到 store 的一些 methods 像是 getState()
仍然非常有用,因此把 store
作為頂層的參數讓它依然可以使用。
嘗試 #6:不成熟的啟用 Middleware
作為 applyMiddlewareByMonkeypatching()
的替代,我們可以寫一個 applyMiddleware()
,先取得最後完整包裝好的 dispatch()
function,並回傳一個使用它的 store 的副本:
// 警告:這是不成熟的實作!
// 這*不*是 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)
,這個 action 將會實際的再次通過整個 middleware 鏈,也包括發出 action 當下的 middleware。這對非同步的 middleware 非常有用,正如我們先前所看到的。為了確保你只會應用 middleware 一次,它操作在
createStore()
上而不是在store
自己上面。它的 signature 是(...middlewares) => (createStore) => createStore
,而不是(store, middlewares) => store
。
因為在使用前套用 function 在 createStore()
上太累贅了,所以 createStore()
接受在最後方使用一個選擇性的變數來指定這些 function。
最後的方法
把剛剛寫的這些 middleware 再拿出來:
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 store 中:
import { createStore, combineReducers, applyMiddleware } from 'redux'
let todoApp = combineReducers(reducers)
let store = createStore(
todoApp,
// applyMiddleware() 告訴 createStore() 如何處理 middleware
applyMiddleware(logger, crashReporter)
)
就是這樣!現在任何被 dispatch 到 store 實體的 action 都將經過 logger
和 crashReporter
:
// 將經過 logger 和 crashReporter 兩個 middleware!
store.dispatch(addTodo('Use Redux'))
七個範例
如果你的頭已經因為閱讀上面的章節而快燒掉了,想像一下把它寫出來會是什麼樣子。這個章節就是要讓你和我放鬆,並幫助你的齒輪繼續轉動。
下面的每一個 function 都是合格的 Redux middleware。它們不是同樣的有用,不過至少它們同樣的有趣。
/**
* 在 action 被 dispatch 之後,Log 所有的 action 和 state。
*/
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(action.type)
return result
}
/**
* 在 state 被更新且 listener 被通知之後傳送當機回報。
*/
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
}
}
/**
* 用 { meta: { delay: N } } 來排程 actions 讓它延遲 N 毫秒。
* 在這個案例中,讓 `dispatch` 回傳一個 function 來取消 timeout。
*/
const timeoutScheduler = store => next => action => {
if (!action.meta || !action.meta.delay) {
return next(action)
}
let timeoutId = setTimeout(
() => next(action),
action.meta.delay
)
return function cancel() {
clearTimeout(timeoutId)
}
}
/**
* 用 { meta: { raf: true } } 來排程 action,
* 讓它在 rAF 迴圈中被 dispatch。在這個案例中,
* 讓 `dispatch` 回傳一個 function 來從佇列中移除這個 action。
*/
const rafScheduler = store => next => {
let 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)
}
}
}
/**
* 讓你除了 action 以外還可以 dispatch promise。
* 如果這個 promise 被 resolve,它的結果將會作為 action 被 dispatch。
* 這個 promise 會被 `dispatch` 回傳,所以呼叫者可以處理 rejection。
*/
const vanillaPromise = store => next => action => {
if (typeof action.then !== 'function') {
return next(action)
}
return Promise.resolve(action).then(store.dispatch)
}
/**
* 讓你可以 dispatch 有 { promise } 屬性的特殊 actions。
*
* 這個 middleware 將會在一開始的時候 dispatch 一個 action,
* 並在這個 `promise` resolves 的時候 dispatch 一個成功 (或失敗) 的 action。
*
* 為了方便,`dispatch` 將會回傳 promise 讓呼叫者可以等待。
*/
const readyStatePromise = store => next => action => {
if (!action.promise) {
return next(action)
}
function makeAction(ready, data) {
let 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 }))
)
}
/**
* 讓你可以 dispatch 一個 function 來取代 action。
* 這個 function 將會接收 `dispatch` 和 `getState` 作為參數。
*
* 對提早退出 (依照 `getState()` 的狀況),
* 以及非同步控制流程 (它可以 `dispatch()` 一些別的東西) 很有用。
*
* `dispatch` 將會回傳被 dispatch 的 function 的回傳值。
*/
const thunk = store => next => action =>
typeof action === 'function' ?
action(store.dispatch, store.getState) :
next(action)
// 你可以使用全部!(這不意味你應該這樣做。)
let todoApp = combineReducers(reducers)
let store = createStore(
todoApp,
applyMiddleware(
rafScheduler,
timeoutScheduler,
thunk,
vanillaPromise,
readyStatePromise,
logger,
crashReporter
)
)