前言
上一篇文章写了关于redux
的作用以及redux
和react-redux
两个插件的API,但redux
中有一个API:applyMiddleware
并没有说明,因为涉及到redux
的中间件概念,需要比较多内容去说明,这次这篇文章就集中写一下这方面的知识。
何为redux
中间件
1. 中间件的作用
之前我们说过,redux
的工作流程图是下面这样的:
我们看express
有中间件机制,其实redux
也有,redux
的中间件middleware
是用来增强dispatch
方法的。有时候当我们想改变dispatch
执行同时,也执行某些操作,例如日志记录,就可以用中间件实现该需求。如果我们把中间件也纳入到redux
的工作流程图,那新的流程图如下所示:
2. 用到中间件的简单例子
我们可以拿一个例子来说一下中间件,在上一篇文章中,我们写了一个计数和单位切换的例子,现在拿这个例子再添加一个需求,我希望可以从控制台里知道页面程序调用了哪些action
。虽然可以在每个action creator
都写打印输出语句,可是这不是最优解,我可以通过插入中间件来达到这个需求:
目录如下所示:
新增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
达到的效果如下所示:
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 store
。applyMiddleware(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}。接下来我们看一下生成enhancer
的applyMiddleware
函数是怎样子的:
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},对照上面的代码来分析,假设我们按照以上的编写格式写了两个中间件分别是middleware1
和middleware2
如下所示:
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))
,会有下图的执行过程:
首先执行chain2
函数,store.dispatch
作为chain2
中的next
新参传入,chain2
立即返回一个格式为action=>{} 的函数,该函数作为chain1
中的next
新参传入chain1
中,而chain1
也会返回一个格式一样为action=>{} 的函数赋值给dispatch
。该dispatch
会在applyMiddleware
函数中最后的语句return {...store,dispatch}
与store
合并返回出去。以上过程中,chain1
和chain2
返回的action=>{}
的函数都以闭包的方式记录着next
变量。
当在开发代码中dispatch(action)
被调用时,会呈现以下的调用流程:
因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)
最后我们来看一下效果:
但在实际开发中,这种做法并不常用,原因可以等我介绍了redux-thunk
的用法后,再拿这两种用法分析对比。
我们更偏向于利用第三方插件实现异步action,异步action指指向异步操作的action
。下面我们来依次看一下上面所说到的常用的第三方插件redux-thunk
和redux-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
,使用异步公共函数的方式会导致:
-
不利于服务端渲染
答案中是这么写的:
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中,这里 我们可以知道,每一次请求经服务端渲染的页面时,后端都会:
- 创建一个新的
Redux store
,选择性地派发部分action
, - 然后模板页面可能某些占位符用
Redux store
中state
的数据填充 - 从
Redux store
获取state
,然后在和已渲染的HTML
放到响应信息中一并传到客户端。客户端会根据响应的state
创建Redux store
。
在上面采用异步公共函数的方式方案的例子中,
store
出现在两个地方,一处是<Provider store={store}>
中,一处是requestEmojis
公共函数中,在服务端渲染中如果调用到requestEmojis
,那需要保证两个地方的store
是同一个实例。这样子会增加后端代码的复杂度。但是如果使用redux-thunk
,那store
只出现在<Provider store={store}>
中,我们不需要考虑保证单例的问题。 - 创建一个新的
-
不利于测试代码的编写
引用答案中的描述:
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
的调用会影响到页面的显示,因此,我们不能通过jest
里bypassing-module-mocks中的jest.mock
去取替这个store
。Redux
不推荐手写Action Creator
,他们更推荐使用@reduxjs/toolkit去生成Action Creator
。更详细的资料可参考action-creators--thunks。 -
难以区分容器组件和展示组件
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
数据和行为无任何关系的组件称为展示容器。一般通过connect
把Redux store
数据和行为注入到展示容器后会成为容器组件。如下图所示:写代码时区分这两种组件会让我们的编写组件逻辑更加清晰,通常严格规范的项目都会把展示组件和容器组件写在不同的文件夹下,如
Redux
官网的例子Todo 列表。但如果用异步公共函数的方式,则不利于区分这两种组件,就拿开头例子中的App.jsx来说明:
上面代码中的
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
是一个函数,其返回值必须是一个promise
,promise
最后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
也是一个函数,其返回值必须是一个payload
为promise
的FSA
,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鼓励自己再接再厉。