Fork me on GitHub

论Redux异步流

 故事的开始,是有一天一个带哥问了我redux的applyMiddleware做了什么…

  这篇文章,会先从applyMiddleware的源码开始分析,只讨论核心代码实现。一些琐碎的部分会被略去,最终延伸到redux异步流处理方案。

准备道具

  本着认真负责的态度,我希望阅读这篇文章的人都能有所收获(带哥可以略过)。因此,会先从一些比较基础的东西开始。

闭包

  啥是闭包,简单点来讲就是你在一个函数里返回了一个函数,在返回的这个函数内,你具有访问包裹它的函数作用域内的变量的能力。

  一般来说在我们声明的函数体内声明变量,只会在函数被调用时在当前函数块的作用域内存在。当函数执行完毕后会垃圾回收。但!如果我们返回的函数中存在对那个变量的引用,那这个变量便不会在函数调用后被销毁。也基于这一特性,延展出很多闭包的应用,如常见的防抖(throttle)、节流(debounce)函数,它们都是不断对内部的一个定时器进行操作;又如一些递归的缓存结果优化,也是设置了一个内部对象去比对结果来跳过一些冗余的递归场景。

1
2
3
4
5
6
7
8
9
10
function throttle(fn, wait) {
let timeStart = 0; // 不会被销毁,返回的函数执行时具有访问该变量的能力
return function (...args) {
let timeEnd = Date.now();
if (timeEnd - timeStart > wait) {
fn.apply(this, args);
timeStart = timeEnd;
}
}
}

HOC(高阶函数or组件)与Compose(组合)

  啥是高阶函数,其实跟上面的闭包的操作手段有点像,最终都会再返回一个函数。只不过它会根据你实际需求场景进行一些附加的操作来“增强”传入的原始函数的功能。像React中的一些HOC(高阶组件)的应用其实也是同理,毕竟class也不过是function的语法糖。网上的应用场景也很多,这里不赘述了。主要再提一嘴的是compose函数,它能让我们在进行多层高阶函数嵌套时,书写代码更为清晰。如我们有高阶函数A、B、C ,要实现A(B(C(…args)))的效果,如果没有compose,就需要不断地将返回结果赋值,调用。而使用compose,只需要一次赋值let HOC = compose(A, B, C);,然后调用HOC(...args)即可。

  瞅瞅compose源码,比较简单,无传参时,返回一个按传入返回的函数;一个入参时,直接返回第一个入参函数;多个则用数组的reduce方法进行迭代,最终返回组合后的结果:

1
2
3
4
5
6
7
8
9
10
11
function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}

if (funcs.length === 1) {
return funcs[0]
}

return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

isPlainObject

  这个工具方法比较简单,就是来判断入参是否是由Object直接构造的且中间没有修改继承关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let isObjectLike = obj => {
return typeof obj === 'object' && obj !== null;
}

let isPlainObject = obj => {
if (!isObjectLike(obj) || !Object.prototype.toString.call(obj) === '[object Object]') {
return false;
}
if (Object.getPrototypeOf(obj) === null) return true; // Object.prototype 本身
let proto = obj; // 拷贝指针,移动指针直至原型链顶端
while (Object.getPrototypeOf(proto) !== null) { // 是否纯粹,如果中间发生继承,则__proto__的最终跨越将不会是1层
proto = Object.getPrototypeOf(proto);
}
return Object.getPrototypeOf(obj) === proto;
}

庖丁解牛

  在聊applyMiddleware前,我们有必要先分析一波createStore内做了什么操作,因为他们俩其实是一个相互成就依赖注入的关系。

createStore

1
2
3
4
5
6
7
8
9
10
function createStore(reducer, preloadedState, enhancer) {
// 略
// return {
// dispatch, // 去改变state的方法 派发 action
// subscribe, // 监听state变化 然后触发回调
// getState, // 访问这个createStore的内部变量currentState 也就是全局那个大state
// replaceReducer, // 传入新的reducer 来替换之前内部的reducer 可能场景是在代码拆分、redux的热加载?
// [$$observable]: observable // symbol属性 返回一个observable方法
// }
}

  从源码中的声明可以看到,createStore接收三个参数,第一个是reducer,这个在项目中通常我们会用combineReducers组合成一个大的reducer传入。

combineReducers

  这个combineReducers使用频率还是很高的,我们先简要看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
   function combineReducers(reducers) {
// 略去一些
return function combination(state = {}, action) {
const nextState = {}
for (let i = 0; i < finalReducerKeys.length; i++) {
const key = finalReducerKeys[i]
const reducer = finalReducers[key]
const previousStateForKey = state[key]
const nextStateForKey = reducer(previousStateForKey, action)
if (typeof nextStateForKey === 'undefined') {
const errorMessage = getUndefinedStateErrorMessage(key, action)
throw new Error(errorMessage)
}
nextState[key] = nextStateForKey
hasChanged = hasChanged || nextStateForKey !== previousStateForKey
}
return hasChanged ? nextState : state
}
}
/**
* 比如传入的子reducer函数是
* function childA(state = 0, action) {
* switch (action.type) {
* case 'INCREMENT':
* return state + 1
* case 'DECREMENT':
* return state - 1
* default:
* return state
* }
* }
* 那初始情况下的store.getState() // { childA: 0 }
*/

  首先combineReducers接收一个对象,里面的key是每一个小reducer文件或函数导出的namespacevalue则是与其对应的reducer函数实体。然后它会将这些不同的reducer函数合并到一个reducer函数中。它会调用每一个合并的子reducer,并且会将他们的结果放入一个state中,最后返回一个闭包使我们可以像操作之前的子reducer一样操作这个大reducer

  preloadedState就是我们传入的初始state,当然源码中的注释里描述还可以向服务端渲染中的应用注入该值or恢复历史用户的session记录,不过没实践过,就不延展了…

  最后的入参enhancer比较关键,字面理解就是用来增强功能的,先看看部分源码:

1
2
3
4
5
6
7
8
9
10
11
if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
enhancer = preloadedState
preloadedState = undefined
}

if (typeof enhancer !== 'undefined') {
if (typeof enhancer !== 'function') {
throw new Error('Expected the enhancer to be a function.')
}
return enhancer(createStore)(reducer, preloadedState)
}

  在这里我们发现其实createStore可以只接收2个参数,当第二个参数为函数时,会自动初始化stateundefined,所以看到一些createStore只传了2个参数不要觉得奇怪。

applyMiddleware

  然后往下看对enhancer函数的调用,这写法一看就是个高阶函数,接收一个方法createStore,然后返回一个函数。现在我们可以把applyMiddleware抬上来了,这个API也是redux本身唯一提供的用于store enhancer的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function applyMiddleware(...middlewares) {
return createStore => (...args) => {
const store = createStore(...args)
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: (...args) => dispatch(...args)
}
const chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)

return {
...store,
dispatch
}
}
}

redux-thunk

  我们注意到applyMiddleware作为enhancer又把createStore这个函数作为参数传入并在内部返回函数中调用了,这其实也是依赖注入的理念。然后我们发现内部其实将applyMiddleware的入参传入的中间件都执行了一次,传参为getStatedispatch。这里可能初见者比较懵逼,我们先把早期处理异步action的中间件redux-thunk的源码翻出来看一眼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}

return next(action);
};
}

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

export default thunk;

  通过代码,我们可以得知一般middleWare的内部构造都遵从一个({ getState, dispatch }) => next => action => {...}的范式,并且导出的时候已经被调用了一次,即返回了一个需要接收getStatedispatch的函数。

  Get到这一点以后,我们再往后看。通过compose将中间件高阶组合并“增强”传入原store.dispatch的功能,最后再在返回值内解构覆盖原始storedispatch

  所以这个时候,如果我再问applyMiddleware做了什么?应该大家都知道答案了吧,就是增强了原始createStore返回的dispatch的功能。

  那再回到那个如何处理redux中的异步数据流问题?其实核心解决方案就是引入中间件,而中间件最终达成的目的就是增强我们的原始dispatch方法。还是以上面的redux-thunkmiddleware来说,它传入的dispatch就是它内部的next,换言之,调用时,如果action是个普通对象,那就跟往常dispatch没啥差别,正常走reducer更新状态;但如果是个函数,那我们就要让action自己玩了自己去处理内部的异步逻辑了,比如什么网络请求,当Promiseresolveddispatch一个成功actionrejecteddispatch一个失败action

redux-devtools-extension

  在开发环境中,为了追溯以及定位一些数据流向,我们会引入redux-devtools-extension,这个模块有2种使用方式,一种是沉浸式,即在开发环境安装对应依赖,然后通过2次增强我们的applyMiddleWare返回一个传入createStore中的enhancer,比如下面这样的:

1
2
3
4
5
6
7
8
9
import { composeWithDevTools } from 'redux-devtools-extension';

const composeEnhancers = composeWithDevTools(options);
const store = createStore(reducer, /* preloadedState, */
composeEnhancers(
// 一个 enhancer入口 套中套
applyMiddleware(...middleware),
// other store enhancers if any
));

  又或者是插件扩展式的:

1
2
3
4
const composeEnhancers = typeof window === 'object' && typeof window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ !== 'undefined' ?
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) : compose;

// 剩下操作跟上面一样

  更细节定制见官方

收工漫谈

  现在处理异步逻辑的中间件已经不少了,但是原理都是差不多的,只不过说从以前的传function,到PromiseGenerator控制之类的;像前文例子的redux-thunk是比较早的异步中间件了,之后社区中有了更多的方案提供:如redux-promiseredux-sagadvajsredux-observable等等。我们还是需要根据实际团队和业务场景使用最适合我们的方案来组织代码编写。

简单回忆

  1. store本身的dispatch派发action更新数据这个动作是同步的;
  2. 所谓异步action,是通过引入中间件的方案增强dispatch后实现的。具体是applyMiddleware返回dispatch覆盖原始storedispatch
  3. 为何会采取这种中间件增强的模式,我个人看来一是集中在一个位置方便统一控制处理,另一个则是减少代码中的冗余判断模板;

课后思考

  认真阅读文章的朋友,可能会有一个思考。

  redux-thunkredux-saga这些中间件的编写范式中next和我们的dispatch到底有什么关系?

  前文中,我们仅使用了redux-thunk来进行applyMiddleware能力的阐释,不过既然是中间件,我们大可以再添加一个比较常见的工具中间件redux-logger来进行结合说明。

  可以看到logger实例的构造函数内,刨除对console是否存在的判断及是否作为applyMiddleware唯一参数的判断外,范式也是遵从middlewareAPI定义的。

  那么我们看一个常见应用场景,对我们每一个dispatch动作进行日志打印:

1
2
3
4
const store = createStore(
reducer,
applyMiddleware(thunk, logger)
);

  redux-logger的使用必须放在中间件的最后一个,原因也很简单,它应该是包裹增强dispatch最近的一层,即发生调用时,输出日志,这就是它的功效。而根据源码中compose组合的高阶函数,compose(A,B)(...args) => A(B(...args)),也确实反应了这一点。

  那么我们回归主线,实际上第一层的redux-logger它返回的就是一个action => { 定制打印能力...; return next(action) }的箭头函数, 而这第一层的next实际上就是store.dispatch。综合来看,第一层中间件(我们这里是logger),就是在执行dispatch基础职能之上再额外定制了一些打印的能力,然后将这个增强的高阶函数HOC1(1层强化版dispatch),交给下一个中间件的next使用。最终走完整条中间件链。