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

中间件

您已经在 "Redux 基础" 教程 中看到了中间件的实际应用。如果您使用过像 ExpressKoa 这样的服务器端库,您可能也已经熟悉了 中间件 的概念。在这些框架中,中间件是您可以在框架接收请求和框架生成响应之间放置的一些代码。例如,Express 或 Koa 中间件可能会添加 CORS 标头、日志记录、压缩等。中间件的最佳特性是它可以在链中组合。您可以在单个项目中使用多个独立的第三方中间件。

Redux 中间件解决的问题与 Express 或 Koa 中间件不同,但概念上类似。它在分发动作和动作到达 reducer 的时刻之间提供了一个第三方扩展点。 人们使用 Redux 中间件进行日志记录、崩溃报告、与异步 API 交谈、路由等。

本文分为深入介绍部分,帮助您理解这个概念,以及 几个实际示例,在最后展示中间件的强大功能。您可能会发现,在感到无聊和灵感之间切换时,在它们之间来回切换会很有帮助。

理解中间件

虽然中间件可以用于各种用途,包括异步 API 调用,但了解它的来源非常重要。我们将以日志记录和崩溃报告为例,引导您了解通往中间件的思考过程。

问题:日志记录

Redux 的优势之一是它使状态变化可预测且透明。每次调度操作时,都会计算并保存新的状态。状态不能自行改变,它只能作为特定操作的结果而改变。

如果我们记录应用程序中发生的每个操作以及操作后计算出的状态,那不是很好吗?当出现问题时,我们可以查看日志,找出哪个操作破坏了状态。

我们如何用 Redux 来解决这个问题?

尝试 #1:手动记录

最简单的解决方案是在每次调用 store.dispatch(action) 时手动记录操作和下一个状态。它不是真正的解决方案,而只是理解问题的第一步。

注意

如果您使用的是 react-redux 或类似绑定,您可能无法在组件中直接访问 store 实例。在接下来的几段中,假设您显式地传递了 store。

例如,您在创建待办事项时调用它

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?当然,是为了能够稍后调用它,但还有一个原因:这样每个中间件都可以访问(并调用)之前包装的 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.dispatchstore.dispatch 将继续指向原始的 dispatch 函数。然后第二个中间件也将绑定到原始的 dispatch 函数。

但也有另一种方法可以实现链式调用。中间件可以接受 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 中间件的样子。

现在中间件接受 next() dispatch 函数,并返回一个 dispatch 函数,该函数反过来作为 next() 传递给左侧的中间件,依此类推。仍然需要访问一些 store 方法,例如 getState(),因此 store 作为顶级参数可用。

尝试 #6:天真地应用中间件

我们可以编写 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 的一个子集:dispatch(action)getState()

  • 它做了一些技巧,以确保如果你从你的中间件调用 `store.dispatch(action)` 而不是 `next(action)`,该动作实际上会再次遍历整个中间件链,包括当前中间件。 这对于异步中间件很有用。 在设置期间调用 `dispatch` 时有一个注意事项,如下所述。

  • 为了确保你只能应用一次中间件,它在 `createStore()` 上运行,而不是在 `store` 本身上。 它的签名不是 `(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'))

七个示例

如果你读完以上部分后头脑发热,想象一下写它是什么感觉。 本节旨在让你和我放松一下,并帮助你开始思考。

下面每个函数都是有效的 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
)
)