id: middleware title: Middleware 中间件 description: ‘原理 > Middleware 中间件:如何使用中间件来扩展 Redux store 的能力’

hide_title: false

Middleware 中间件

“Redux 深入浅出” 教程中 你已经学习过中间件的实践案例。如果你使用过服务端框架像 ExpressKoa,你或许已经熟悉 middleware 的概念。在这些框架中,中间件可以让你在接收请求和生成响应之间放置的一些代码。例如,Express 或 Koa 中间件可能会添加 CORS 标头、记录日志、压缩等。中间件的最大特点是它可以组合成一个链。您可以在一个项目中使用多个不同的中间件。

Redux 中间件解决的问题与 Express 或 Koa 中间件不同,但在概念上是相似的。它在 dispatch action 和到达 reducer 的那一刻之间提供了逻辑插入点。可以使用 Redux 中间件进行日志记录、异常监控、与异步 API 对话、路由等。

本文分成几部分来让你深入了解它的概念,以及几个实际例子在最后展示中间件的威力。这些例子可能需要来回看,这样你会在无聊和灵感间切换。

理解 Middleware

虽然中间件可用于多种用途,包括异步 API 调用,但了解它的来源的原因非常重要。我们将通过“日志记录”和“异常监控”作为示例,来一步步引导你知道中间件产生背后的过程。

问题:日志

Redux 的好处之一是它使状态的变更变得可预测且透明。每次 dispatch action 时,都会计算并保存新状态。状态不能自行改变,它只能作为特定 action 的结果而改变。

如果我们记录应用程序中发生的每个 action 调用前后的状态,不是很好吗?当出现问题时,我们可以回溯日志,并找出哪个 action 导致了问题。

Middleware 中间件 - 图1

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

尝试 #1:手动打日志

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

注意

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

比如说,这样来创建 todo:

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

要记录 action 和状态,您可以将代码改成这样:

  1. const action = addTodo('Use Redux')
  2. console.log('dispatching', action)
  3. store.dispatch(action)
  4. console.log('next state', store.getState())

这会产生所需的效果,但您不想每次都这样做。

尝试 #2: 包裹 dispatch

您可以写个函数来记录日志:

  1. function dispatchAndLog(store, action) {
  2. console.log('dispatching', action)
  3. store.dispatch(action)
  4. console.log('next state', store.getState())
  5. }

然后你可以在任何地方使用它而不是store.dispatch()

  1. dispatchAndLog(store, addTodo('Use Redux'))

我们可以到此结束,但是每次都导入一个特殊函数不是很方便。

尝试 #3: 给 dispatch 做猴子补丁(Monkeypatch)

如果我们直接重写 store 实例上的 dispatch 函数怎么样?store 本身是带有 一些方法 的普通对象,因为是 JavaScript,所以我们可以直接对 dispatch 函数进行猴子补丁:

  1. const next = store.dispatch
  2. store.dispatch = function dispatchAndLog(action) {
  3. console.log('dispatching', action)
  4. let result = next(action)
  5. console.log('next state', store.getState())
  6. return result
  7. }

这已经非常接近我们想要的了!无论我们在何处 dispatch action,它都保证被记录。Monkeypatching 从来都不是正确的,但我们现在可以忍受它。

问题: 异常监控

如果需要对 dispatch多个这样的转换怎么办?

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

想一下这样会更好,每当 dispatch action 抛出错误时,我们都将错误发送给异常监控服务器,例如 Sentry 和堆栈,这不是很有用吗?记录导致错误的 action 以及当前状态?这样,在开发环境中重现错误要容易得多。

但是,将日志记录和异常监控分开是很重要的。理想情况下,我们希望它们属于不同的模块,可能在不同的包中。否则,我们就无法在不同的应用中复用。(提示:我们正在慢慢了解什么是中间件!)

如果日志记录和异常监控是分开的工具函数,它们可能长这样:

  1. function patchStoreToAddLogging(store) {
  2. const next = store.dispatch
  3. store.dispatch = function dispatchAndLog(action) {
  4. console.log('dispatching', action)
  5. let result = next(action)
  6. console.log('next state', store.getState())
  7. return result
  8. }
  9. }
  10. function patchStoreToAddCrashReporting(store) {
  11. const next = store.dispatch
  12. store.dispatch = function dispatchAndReportErrors(action) {
  13. try {
  14. return next(action)
  15. } catch (err) {
  16. console.error('Caught an exception!', err)
  17. Raven.captureException(err, {
  18. extra: {
  19. action,
  20. state: store.getState()
  21. }
  22. })
  23. throw err
  24. }
  25. }
  26. }

如果这些函数作为单独的模块发布,我们稍后可以使用它们来装饰我们的 store:

  1. patchStoreToAddLogging(store)
  2. patchStoreToAddCrashReporting(store)

尽管如此,这并不好。

尝试 #4: 隐藏猴子补丁

打猴子补丁是一种 Hack(不推荐)的方式。“替换任何你喜欢的方法”,那是一种什么样的API?让我们找出它的本质。以前,我们的函数替换了store.dispatch。如果他们 返回 新的 dispatch 函数怎么样?

  1. function logger(store) {
  2. const next = store.dispatch
  3. // Previously:
  4. // store.dispatch = function dispatchAndLog(action) {
  5. return function dispatchAndLog(action) {
  6. console.log('dispatching', action)
  7. let result = next(action)
  8. console.log('next state', store.getState())
  9. return result
  10. }
  11. }

我们可以在 Redux 中提供一个 helper 来帮助我们做猴子补丁:

  1. function applyMiddlewareByMonkeypatching(store, middlewares) {
  2. middlewares = middlewares.slice()
  3. middlewares.reverse()
  4. // 依次调用每个 middleware 来增强 dispatch
  5. middlewares.forEach(middleware => (store.dispatch = middleware(store)))
  6. }

我们可以使用它来调用多个中间件,如下所示:

  1. applyMiddlewareByMonkeypatching(store, [logger, crashReporter])

然而,它仍然是猴子补丁。 我们将它隐藏在库中的事实并没有改变这一事实。

尝试 #5: 移除猴子补丁

为什么每次都需要覆盖 dispatch 呢?很简单,为了以后能够调用它,但还有另外一个原因:这样每个中间件都可以访问(和调用)之前包裹的 store.dispatch

  1. function logger(store) {
  2. // Must point to the function returned by the previous middleware:
  3. const next = store.dispatch
  4. return function dispatchAndLog(action) {
  5. console.log('dispatching', action)
  6. let result = next(action)
  7. console.log('next state', store.getState())
  8. return result
  9. }
  10. }

必须要链式调用中间件!

如果 applyMiddlewareByMonkeypatching 在处理第一个中间件后没有立即覆盖掉原来的 store.dispatchstore.dispatch 将继续指向原始的 dispatch 函数。那么第二个中间件也会绑定到原来的 dispatch 函数上。

但是还有一种不同的方式来做链式调用。中间件来接受 next() 调度函数作为参数,而不是从 store 实例中读取它。

  1. function logger(store) {
  2. return function wrapDispatchToAddLogging(next) {
  3. return function dispatchAndLog(action) {
  4. console.log('dispatching', action)
  5. let result = next(action)
  6. console.log('next state', store.getState())
  7. return result
  8. }
  9. }
  10. }

这是一个 “我们需要更深入” 的时刻,所以这可能需要一段时间来理解。函数级联感觉很吓人。ES6 箭头函数使这个 轲里化 更易读:

  1. const logger = store => next => action => {
  2. console.log('dispatching', action)
  3. let result = next(action)
  4. console.log('next state', store.getState())
  5. return result
  6. }
  7. const crashReporter = store => next => action => {
  8. try {
  9. return next(action)
  10. } catch (err) {
  11. console.error('Caught an exception!', err)
  12. Raven.captureException(err, {
  13. extra: {
  14. action,
  15. state: store.getState()
  16. }
  17. })
  18. throw err
  19. }
  20. }

这正是 Redux 中间件的样子。

现在中间件接受 next() dispatch 函数,并返回新的 dispatch 函数,该函数又充当左侧中间件的 next(),依此类推。仍然可以在需要的时候调用 store 方法(如 getState()),因此 store 作为顶级参数传入在这里仍然可用。

尝试 #6:简单粗暴的中间件调用方法

为了替代 applyMiddlewareByMonkeypatching(),我们可以编写 applyMiddleware(),它先计算出最终的、被所有 middleware 包裹后的 dispatch() 函数,并使用它返回 store 的副本:

  1. // 注意:这是简单粗暴的中间件调用方法
  2. // 并 *不是* Redux API 真实的实现方法。
  3. function applyMiddleware(store, middlewares) {
  4. middlewares = middlewares.slice()
  5. middlewares.reverse()
  6. let dispatch = store.dispatch
  7. middlewares.forEach(middleware => (dispatch = middleware(store)(dispatch)))
  8. return Object.assign({}, store, { dispatch })
  9. }

Redux 内置的 applyMiddleware() 的实现是相似的,但在三个重要方面不同:

  • 它只向中间件公开 store API 的一个子集:dispatch(action)getState()

  • 确保如果您从中间件调用 store.dispatch(action) 而不是 next(action),该 action 实际上将再次遍历整个中间件链,包括当前的中间件,这需要一些技巧。这对异步中间件很有用。在配置 Redux 期间调用 dispatch 时有一个警告,后面会讲。

  • 为确保您只能应用一次中间件,它在 createStore() 上运行,而不是在 store 本身上运行。它的函数签名不是(store, middlewares) => store,而是(...middlewares) => (createStore) => createStore

因为在使用 createStore() 之前将函数应用到它是很麻烦的,createStore() 的最后一个参数作为可选的来指定这样的函数。

警告:在配置 Redux 期间调用 dispatch

applyMiddleware 执行并配置你的中间件时,store.dispatch 函数将指向 createStore 提供的最初版本。这个时候的 dispatch 还不会经过任何 middleware。如果您期望在配置期间与另一个中间件进行交互,您可能会感到失望。由于这种意外行为,在配置完成之前如果 dispatch 了 action,applyMiddleware 将抛出错误。解法是,你应该通过一个公共对象(例如做 API 请求的中间件,那就直接用 API 请求的对象)直接与其他中间件通信,或者放到中间件配置完后的回调函数中等待执行。

最后的实现

鉴于这个中间件,我们刚刚写道:

  1. const logger = store => next => action => {
  2. console.log('dispatching', action)
  3. let result = next(action)
  4. console.log('next state', store.getState())
  5. return result
  6. }
  7. const crashReporter = store => next => action => {
  8. try {
  9. return next(action)
  10. } catch (err) {
  11. console.error('Caught an exception!', err)
  12. Raven.captureException(err, {
  13. extra: {
  14. action,
  15. state: store.getState()
  16. }
  17. })
  18. throw err
  19. }
  20. }

以下是如何将其应用于 Redux store :

  1. import { createStore, combineReducers, applyMiddleware } from 'redux'
  2. const todoApp = combineReducers(reducers)
  3. const store = createStore(
  4. todoApp,
  5. // applyMiddleware() tells createStore() how to handle middleware
  6. applyMiddleware(logger, crashReporter)
  7. )

就是这样!现在,dispatch 到 store 的所有 action 都会调用 loggercrashReporter 这2个中间件:

  1. // Will flow through both logger and crashReporter middleware!
  2. store.dispatch(addTodo('Use Redux'))

Seven Examples

如果您在阅读上述部分时头脑发热,请想象一下编写它是什么感觉。本节旨在让您和我放松身心,并有助于让您的头脑开窍。

下面的每个函数都是一个有效的 Redux 中间件。它们并不同样有用,但都同样有趣。

  1. /**
  2. * 日志打印每个 dispatch 的 action 和调用后的状态
  3. */
  4. const logger = store => next => action => {
  5. console.group(action.type)
  6. console.info('dispatching', action)
  7. let result = next(action)
  8. console.log('next state', store.getState())
  9. console.groupEnd()
  10. return result
  11. }
  12. /**
  13. * 报错的时候发送异常报告
  14. */
  15. const crashReporter = store => next => action => {
  16. try {
  17. return next(action)
  18. } catch (err) {
  19. console.error('Caught an exception!', err)
  20. Raven.captureException(err, {
  21. extra: {
  22. action,
  23. state: store.getState()
  24. }
  25. })
  26. throw err
  27. }
  28. }
  29. /**
  30. * 一个定时器,使用 { meta: { delay: N } } 安排 action 延迟 N 毫秒后调用。
  31. * 让 `dispatch` 返回一个取消定时器的函数。
  32. */
  33. const timeoutScheduler = store => next => action => {
  34. if (!action.meta || !action.meta.delay) {
  35. return next(action)
  36. }
  37. const timeoutId = setTimeout(() => next(action), action.meta.delay)
  38. return function cancel() {
  39. clearTimeout(timeoutId)
  40. }
  41. }
  42. /**
  43. * 另一个定时器,使用 { meta: { raf: true } } 让 action 在 rAF 循环内调用。
  44. * 让 `dispatch` 返回一个删除 action 的函数
  45. */
  46. const rafScheduler = store => next => {
  47. const queuedActions = []
  48. let frame = null
  49. function loop() {
  50. frame = null
  51. try {
  52. if (queuedActions.length) {
  53. next(queuedActions.shift())
  54. }
  55. } finally {
  56. maybeRaf()
  57. }
  58. }
  59. function maybeRaf() {
  60. if (queuedActions.length && !frame) {
  61. frame = requestAnimationFrame(loop)
  62. }
  63. }
  64. return action => {
  65. if (!action.meta || !action.meta.raf) {
  66. return next(action)
  67. }
  68. queuedActions.push(action)
  69. maybeRaf()
  70. return function cancel() {
  71. queuedActions = queuedActions.filter(a => a !== action)
  72. }
  73. }
  74. }
  75. /**
  76. * 让你可以直接 dispatch promise 作为 action。
  77. * 如果 promise 执行成功,它的结果将作为一个 action 发送。
  78. * `dispatch` 返回的也是 promise,这样如果出错也能处理。
  79. */
  80. const vanillaPromise = store => next => action => {
  81. if (typeof action.then !== 'function') {
  82. return next(action)
  83. }
  84. return Promise.resolve(action).then(store.dispatch)
  85. }
  86. /**
  87. * 允许您 dispatch 带有 { promise } 字段的特殊 action。
  88. *
  89. * 这个中间件会在一开始就把它们变成一个单一的 action ,
  90. * 以及当 `promise` 成功时再 dispatch 一个成功(或失败)的 action 。
  91. *
  92. * 为方便起见,`dispatch` 将返回 promise 以便调用者可以继续 then。
  93. */
  94. const readyStatePromise = store => next => action => {
  95. if (!action.promise) {
  96. return next(action)
  97. }
  98. function makeAction(ready, data) {
  99. const newAction = Object.assign({}, action, { ready }, data)
  100. delete newAction.promise
  101. return newAction
  102. }
  103. next(makeAction(false))
  104. return action.promise.then(
  105. result => next(makeAction(true, { result })),
  106. error => next(makeAction(true, { error }))
  107. )
  108. }
  109. /**
  110. * 让你 dispatch 函数而不是 action 对象。
  111. * 此函数将接收 `dispatch` 和 `getState` 作为参数。
  112. *
  113. * 用于提前退出(使用 `getState()` 的条件判断),以及
  114. * 用于异步控制流(它可以 `dispatch()` 别的东西)。
  115. *
  116. * `dispatch` 将返回被调度函数的返回值。
  117. */
  118. const thunk = store => next => action =>
  119. typeof action === 'function'
  120. ? action(store.dispatch, store.getState)
  121. : next(action)
  122. // 您可以使用以上所有中间件!(这并不意味着你应该。)
  123. const todoApp = combineReducers(reducers)
  124. const store = createStore(
  125. todoApp,
  126. applyMiddleware(
  127. rafScheduler,
  128. timeoutScheduler,
  129. thunk,
  130. vanillaPromise,
  131. readyStatePromise,
  132. logger,
  133. crashReporter
  134. )
  135. )