跳至主内容
非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

中间件

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

Redux 中间件解决的问题虽与 Express/Koa 不同,但概念上异曲同工。它在派发 action 和 action 抵达 reducer 之间提供了第三方扩展点。开发者常用 Redux 中间件实现日志记录、崩溃报告、异步 API 通信、路由控制等功能。

本文分为深度解析(帮助您透彻理解概念)和实战示例(文末展示中间件威力)两部分。建议您在感到枯燥和灵感迸发时交替阅读这两部分内容。

理解中间件

虽然中间件可用于多种场景(包括异步 API 调用),理解其设计渊源至关重要。我们将以日志记录和崩溃报告为例,带您了解中间件的设计演进思路。

问题:日志记录

Redux 的优势之一是使状态变更可预测且透明。每次派发 action 后,新状态都会被计算并保存。状态不会自主变化,只能由特定 action 触发变更。

如果记录应用中每个发生的 action 及其触发的状态变化,岂不完美?当出现问题时,我们可以回溯日志,定位导致状态异常的具体 action。

如何在 Redux 中实现此需求?

尝试一:手动记录

最基础的解决方案是在每次调用 store.dispatch(action) 时手动记录 action 和后续状态。这虽非终极方案,却是理解问题的第一步。

注意

若使用 react-redux 等绑定库,组件通常无法直接访问 store 实例。后续几段假设您已显式传递 store。

假设创建待办事项时调用:

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

要记录 action 和状态,可修改为:

const action = addTodo('Use Redux')

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

此方案效果符合预期,但您不会希望每次都这样操作。

尝试二:封装 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'))

方案到此可行,但每次导入专用函数并不便捷。

尝试三:猴子补丁 dispatch

何不直接替换 store 实例的 dispatch 方法?Redux store 是包含若干方法的普通对象,借助 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
}

这已接近理想方案!无论从何处派发 action,都能确保记录日志。猴子补丁虽非完美方案,但现阶段可接受。

问题:崩溃报告

如果我们想对 dispatch 应用多个此类转换会怎样?

我想到的另一个实用转换是生产环境中的 JavaScript 错误报告。全局的 window.onerror 事件并不可靠,因为在某些旧版浏览器中它不提供堆栈信息——而这对于理解错误原因至关重要。

如果在派发 action 时抛出错误,我们能否将其发送到像 Sentry 这样的崩溃报告服务?附带堆栈跟踪、引发错误的 action 以及当前状态?这样就能在开发环境中更轻松地复现问题。

但关键是要保持日志记录和崩溃报告分离。理想情况下它们应是不同模块,甚至可能属于不同包。否则就无法形成工具生态体系。(提示:我们正逐步接近中间件的本质!)

如果日志和崩溃报告是独立工具,它们可能长这样:

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)

但这仍不够优雅。

尝试四:隐藏猴子补丁

猴子补丁(Monkeypatching)属于临时解决方案。"随意替换任何方法" 算哪门子 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])

但这本质上仍是猴子补丁。 将其隐藏在库内部并未改变这一事实。

尝试五:消除猴子补丁

为何要覆盖 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 函数。导致第二个中间件仍绑定到未修改的 dispatch

但还有另一种实现链式调用的方式:中间件可以接收 next() 派发函数作为参数,而非从 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() 派发函数,并返回新的派发函数——该函数又成为左侧中间件的 next(),依此类推。同时保留访问 getState() 等 store 方法的能力,因此顶层仍保留 store 参数。

尝试六:基础中间件应用

可以创建 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) 时,该 action 会重新流经整个中间件链(包括当前中间件)。这对异步中间件非常有用。但在初始化阶段调用 dispatch 存在一个注意事项,详见下文说明。

  • 为确保中间件只能应用一次,它操作的对象是 createStore() 而非 store 实例。其函数签名为 (...middlewares) => (createStore) => createStore,而非 (store, middlewares) => store

由于在 createStore() 使用前对其应用函数较为繁琐,createStore() 允许在最后一个参数中指定此类函数。

注意事项:初始化阶段的分发行为

applyMiddleware 执行并设置中间件时,store.dispatch 函数指向的是 createStore 提供的原始版本。此时分发 action 将不会应用任何其他中间件。如果你期望在初始化阶段与其他中间件交互,结果可能会令人失望。鉴于这种非预期行为,若在中间件设置完成前尝试分发 action,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)
)

就这样!现在所有分发到仓库实例的 action 都将流经 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
)
)