理解redux-thunk和redux-promise?从学习redux中间件开始

3,611 阅读14分钟

前言

上一篇文章写了关于redux的作用以及reduxreact-redux两个插件的API,但redux中有一个API:applyMiddleware并没有说明,因为涉及到redux的中间件概念,需要比较多内容去说明,这次这篇文章就集中写一下这方面的知识。

何为redux中间件

1. 中间件的作用

之前我们说过,redux的工作流程图是下面这样的:

image.png

我们看express有中间件机制,其实redux也有,redux的中间件middleware是用来增强dispatch方法的。有时候当我们想改变dispatch执行同时,也执行某些操作,例如日志记录,就可以用中间件实现该需求。如果我们把中间件也纳入到redux的工作流程图,那新的流程图如下所示:

image.png

2. 用到中间件的简单例子

我们可以拿一个例子来说一下中间件,在上一篇文章中,我们写了一个计数和单位切换的例子,现在拿这个例子再添加一个需求,我希望可以从控制台里知道页面程序调用了哪些action。虽然可以在每个action creator都写打印输出语句,可是这不是最优解,我可以通过插入中间件来达到这个需求:

目录如下所示:

image.png

新增store/middleware/logger.js文件,内容如下:

// 中间件用函数来定义
const logger = store => next => action => {
  console.info('dispatching', action.type)
  next(action)
}

export default logger

index.js

import { createStore,applyMiddleware } from 'redux'
import reducer from './reducer'
import logger from './middleware/logger'

// createStore的第三个参数是用来定义中间件的,如果initalState(即下面的第二个参数)省略,则可以放在第二个参数的位置传进去
const store = createStore(reducer,{number:3,unit:'mm'},applyMiddleware(logger))
export default store

达到的效果如下所示:

add&sub&unit&middleware.gif

项目代码

3. 中间件的使用方式

从上面的例子可知,中间件以函数来定义,其格式为:

store => next => action => {
    // do something
}

{}里面需要调用next(action),不然后面的middleware们不会处理该action以及真正触发dispatch(action)

派发给 redux Store 的 action 对象,会被 Store 上的多个中间件依次处理,如果把 action 和当前的 state 交给 reducer 处理的过程看做默认存在的中间件,那么其实所有的对 action 的处理都可以有中间件组成的。值得注意的是这些中间件会按照指定的顺序依次处理传入的 action,只有排在前面的中间件完成任务后,后面的中间件才有机会继续处理 action,同样的,每个中间件都有自己的“熔断”处理,当它认为这个 action 不需要后面的中间件进行处理时,后面的中间件就不能再对这个 action 进行处理了。

最后在生成Redux store时作为第二或第三次参数传入到createStore中,传入之前要用applyMiddleware处理。下面来通过分析相关源码来了解下为什么要这么用:

4. Redux源码中是如何实现中间件的

createStore

我们先了解createStore方法:

createStore(reducer, [preloadedState], enhancer)

该方法传入2~3个参数,最后会返回一个Redux storeapplyMiddleware(middle)是作为enhancer传入的,enhancer是什么?下面先引用官方的解释对其说明:

Store enhancer 是一个组合 store creator 的高阶函数,返回一个新的强化过的 store creator。这与 middleware 相似,它也允许你通过复合函数改变 store 接口。

总结以上的引用,其实enhancer是一个用于更改增强Redux store的函数,如何增强?我们先了解下createStore函数的部分代码:

function createStore(
  reducer,
  preloadedState,
  enhancer
) {
    // ...无关代码不展示
    if (typeof enhancer !== 'undefined') {
        if (typeof enhancer !== 'function') {
          throw new Error(
            `Expected the enhancer to be a function. Instead, received: '${kindOf(
              enhancer
            )}'`
          )
        }

        return enhancer(createStore)(
          reducer,
          preloadedState
        )
    }
    //... 一堆定义store函数的逻辑不展示
    const store = {
    dispatch: dispatch,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
  } 
  return store
}

createStore函数的返回结果得知,store本质上是一个带dispatch,subscribe,getState,replaceReducer以及$$observable五个属性的普通object对象。而当调用createStore时有传入enhancer,他会直接返回enhancer(createStore)(reducer,preloadedState),那其实enhancer(createStore)(reducer,preloadedState)执行完成后最终返回的也是一个store,我们可以推断enhancer的编写格式是这样的: (createStore)=>(reducer,preloadedState)=>{return store}。接下来我们看一下生成enhancerapplyMiddleware函数是怎样子的:

function applyMiddleware(...middlewares){
  return (createStore) =>
    (
      reducer,
      preloadedState
    ) => {
      const store = createStore(reducer, preloadedState)
      // 调用applyMiddleware时不允许middlewares为空
      let dispatch = () => {
        throw new Error(
          'Dispatching while constructing your middleware is not allowed. ' +
            'Other middleware would not be applied to this dispatch.'
        )
      }

      const middlewareAPI = {
        getState: store.getState,
        dispatch: (action, ...args) => dispatch(action, ...args)
      }
      /**
       * 通过compose形成调用链
       * compose函数代码:
        function compose(...funcs: Function[]) {
          if (funcs.length === 0) {
            return (arg) => arg
          }
       
          if (funcs.length === 1) {
            return funcs[0]
          }

          return funcs.reduce(
            (a, b) =>
              (...args: any) =>
                a(b(...args))
          )
        }
       */
      const chain = middlewares.map(middleware => middleware(middlewareAPI))
      dispatch = compose(...chain)(store.dispatch)
      // 通过扩展运算符拆开store后合并成新的对象以更改dispatch方法
      return {
        ...store,
        dispatch
      }
    }
}

重点说一下这两行代码

const chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)

之前说过,中间件的编写格式store => next => action => {// do something},对照上面的代码来分析,假设我们按照以上的编写格式写了两个中间件分别是middleware1middleware2如下所示:

const middleware1 = store => next => async(action) => {
  console.info('middleware1 start')
  await next(action)
  console.info('middleware1 end')
}

const middleware2 = store => next => async(action) => {
  console.info('middleware2 start')
  await next(action)
  console.info('middleware2 end')
}

当调用applyMiddleware(middleware1,middleware2)传入这两个中间件,applyMiddleware内部执行到const chain = middlewares.map(middleware => middleware(middlewareAPI))这一条语句时,middlewareAPI对应编写格式中的store形参,返回的chain是一个数组,其中的元素为 next => action => {// do something} 格式的函数,即是一个描述如何调用dispatch的函数(next是一个经包装或者原始的dispatch,通过next(action)可以派发action)。

轮到下一条语句dispatch = compose(...chain)(store.dispatch),当执行compose(...chain)时,根据注释中compose函数的源码我们可以推断该语句执行后返回的结果为: (...args: any) =>chain1(chain2(...args)),最终把store.dispatch作为形参传入该函数时,相当于执行chain1(chain2(store.dispatch)),会有下图的执行过程:

image.png

首先执行chain2函数,store.dispatch作为chain2中的next新参传入,chain2立即返回一个格式为action=>{} 的函数,该函数作为chain1中的next新参传入chain1中,而chain1也会返回一个格式一样为action=>{} 的函数赋值给dispatch。该dispatch会在applyMiddleware函数中最后的语句return {...store,dispatch}store合并返回出去。以上过程中,chain1chain2返回的action=>{}的函数都以闭包的方式记录着next变量。

当在开发代码中dispatch(action)被调用时,会呈现以下的调用流程:

image.png

dispatch指向chain1,故先执行chain1,执行到next(action)语句时,其next指向chain2,故开始执行chain2,执行到next(action)语句时,next指向store原始的dispatch方法,从而实现了增强dispatch方法。上面的调用流程中控制台的输出会是以下的结果:

middleware1 start
middleware2 start
middleware2 end
middleware1 end

关于异步action

存在以下需求,我需要把github中的表情包数据放到Redux store中供项目里的多个模块使用,而这些数据需要异步请求获取,这时候我们遇到一个难题,因reducer原则上是纯函数,因此,异步操作这类不纯的行为不能出现在reducer中,针对此问题,我们可以绕个弯子,写个如下的公共函数,获取响应后调用dispatch设置状态,下面我来写一个例子来实践一下上述思路:

utils\index.js

import store from '../store'
import {SET_EMOJIS} from '../store/action'

// 公共函数,用于请求或更新表情图数据
export function requestEmojis(){
  fetch('https://api.github.com/emojis') // 数据从github的公共开放接口获取
    .then(res=>res.json())
    .then(emojis=>store.dispatch(SET_EMOJIS(emojis)))
}

下面是store的代码:

store\index.js

import { createStore } from 'redux'
import reducer from './reducer'

// 把数据初始值设为对象
const store = createStore(reducer,{})
export default store

store\action\index.js

// 用于生成设置表情图数据的action的action creator
export const SET_EMOJIS=(emojis)=>({
  type:'SET_EMOJIS',
  emojis
})

store\reducer\index.js

const reducer = (state,action)=>{
  switch (action.type) {
    case 'SET_EMOJIS':
      return action.emojis
    default:
      return state
  }
}

export default reducer

最后我们来通过以下组件查看效果:

App.jsx

import  React  from 'react';
import { connect } from 'react-redux'
import {requestEmojis} from '../utils'

const App = (props)=>{
  const {emojis} = props
  return <div>
    <h2>emojis</h2>
    // 点击该按钮后通过调用公共方法requestEmojis获取表情图并存到Redux store中
    <button onClick={requestEmojis}>获取emojis</button><br/>
    {
      Object.entries(emojis)
        .slice(0,50) // 数据有点多,所以只显示50个表情图
        .map(([key,value])=>
          <img src={value} alt={key} title={key} key={key}/>
        )
    }
  </div>
}

const mapStateToProps = (state) => ({
  emojis:state
})

export default  connect(mapStateToProps,null)(App)

最后我们来看一下效果:

async emojis.gif

项目地址

但在实际开发中,这种做法并不常用,原因可以等我介绍了redux-thunk的用法后,再拿这两种用法分析对比。

我们更偏向于利用第三方插件实现异步action异步action指指向异步操作的action。下面我们来依次看一下上面所说到的常用的第三方插件redux-thunkredux-promise

redux-thunk

使用方法

我们在上面的例子引入redux-thunk进行改造,在调用createStore创建Redux store时,就要通过applyMiddleware加载redux-thunk,如下所示:

store\index.js

import { createStore,applyMiddleware } from 'redux'
import reducer from './reducer'
import thunk from 'redux-thunk'

const store = createStore(reducer,{}, applyMiddleware(thunk))
export default store

然后我们在 store\action\index.js 中加一个异步action如下所示: (注意此处的action是一个函数,而并非是以往的带type属性的纯对象)

store\action\index.js

export const SET_EMOJIS=(emojis)=>({
  type:'SET_EMOJIS',
  emojis
})

// 此处的异步action为一个高阶函数,返回结果也是一个函数
// 此处的REQUEST_EMOJIS也是一个Action Creator,所谓Action Creator指创建异步action或同步action的函数
export const REQUEST_EMOJIS = ()=>dispatch => (
  fetch('https://api.github.com/emojis')
    .then((res)=>res.json())
    .then(emojis => dispatch(SET_EMOJIS(emojis)))
)

最后更改一下App.jsx

App.jsx

import  React  from 'react';
import { connect } from 'react-redux'
import {REQUEST_EMOJIS} from '../store/action/index'

const App = (props)=>{
  const {emojis} = props
  return <div>
    <h2>emojis</h2>
    <button onClick={props.requestEmojis}>获取表情图</button>
    <br/>
    {
      Object.entries(emojis).slice(0,50).map(([key,value])=>
        <img src={value} alt={key} title={key} key={key}/>
      )
    }
  </div>
}

const mapStateToProps = (state) => ({
  emojis:state
})

const mapDispatchToProps = (dispatch) => ({
  requestEmojis: () => dispatch(REQUEST_EMOJIS()),
})

export default  connect(mapStateToProps,mapDispatchToProps)(App)

这样子就可以不调用异步请求的公共函数的同时也实现上面的效果,项目地址

值得注意的是,被dispatch派发的 异步action 是一个函数,格式是(dispatch, getState, extraArgument)=>{}

源码分析

现在来分析一下redux-thunk的源码,源码非常简洁,如下所示:

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => (next) => (action) => {
    // 如果传入的action是一个函数,则代表该action为异步action,则把dispatch, getState, extraArgument作为形参传入该异步action执行
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

上面的代码太精简了,我觉得我都不用解释什么了,不过从源码中我们可以看出一点,在使用redux-thunk时,异步action 必须写成(dispatch, getState, extraArgument)=>{} 格式,然后在执行过程中需要调用dispatch(action)派发。

拓展:为什么要用redux-thunk(此章节可跳过)

此章节可能跟文章无关,我是兴趣之余写的,可以直接跳过

为什么目前大多数用的是redux-thunk而不是像开头的异步公共函数的方式去解决异步操作。我从stackoverflow中的问题how-to-dispatch-a-redux-action-with-a-timeout其中Dan Abramov(Redux作者) 的回答中得出了主要的答案:

对比于redux-thunk,使用异步公共函数的方式会导致:

  1. 不利于服务端渲染

    答案中是这么写的:

    The main reason we dislike it is because it forces store to be a singleton. This makes it very hard to implement server rendering. On the server, you will want each request to have its own store, so that different users get different preloaded data.

    在Redux关于服务端渲染的链接Redux Server Rendering中,这里 我们可以知道,每一次请求经服务端渲染的页面时,后端都会:

    1. 创建一个新的Redux store,选择性地派发部分action
    2. 然后模板页面可能某些占位符用Redux storestate的数据填充
    3. Redux store获取state,然后在和已渲染的HTML放到响应信息中一并传到客户端。客户端会根据响应的state创建Redux store

    在上面采用异步公共函数的方式方案的例子中,store出现在两个地方,一处是<Provider store={store}>中,一处是 requestEmojis公共函数中,在服务端渲染中如果调用到 requestEmojis,那需要保证两个地方的store是同一个实例。这样子会增加后端代码的复杂度。但是如果使用redux-thunk,那store只出现在<Provider store={store}>中,我们不需要考虑保证单例的问题。

  2. 不利于测试代码的编写

    引用答案中的描述:

    A singleton store also makes testing harder. You can no longer mock a store when testing action creators because they reference a specific real store exported from a specific module. You can’t even reset its state from outside.

    在保证上述所说的单例时,我们会很难编写测试用例,因为对于requestEmojis公共函数的测试中,其调用的store是一个真正的Redux store,其duspatch的调用会影响到页面的显示,因此,我们不能通过jestbypassing-module-mocks中的jest.mock去取替这个store

    Redux不推荐手写Action Creator,他们更推荐使用@reduxjs/toolkit去生成Action Creator。更详细的资料可参考action-creators--thunks

  3. 难以区分容器组件和展示组件

    This makes it trickier to separate container and presentational components because any component that dispatches Redux actions asynchronously in the manner above has to accept dispatch as a prop so it can pass it further.

    什么是容器组件(container components)展示组件(presentational components),我引用别的文章的一张图来解释:

    Redux把接受来自Redux store数据和行为的组件称为容器组件,与Redux store数据和行为无任何关系的组件称为展示容器。一般通过connectRedux store数据和行为注入到展示容器后会成为容器组件。如下图所示:

    image.png

    写代码时区分这两种组件会让我们的编写组件逻辑更加清晰,通常严格规范的项目都会把展示组件容器组件写在不同的文件夹下,如Redux官网的例子Todo 列表

    但如果用异步公共函数的方式,则不利于区分这两种组件,就拿开头例子中的App.jsx来说明:

    image.png

    上面代码中的App变量里<button onClick={requestEmojis}>获取emojis</button>已经注入了requestEmojis方法,而该方法里面已经包含了Redux store行为。所以在此已经不能区分容器组件展示组件 了。

综上,我们更推荐使用Redux-thunk取替异步公共函数的方式的方案。

redux-promise

使用方法

我们继续用上面表情图的例子,只是这次把redux-thunk换成redux-promise。首先在用createStore创建store时,和redux-thunk的配置一样,用applyMiddleware加载从redux-promise引入的插件,如下所示:

import { createStore,applyMiddleware } from 'redux'
import reducer from './reducer'
import promiseMiddleware from 'redux-promise';

const store = createStore(reducer,{}, applyMiddleware(promiseMiddleware))
export default store

接下来就是根据redux-promise规定的格式编写action creator了,此处的action creator有两种写法:

1. action creator是一个函数,其返回值必须是一个promisepromise最后resolve的是一个同步的action,该action会直接设置Redux store中的state值。如下所示:

reducer

const reducer = (state,action)=>{
  switch (action.type) {
    // 处理第一种action creator
    case 'SET_EMOJIS':
      return action.emojis
    default:
      return state
  }
}

action

export const SET_EMOJIS=(emojis)=>({
  type:'SET_EMOJIS',
  emojis
})

// 第一种action creator写法:
// 此action creator执行后返回一个promise,promise.resolve的同步action会直接被Redux store的dispatch执行,而不是经过下一个中间件middleware的处理
export const REQUEST_EMOJIS=async()=>{
  const emojis = await fetch('https://api.github.com/emojis')
    .then(res=>res.json())
  return SET_EMOJIS(emojis)
}

2. action creator也是一个函数,其返回值必须是一个payloadpromiseFSA,FSA全称flux standard action,意指符合flux标准的action,该action的判断函数如下:

function isFSA(action) {
    //1. action必须是一个平面对象 plain-object
    //2. action.type必须是一个字符串
    //3. action的属性中不能出现["type", "payload", "error", "meta"]以外的属性
    return isPlainObject(action)&&
        isString(action.type)&&
        Object.keys(action).every(key => ["type", "payload", "error", "meta"].includes(key));
}

action

// 第二种action creator写法:
// payload必须是一个promise,中间件会先处理这个promise,等promise.resolve后把resolve的值替换这个promise放payload里,然后把action传给reducer处理
export const SET_EMOJIS1=()=>({
  type:'SET_EMOJIS1',
  payload:fetch('https://api.github.com/emojis').then(res=>res.json())
})

reducer

const reducer = (state,action)=>{
  switch (action.type) {
    // 处理第二种action creator
    // 其promise.resolve的值会放在payload上,即fecth请求的数据就放在payload上
    case 'SET_EMOJIS1':
      return action.payload
    default:
      return state
  }
}

以上代码可以在项目代码中查看。

源码分析

import isPromise from 'is-promise';
import { isFSA } from 'flux-standard-action';

export default function promiseMiddleware({ dispatch }) {
  return next => action => {
    // 判断action是否为FSA
    if (!isFSA(action)) {
      // 判断action是否为promise,若是则按上述第一种action处理,如果不是则传递给下一个middleware处理
      // 注意如果action以reject的形式结束,则不会执行下去
      return isPromise(action) ? action.then(dispatch) : next(action);
    }

    // 如果action为FSA且action.payload是一个promise,则按上述第二种action处理:
    // 即等其resolve后,把resolve的值替换当前action的payload,然后跳过接下来的中间件直接让store.dispatch派发action
    // 如果promise是catch,我就不说了,下面写的很清楚了
    return isPromise(action.payload)
      ? action.payload
          .then(result => dispatch({ ...action, payload: result }))
          .catch(error => {
            dispatch({ ...action, payload: error, error: true });
            return Promise.reject(error);
          })
      : next(action);
  };
}

后记

之后会继续写关于dva用法的文章,再次立个FLAG鼓励自己再接再厉。