Redux 梳理分析【二:combineReducers和中间件】

1,139 阅读9分钟

当一个应用足够大的时候,我们使用一个reducer函数来维护state是会碰到麻烦的,太过庞大,分支会很多,想想都会恐怖。基于以上这一点,redux支持拆分reducer,每个独立的reducer管理state树的某一块。

combineReducers 函数

随着应用变得越来越复杂,可以考虑将 reducer 函数 拆分成多个单独的函数,拆分后的每个函数负责独立管理 state 的一部分。combineReducers 辅助函数的作用是,把一个由多个不同 reducer 函数作为 value 的 object,合并成一个最终的 reducer 函数,然后就可以对这个 reducer 调用 createStore 方法。

根据redux文档介绍,来看一下这个函数的实现。

export default function combineReducers(reducers) {
...
  return function combination(state = {}, action) {
  ...
  }
}

先看一下函数的结构,就如文档所说,传入一个key-value对象,value为拆分的各个reducer,然后返回一个reducer函数,就如代码里面的combination函数,看入参就知道和reducer函数一致。

检查传入的 reducers 对象的合理性

检查的操作就是在返回之前,看看代码。

const reducerKeys = Object.keys(reducers)
const finalReducers = {}
for (let i = 0; i < reducerKeys.length; i++) {
  const key = reducerKeys[i]

  if (process.env.NODE_ENV !== 'production') {
    if (typeof reducers[key] === 'undefined') {
      warning(`No reducer provided for key "${key}"`)
    }
  }

  if (typeof reducers[key] === 'function') {
    finalReducers[key] = reducers[key]
  }
}
const finalReducerKeys = Object.keys(finalReducers)

// This is used to make sure we don't warn about the same
// keys multiple times.
let unexpectedKeyCache
if (process.env.NODE_ENV !== 'production') {
  unexpectedKeyCache = {}
}

let shapeAssertionError
try {
  assertReducerShape(finalReducers)
} catch (e) {
  shapeAssertionError = e
}
  1. 使用Object.keys拿到入参对象的key,然后声明一个finalReducers变量用来存方最终的reducer
  2. 遍历reducerKeys,检查每个reducer的正确性,比如控制的判断,是否为函数的判断,如果符合规范就放到finalReducerKeys对象中。
  3. 使用Object.keys获取清洗后的key
  4. 通过assertReducerShape(finalReducers)函数去检查每个reducer的预期返回值,它应该符合以下:
    1. 所有未匹配到的 action,必须把它接收到的第一个参数也就是那个 state 原封不动返回。
    2. 永远不能返回 undefined。当过早 return 时非常容易犯这个错误,为了避免错误扩散,遇到这种情况时 combineReducers 会抛异常。
    3. 如果传入的 state 就是 undefined,一定要返回对应 reducer 的初始 state。

combination 函数

经过了检查,最终返回了reducer函数,相比我们直接写reducer函数,这里面预置了一些操作,重点就是来协调各个reducer的返回值。

if (shapeAssertionError) {
  throw shapeAssertionError
}

if (process.env.NODE_ENV !== 'production') {
  const warningMessage = getUnexpectedStateShapeWarningMessage(
    state,
    finalReducers,
    action,
    unexpectedKeyCache
  )
  if (warningMessage) {
    warning(warningMessage)
  }
}

如果之前检查有警告或者错误,在执行reducer的时候就直接抛出。

最后在调用dispatch函数之后,处理state的代码如下:

let hasChanged = false
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
}
hasChanged =
  hasChanged || finalReducerKeys.length !== Object.keys(state).length
return hasChanged ? nextState : state
  1. 声明一个变量isChanged来表示,经过reducer处理之后,state是否变更了。
  2. 遍历 finalReducerKeys
  3. 获取reducer和对应的key并且根据key获取到state相关的子树。
  4. 执行reducer(previousStateForKey, action)获取对应的返回值。
  5. 判断返回值是否为undefined,然后进行相应的报错。
  6. 将返回值赋值到对应的key中。
  7. 使用===进行比较新获取的值和state里面的旧值,可以看到这里只是比较了引用,注意redcuer里面约束有修改都是返回一个新的state,所有如果你直接修改旧state引用的话,这里的hasChanged就会被判断为false,在下一步中,如果为false就会返回旧的state,数据就不会变化了。
  8. 最后遍历完之后,通过hasChanged判断返回原始值还是新值。

添加中件件

当我们需要使用异步处理state的时候,由于reducer必须要是纯函数,这和redux的设计理念有关,为了可以能追踪到每次state的变化,reducer的每次返回值必须是确定的,才能追踪到。具体放在后面讲。

当使用中间件,我们需要通过applyMiddleware去整合中间件,然后传入到createStore函数中,这时候相应的流程会发生变化。

先看看createStore函数对这部分的处理。

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

  return enhancer(createStore)(reducer, preloadedState)
}

这里的enhancer就是applyMiddleware(thunk, logger, ...)执行后的返回值。可以看到,enhancer函数执行,需要把createStore函数传入,说明enhancer内部会自己去处理一些其他操作后,再回来调用createStore生成store

applyMiddleware 函数

首先看一下applyMiddleware的结构。

export default function applyMiddleware(...middlewares) {
  return createStore => (...args) => {
  ...
  }
}

可以看到applyMiddleware函数啥都没干,只是对传入的middlewares参数形成了一个闭包,把这个变量缓存起来了。确实很函数式。

接下来看一下它的返回的这个函数:

createStore => (...args) => {}

它返回的这个函数也只是把createStore缓存(柯里化绑定)了下来,目前在createStore执行到了这一步enhancer(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
  }
}
  1. 调用createStore传入reducer, preloadedState这两个参数,也就是...args,生成store
  2. 声明变量dispatch为一个只会抛错误的空函数。
  3. 构造 middlewareAPI变量,对象里面有两个属性,分别为getStatedispatch,这里的dispatch是一个函数,执行的时候会调用当前作用域的dispatch变量,可以看到,在这一步dispatch还是那个空函数。
  4. 遍历传入的middlewares,将构建的middlewareAPI变量传入,生成一个新的队列,里面装的都是各个中间件执行后的返回值(一般为函数)。
  5. 通过函数 compose 去生成新的dispatch函数。
  6. 最后把store的所有属性返回,然后使用新生成的dispatch去替换默认的dispatch函数。

compose 函数

中间件的重点就是将dispatch替换成了新生成的dispatch函数,以至于可以在最后调用store.dispatch之前做一些其他的操作。生成的核心在于compose函数,接下来看看。

export default 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)))
}
  1. 如果参数的长度为0,就返回兜底一个函数,这函数只会把传入的形参返回,没有其他操作。
  2. 如果参数的长度为1,就将这个元素返回。
  3. 这个情况就是说有多个参数,然后调用数组的reduce方法,对这些参数(函数),进行一种整合。看看官方注释:

    For example, compose(f, g, h) is identical to doing (...args) => f(g(h(...args))). 这就是为什么像logger这样的中间件需要注意顺序的原因了,如果放在最后一个参数。最后一个中间件可以拿到最终的store.dispatch,所有能在它的前后记录变更,不受其他影响。nodejskoa框架的洋葱模型与之类似。

再回到applyMiddleware函数,经过compose函数处理后,最后返回了一个函数。

compose(...chain)(store.dispatch)

再把store.dispatch传入到这些整合后的中间件后,得到最后的dispatch函数。

redux-thunk 中间件

看了redux是怎么处理整合中间件的,看一下redux-thunk的实现,加深一下印象。

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;

可以看到最终导出的是createThunkMiddleware函数的返回值,这就是中间件的一个实现了。

  1. 第一个函数,得到的是store,也就是applyMiddleware函数在执行 const chain = middlewares.map(middleware => middleware(middlewareAPI)) 会传入的。
  2. 第二个函数,是在compose(...chain)(store.dispatch)函数得到的,这里会将其他的中间件作为参数next传入。
  3. 第三个函数,就是用来实现自己的逻辑了,拦截或者进行日志打印。

可以看到,当传入的action为函数的时候,直接就return了,打断了中间件的pie执行,而是去执行了action函数里面的一些异步操作,最后异步成功或者失败了,又重新调用dispatch,重新启动中间件的pie

尾巴

上面说到,为什么reducer为什么一定需要是纯函数?下面说说个人理解。

通过源码,可以反应出来。hasChanged = hasChanged || nextStateForKey !== previousStateForKey ... return hasChanged ? nextState : state

从这一点可以看到,是否变化redux只是简单的使用了精确等于来判断的,如果reducer是直接修改旧值,那么这里的判断会将修改后的丢弃掉了。那么为什么redux要这么设计呢?我在网上查了一些文章,说的最多的就是说,如果想要判断A、B两对象是否相对,就只能深度对比每一个属性,我们知道redux应用在大型项目上,state的结构会很庞大,变更频率也是很高的,每次都进行深度比较,消耗很大。所有redux就把这个问题给抛给开发者了。

还有为什么reducer或者vuex里面的mutation中,不能执行异步操作,引用·vuex官方文档:


Mutation 必须是同步函数 一条重要的原则就是要记住 mutation 必须是同步函数。为什么?请参考下面的例子:

mutations: {
  someMutation (state) {
    api.callAsyncMethod(() => {
      state.count++
    })
  }
}

现在想象,我们正在 debug 一个 app 并且观察 devtool 中的 mutation 日志。每一条 mutation 被记录,devtools 都需要捕捉到前一状态和后一状态的快照。然而,在上面的例子中 mutation 中的异步函数中的回调让这不可能完成:因为当 mutation 触发的时候,回调函数还没有被调用,devtools 不知道什么时候回调函数实际上被调用——实质上任何在回调函数中进行的状态的改变都是不可追踪的。


文档地址reducer也是同理。

小结

几行代码可以做很多事情,比如中间件的串联实现,函数式的编程令人眼花缭乱。

分析了combineReducerapplyMiddlewareredux也就梳理完了。中间件的编程思想很值得借鉴,在中间件上下相互不知的情况下,也能很好的协作。

参考文章

  1. 图解Redux中middleware的洋葱模型

原文地址