今天看啥  ›  专栏  ›  鲨叔

redux源码解析

鲨叔  · 掘金  ·  · 2019-12-18 05:39
阅读 45

redux源码解析

背景

从flux再到mobx,最后到redux。到目前为止,本人也算辗转了好几个数据流管理工具了。经过不断的实践和思考,我发现对于中大型项目来说,redux无疑是当下综合考量下最优秀的一个。所以我决定深入它的源码,一探究竟。

探究主题

redux的源码不多,但是都是浓缩的精华,可以说字字珠玑。 我们可以简单地将redux的源码划分为三部分。

  • redux这个类库/包的入口。这里就对应createStore.js的源码。
  • state的combine机制。这里对应于combineReducers.js。
  • 中间件的组合原理。这里对应于applyMiddleware.js和compose.js。

之所以没有提到utils文件夹和bindActionCreators.js,这是因为他们都是属于工具类的代码,不涉及到redux的核心功能,故略过不表。

与以上所提到的三部分代码相呼应的三个主题是:

  • createStore.js到底做了什么?
  • redux是如何将各个小的reducer combine上来,组建总的,新的state树呢?
  • 中间件机制是如何工作的呢?它的原理又是什么呢?

下面,让我们深入到源码中,围绕着这三个主题,来揭开redux的神秘面纱吧!

一、createStore.js到底做了什么?

为了易于理解,我们不妨使用面向对象的思维去表达。 一句话,createStore就是Store类的“构造函数”。

Store类主要有私有属性:

  • currentReducer
  • currentState
  • currentListeners
  • isDispatching

公有方法:

  • dispatch
  • subscribe
  • getState
  • replaceReducer

而在这几个公有方法里面,getStatereplaceReducer无疑就是特权方法。因为通过getState可以读取私有属性currentState的值,而通过replaceReducer可以对私有属性currentReducer进行写操作。虽然我们是使用普通的函数调用来使用createStore,但是它本质上可以看作是一个构造函数调用。通过这个构造函数调用,我们得到的是Store类的一个实例。这个实例有哪些方法,我们通过源码可以一目了然:

return {
    dispatch,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
  }
复制代码

官方推荐我们全局只使用一个Store类的实例,我们可以将此看作是单例模式的践行。 其实,我们也可以换取另外一个角度去解读createStore.js的源码:模块模式。 为什么可行呢?我们可以看看《你不知道的javascript》一文中如何定义javascript中的模块模式的实现:

  • 必须有外部的嵌套函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
  • 嵌套函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

我们再来看看createStore.js的源码的骨架。首先,最外层的就是一个叫createStore的函数,并且它返回了一个字面量对象。这个字面量对象的属性值保存着一些被嵌套函数(内部函数)的引用。所以我们可以说createStore函数其实是返回了至少一个内部函数。我们可以通过返回的内部函数来访问模块的私有状态(currentReducer,currentState)。而我们在使用的时候,也是至少会调用一次createStore函数。综上所述,我们可以把createStore.js的源码看作是一次模块模式的实现。

export default function createStore(reducer, preloadedState, enhancer) {
    //......
    //......
    return {
    dispatch,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
  }
}
复制代码

其实无论是从类模式还是模块模式上解读,createStore.js本质上就是基于闭包的原理实现了数据的封装-通过私有变量来防止了全局污染和命名冲突,同时还允许通过特定的方法来访问特定的私有变量。

二、redux是如何将各个小的reducer combine上来?state树的初始化如何进行的?后面又是如何更新的?

首先,我们得搞清楚什么是reducer?接触过函数式编程的人都知道,reducer是很纯粹的函数式编程概念。简单来说,它就是一个函数。只不过不同于普通函数(实现特定的功能,可重复使用的代码块)这个概念,它还多了一层概念-“纯”。对,reducer必须是纯函数。所谓的“纯函数”是指,同样的输入会产生同样的输出。比如下面的add函数就是一个纯函数:

function add(x,y){
    return x+y;
}
复制代码

因为你无论第几次调用add(1,2),结果都是返回3。好,解释到这里就差不多了。因为要深究函数式编程以及它的一些概念,那是说上几天几夜都说不完的。在这里,我们只需要明白,reducer是一个有返回值的函数,并且是纯函数即可。为什么这么说呢?因为整个应用的state树正是由每个reducer返回的值所组成的。我们可以说reducer的返回值是组成整颗state树的基本单元。而我们这个小节里面要探究的combine机制本质上就是关于如何组织state树-一个关于state树的数据结构问题。 在深入探究combine机制及其原理之前,我们先来探讨一下几个关于“闭包”的概念。

什么是“闭包”?当然,这里的闭包是指我们常说的函数闭包(除了函数闭包,还有对象闭包)。关于这个问题的答案可谓仁者见仁,智者见智。在这里,我就不作过多的探讨,只是说一下我对闭包的理解就好。因为我正是靠着这几个对闭包的理解来解读reducer的combine机制及其原理的。我对闭包的理解主要是环绕在以下概念的:

  1. 形成一个可以观察得到的闭包(代码书写期)
  2. 产生某个闭包(代码运行期)
  3. 进入某个闭包(代码运行期)

首先,说说“形成一个可以观察得到的闭包”。要想形成一个可以观察得到的闭包,我们必须满足两个前提条件:

  • (词法)作用域嵌套。
  • 内层作用域对外层作用域的标识符进行了访问/引用。

其次,说说“产生某个闭包”。正如周爱民所说的,很多人没有把闭包说清楚,那是因为他们忽略了一个事实:闭包是一个运行时的概念。对此,我十分认同。那么怎样才能产生一个闭包呢?这个问题的答案是在前面所说的那个前提条件再加上一个条件:

  • 通过调用嵌套函数,将被嵌套的函数实例引用传递到外层作用域(嵌套函数所在的词法作用域)之外。

我们可以简单地总结一下:当我们对一个在内部实现中形成了一个可以观察得到的闭包的函数进行调用操作的时候,我们就说“产生某个闭包”。

最后,说说“进入某个闭包”。同理,“进入某个闭包”跟“产生某个闭包”一样,都是一个运行时的概念,他们都是在函数被调用的时候所发生的。只不过,“进入某个闭包”所对应的函数调用是不一样而已。当我们对传递到嵌套作用域之外的被嵌套函数进行调用操作的时候,我们可以说“进入某个闭包”。

好了,经过一大堆的概念介绍后,你可能会有点云里雾里的,不知道我在说什么。下面我们结合combineReducers.js的源码和实际的使用例子来娓娓道来。

二话不说,我们来看看combineReducers.js的整体骨架。

export default function combineReducers(reducers) {
  const finalReducers = {}
  //省略了很多代码
  const finalReducerKeys = Object.keys(finalReducers)
  //省略了很多代码
  
  return function combination(state = {}, action) {
    let hasChanged = false
    const nextState = {}
     for (let i = 0; i < finalReducerKeys.length; i++) {
       const key = finalReducerKeys[i]
       const reducer = finalReducers[key]
       //省略了很多代码
    }
    return hasChanged ? nextState : state
  }
}
复制代码

在这里,我们可以看到combineReducers的词法作用域里面嵌套着combination的词法作用域,并且combination的词法作用域持有着它的上层作用域的好几个变量。这里,只是摘取了finalReducers和finalReducerKeys。所以,我们说,在combineReducers函数的内部,有一个可以被观察得到的闭包。而这事是redux类库帮我们做了。

const todoReducer=combineReducers({
    list:listReducer,
    visibility:visibilityReducer
})
复制代码

当我们像上面那样调用combineReducers,在运行期,这里就会产生一个闭包

const todoReducer=combineReducers({
    list:listReducer,
    visibility:visibilityReducer
})
const prevState = {
    list:[],
    visibility:'showAll'
}
const currAction = { 
    type:"ADD_TODO",
    payload:{
        id:1,
        text:'23:00 准时睡觉',
        isFinished: false
    }
}
todoReducer(prevState,currAction)
复制代码

当我们像上面那样调用combineReducers()返回的todoReducer,在运行期,我们就会开始进入一个闭包

这里顺便提一下,与combineReducers()函数的实现一样。createStore()函数内部的实现也有一个“可以观察得到的闭包”,在这里就不赘言了。

其实提到闭包,这里还得提到函数实例。函数狭义上来讲应该是指代码书写期的脚本代码,是一个静态概念。而函数实例恰恰相反,是一个函数在代码运行期的概念。函数实例与闭包存在对应关系。一个函数实例可以对应一个闭包,多个函数实例可以共享同一个闭包。所以,闭合也是一个运行期的概念。明白这一点,对于理解我后面所说至关重要。

在这个小节,我们提出的疑问是“redux是如何将各个小的reducer combine上来,组建总的state树?”。答案是通过combineReducers闭包的层层嵌套。通过查看源码我们知道,调用combineReducers,我们可以得到一个combination函数的实例,而这个函数实例有一个对应的闭包。也就是说,在代码书写期,redux类库[形成了一个可以观察得到的闭包]。我们的js代码初始加载和执行的时候,通过调用combineReducers()函数,我们得到了一个函数实例,我们把这个函数实例的引用保存在调用combineReducers函数时传进去的对象属性上。正如上面所说每个函数实例都会有一个与之对应的闭包。因此我们理解为,我们通过调用combineReducers(),事先(注意:这个“事先”是相对代码正式可以跟用户交互阶段而言)生成了一个闭包,等待进入。因为combineReducers的使用是层层嵌套的,所以这里产生了一个闭包链。既然闭包链已经准备好了,那么我们什么时候进入这条闭包链呢?也许你会有个疑问:“进入闭包有什么用?”。因为闭包的本质是将数据保存在内存当中,所以这里的答案就是:“进入闭包,是为了访问该闭包在产生时保存在内存当中的数据”。在redux中,state被设计为一个树状结构,而我们的combine工作又是自底向上来进行的。可以这么讲,在自底向上的combine工作中,当我们完成最后一次调用combineReducers时,我们的闭包链已经准备妥当了,等待我们的进入。现实开发中,由于都采用了模块化的开发方式,combine工作的实现代码都是分散在各个不同的文件里面,最后是通过引用传递的方式来汇总在一块。假如一开始我们把它们写在一起,那么我们就很容易看到combine工作的全貌。就如下代码所演示的那样:

const finalReducer = combineReducers({
    page1:combineReducers({
        counter:counterReducer,
        todo:combineReducers({
            list:listReducer,
            visibility:visibilityReducer
        })
    })
})

复制代码

我们可以说当代码执行到[给finalReducer变量赋值]的时候,我们的闭包链已经准备好了,等待我们的进入。 接下来,我们不禁问:“那到底什么时候进入呢?”。答案是:“finalReducer被调用的时候,我们就开始进入这个闭包链了。”。那么finalReducer什么时候,在哪里被调用呢?大家可以回想以下,我们最后一次消费finalReducer是不是就是调用createStore()时作为第一个参数传入给它?是的。于是乎,我们就来到createStore.js的源码中来看看:

import $$observable from 'symbol-observable'

import ActionTypes from './utils/actionTypes'
import isPlainObject from './utils/isPlainObject'

/**
 * Creates a Redux store that holds the state tree.
 * The only way to change the data in the store is to call `dispatch()` on it.
 *
 * There should only be a single store in your app. To specify how different
 * parts of the state tree respond to actions, you may combine several reducers
 * into a single reducer function by using `combineReducers`.
 *
 * @param {Function} reducer A function that returns the next state tree, given
 * the current state tree and the action to handle.
 *
 * @param {any} [preloadedState] The initial state. You may optionally specify it
 * to hydrate the state from the server in universal apps, or to restore a
 * previously serialized user session.
 * If you use `combineReducers` to produce the root reducer function, this must be
 * an object with the same shape as `combineReducers` keys.
 *
 * @param {Function} [enhancer] The store enhancer. You may optionally specify it
 * to enhance the store with third-party capabilities such as middleware,
 * time travel, persistence, etc. The only store enhancer that ships with Redux
 * is `applyMiddleware()`.
 *
 * @returns {Store} A Redux store that lets you read the state, dispatch actions
 * and subscribe to changes.
 */
export default function createStore(reducer, preloadedState, enhancer) {
  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)
  }

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

  let currentReducer = reducer
  let currentState = preloadedState
  let currentListeners = []
  let nextListeners = currentListeners
  let isDispatching = false

  function ensureCanMutateNextListeners() {
    if (nextListeners === currentListeners) {
      nextListeners = currentListeners.slice()
    }
  }

  /**
   * Reads the state tree managed by the store.
   *
   * @returns {any} The current state tree of your application.
   */
  function getState() {
    if (isDispatching) {
      throw new Error(
        'You may not call store.getState() while the reducer is executing. ' +
          'The reducer has already received the state as an argument. ' +
          'Pass it down from the top reducer instead of reading it from the store.'
      )
    }

    return currentState
  }

  /**
   * Adds a change listener. It will be called any time an action is dispatched,
   * and some part of the state tree may potentially have changed. You may then
   * call `getState()` to read the current state tree inside the callback.
   *
   * You may call `dispatch()` from a change listener, with the following
   * caveats:
   *
   * 1. The subscriptions are snapshotted just before every `dispatch()` call.
   * If you subscribe or unsubscribe while the listeners are being invoked, this
   * will not have any effect on the `dispatch()` that is currently in progress.
   * However, the next `dispatch()` call, whether nested or not, will use a more
   * recent snapshot of the subscription list.
   *
   * 2. The listener should not expect to see all state changes, as the state
   * might have been updated multiple times during a nested `dispatch()` before
   * the listener is called. It is, however, guaranteed that all subscribers
   * registered before the `dispatch()` started will be called with the latest
   * state by the time it exits.
   *
   * @param {Function} listener A callback to be invoked on every dispatch.
   * @returns {Function} A function to remove this change listener.
   */
  function subscribe(listener) {
    if (typeof listener !== 'function') {
      throw new Error('Expected the listener to be a function.')
    }

    if (isDispatching) {
      throw new Error(
        'You may not call store.subscribe() while the reducer is executing. ' +
          'If you would like to be notified after the store has been updated, subscribe from a ' +
          'component and invoke store.getState() in the callback to access the latest state. ' +
          'See http://redux.js.org/docs/api/Store.html#subscribe for more details.'
      )
    }

    let isSubscribed = true

    ensureCanMutateNextListeners()
    nextListeners.push(listener)

    return function unsubscribe() {
      if (!isSubscribed) {
        return
      }

      if (isDispatching) {
        throw new Error(
          'You may not unsubscribe from a store listener while the reducer is executing. ' +
            'See http://redux.js.org/docs/api/Store.html#subscribe for more details.'
        )
      }

      isSubscribed = false

      ensureCanMutateNextListeners()
      const index = nextListeners.indexOf(listener)
      nextListeners.splice(index, 1)
    }
  }

  /**
   * Dispatches an action. It is the only way to trigger a state change.
   *
   * The `reducer` function, used to create the store, will be called with the
   * current state tree and the given `action`. Its return value will
   * be considered the **next** state of the tree, and the change listeners
   * will be notified.
   *
   * The base implementation only supports plain object actions. If you want to
   * dispatch a Promise, an Observable, a thunk, or something else, you need to
   * wrap your store creating function into the corresponding middleware. For
   * example, see the documentation for the `redux-thunk` package. Even the
   * middleware will eventually dispatch plain object actions using this method.
   *
   * @param {Object} action A plain object representing “what changed”. It is
   * a good idea to keep actions serializable so you can record and replay user
   * sessions, or use the time travelling `redux-devtools`. An action must have
   * a `type` property which may not be `undefined`. It is a good idea to use
   * string constants for action types.
   *
   * @returns {Object} For convenience, the same action object you dispatched.
   *
   * Note that, if you use a custom middleware, it may wrap `dispatch()` to
   * return something else (for example, a Promise you can await).
   */
  function dispatch(action) {
    if (!isPlainObject(action)) {
      throw new Error(
        'Actions must be plain objects. ' +
          'Use custom middleware for async actions.'
      )
    }

    if (typeof action.type === 'undefined') {
      throw new Error(
        'Actions may not have an undefined "type" property. ' +
          'Have you misspelled a constant?'
      )
    }

    if (isDispatching) {
      throw new Error('Reducers may not dispatch actions.')
    }

    try {
      isDispatching = true
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }

    const listeners = (currentListeners = nextListeners)
    for (let i = 0; i < listeners.length; i++) {
      const listener = listeners[i]
      listener()
    }

    return action
  }

  /**
   * Replaces the reducer currently used by the store to calculate the state.
   *
   * You might need this if your app implements code splitting and you want to
   * load some of the reducers dynamically. You might also need this if you
   * implement a hot reloading mechanism for Redux.
   *
   * @param {Function} nextReducer The reducer for the store to use instead.
   * @returns {void}
   */
  function replaceReducer(nextReducer) {
    if (typeof nextReducer !== 'function') {
      throw new Error('Expected the nextReducer to be a function.')
    }

    currentReducer = nextReducer
    dispatch({ type: ActionTypes.REPLACE })
  }

  /**
   * Interoperability point for observable/reactive libraries.
   * @returns {observable} A minimal observable of state changes.
   * For more information, see the observable proposal:
   * https://github.com/tc39/proposal-observable
   */
  function observable() {
    const outerSubscribe = subscribe
    return {
      /**
       * The minimal observable subscription method.
       * @param {Object} observer Any object that can be used as an observer.
       * The observer object should have a `next` method.
       * @returns {subscription} An object with an `unsubscribe` method that can
       * be used to unsubscribe the observable from the store, and prevent further
       * emission of values from the observable.
       */
      subscribe(observer) {
        if (typeof observer !== 'object') {
          throw new TypeError('Expected the observer to be an object.')
        }

        function observeState() {
          if (observer.next) {
            observer.next(getState())
          }
        }

        observeState()
        const unsubscribe = outerSubscribe(observeState)
        return { unsubscribe }
      },

      [$$observable]() {
        return this
      }
    }
  }

  // When a store is created, an "INIT" action is dispatched so that every
  // reducer returns their initial state. This effectively populates
  // the initial state tree.
  dispatch({ type: ActionTypes.INIT })

  return {
    dispatch,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
  }
}

复制代码

果不其然,在dispatch方法的内部实现,我们看到了我们传入的finalReducer被调用了(我们缩减了部分代码来看):

export default function createStore(reducer, preloadedState, enhancer) {
    ...... // other code above
     let currentReducer = reducer
     ...... // other code above
    function dispatch(action) {
     ...... // other code above

    try {
      isDispatching = true
      // 注意看,就是在这里调用了我们传入的“finalReducer”
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }
    ...... // other code rest
  }
}
复制代码

也就是说,我们在调用dispatch(action)的时候(准确地来说,第一次调用dispatch的并不是我们,而是redux类库自己),我们就开始进入早就准备好的combination闭包链了。 到这里我们算是回答了这个小节我们自己给自己提出的第一个小问题:“redux是如何将各个小的reducer combine上来?”。下面,我们一起来总结以下:

  1. redux类库在代码书写期就[形成了一个可以观察得到的闭包]-combination闭包。
  2. (redux类库的)用户自己通过自底向上地调用combineReducers()来产生了一个层层嵌套的闭包链。
  3. 最终,在我们以一个action去调用dispatch()方法的时候,我们就会进入了这个闭包链。

既然combine工作的原理我们已经搞清楚了,那么第二个小问题和第三个小问题就迎刃而开了。

首先我们来看看createStore的函数签名:

createStore(finalreducer, preloadedState, enhancer) => store实例
复制代码

state树的初始值取决于我们调用createStore时的传参情况。

  • 假如,我们有传递preloadedState这个实参,那么这个实参将作为state树的初始值。
  • 假如,我们没有传递preloadedState这个实参,那么state树的初始化将会是由redux类库自己来完成。具体地说,redux在内部通过dispatch一个叫{ type: ActionTypes.INIT }的action来完成了state树的初始化。在这里,我们得提到reducer的初始值。reducer的初始值指的是reducer在没有匹配到action.type的情况下返回的值。一个纯正的reducer,我们一般会这么写:
function counterReducer(state = 0, action){
    switch (action.type) {
        case "INCREMENT":
            return state + 1;
        case "DECREMENT":
            return state - 1;
        default:
            return state;
    }
}
复制代码

在上面的代码中,如果当前dispatch的action在counterReducer里面没有找到匹配的action.type,那么就会走default分支。而default分支的返回值又等同具有默认值为0的形参state。所以,我们可以说这个reducer的初始值为0。注意,形参state的默认值并不一定就是reducer的初始值。考虑下面的写法(这种写法是不被推荐的):

function counterReducer(state = 0, action){
    switch (action.type) {
        case "INCREMENT":
            return state + 1;
        case "DECREMENT":
            return state - 1;
        default:
            return1;
    }
}
复制代码

在switch语句的default条件分支里面,我们是return -1,而不是return state。在这种情况下,reducer的初始值是-1,而不是0 。所以,我们不能说reducer的初始值是等同于state形参的默认值了。

也许你会问:“为什么dispatch 一个叫{ type: ActionTypes.INIT }action就能将各个reducer的初始值收集上来呢?”。我们先来看看ActionTypes.INIT到底是什么。

utils/actionTypes.js的源码:

/**
 * These are private action types reserved by Redux.
 * For any unknown actions, you must return the current state.
 * If the current state is undefined, you must return the initial state.
 * Do not reference these action types directly in your code.
 */
const ActionTypes = {
  INIT:
    '@@redux/INIT' +
    Math.random()
      .toString(36)
      .substring(7)
      .split('')
      .join('.'),
  REPLACE:
    '@@redux/REPLACE' +
    Math.random()
      .toString(36)
      .substring(7)
      .split('')
      .join('.')
}
复制代码

可以看到,ActionTypes.INIT的值就是一个随机字符串。也就是说,在redux类库内部(createStore函数内部实现里面)我们dispatch了一个type值为随机字符串的action。这对于用户自己编写的reducer来说,99.99%都不可能找到与之匹配的action.type 。所以,ActionTypes.INIT这个action最终都会进入default条件分支。也就是说,作为响应,各个reducer最终会返回自己的初始值。而决定state树某个子树结构的字面量对象,我们早就在产生闭包时存在在内存当中了。然后,字面量对象(代表着结构) + 新计算出来的new state(代表着数据) = 子state树。最后,通过组合层层子state树,我们就初始化了一颗完整的state树了。

回答完第二个问题后,那么接着回答第三个问题:“state树是如何更新的?”。其实,state树跟通过dispatch一个type为ActionTypes.INIT的action来初始化state树的原理是一样的。它们都是通过给所有最基本的reducer传入一个该reducer负责管理的节点的previous state和当前要广播的action来产出一个最新的值,也就是说: previousState + action => newState。不同于用于state树初始化的action,后续用于更新state树的action一般会带有payload数据,并且会在某个reducer里面匹配到对应action.type,从而计算出最新的state值。从这里我们也可以看出了,reducer的本质是计算。而计算也正是计算机的本质。

最后,我们通过对比combineReducers函数的调用代码结构和其生成的state树结构来加深对combine机制的印象:

// 写在一块的combineReducers函数调用
const finalReducer = combineReducers({
    page1:combineReducers({
        counter:counterReducer,// 初始值为0
        todo:combineReducers({
            list:listReducer,// 初始值为[]
            visibility:visibilityReducer// 初始值为"showAll"
        })
    })
})

// 与之对应的state树的初始值
{
    page1:{
        counter:0,
        todo:{
            list:[],
            visibility:"showAll"
        }
    }
}
复制代码

三、中间件运行机制与原理是什么呢?

在探讨redux中间件机制之前,我们不妨来回答一下中间件是什么?答曰:“redux的中间件其实就是一个函数。

更确切地讲是一个包了三层的函数,形如这样:

const middleware = function (store) {

            return function (next) {

                return function (action) {
                    // maybe some code here ...
                    next(action)
                    // maybe some code here ...
                }
            }
        }
复制代码

既然我们知道了redux的中间件长什么样,那么我们不禁问:“为什么redux的中间件长这样能有用呢?整个中间件的运行机制又是怎样的呢?”

我们不妨回顾一下,我们平时使用中间件的大致流程:

  1. 写好一个中间件;

  2. 注册中间件,如下:

import Redux from 'redux';

const enhancer = Redux.applyMiddleware(middleware1,middleware2)
复制代码
  1. 把enhancer传入redux的createStore方法中,如下:
import Redux from 'redux';

let store = Redux.createStore(counterReducer,enhance);
复制代码

其实要搞清楚中间件的运行机制,无非就是探索我们写的这个包了三层的函数是如何被消费(调用)。我们写的中间件首次被传入了applyMiddleware方法,那我们来瞧瞧这个方法的真面目吧。源文件applyMiddleware.js的代码如下:

import compose from './compose'

/**
 * Creates a store enhancer that applies middleware to the dispatch method
 * of the Redux store. This is handy for a variety of tasks, such as expressing
 * asynchronous actions in a concise manner, or logging every action payload.
 *
 * See `redux-thunk` package as an example of the Redux middleware.
 *
 * Because middleware is potentially asynchronous, this should be the first
 * store enhancer in the composition chain.
 *
 * Note that each middleware will be given the `dispatch` and `getState` functions
 * as named arguments.
 *
 * @param {...Function} middlewares The middleware chain to be applied.
 * @returns {Function} A store enhancer applying the middleware.
 */
export default 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.`
      )
    }
    let chain = []

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    }

    chain = middlewares.map(middleware => middleware(middlewareAPI)) // 这里剥洋葱模型的第一层
    dispatch = compose(...chain)(store.dispatch) //  第二个函数调用剥洋葱模型的第二层

    return {
      ...store,
      dispatch
    }
  }
  //return enhancer(createStore)(reducer, preloadedState)
}

复制代码

又是function返回function,redux.js到处可见闭包,可见作者对闭包的应用已经到随心应手,炉火纯青的地步了。就是上面几个简短却不简单的代码,实现了redux的中间件机制,可见redux源代码的凝练程度可见一斑。

applyMiddleware返回的函数将会在传入createStore之后反客为主:

export default function createStore(reducer, preloadedState, enhancer) {
  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方法的reducer, preloadedState等参数绕了一圈,最终还是原样传递到applyMiddleware方法的内部createStore。这就是这行代码:

const store = createStore(...args)
复制代码

顺便提一下,在applyMiddleware方法里面的const store = createStore(...args)所创建的store与我们平常调用redux.createStore()所产生的store是没什么区别的。

来到这里,我们可以讲applyMiddleware方法里面的middlewareAPI对象所引用的getState和dispatch都是未经改造的,原生的方法。而我们写中间件的过程就是消费这两个方法(主要是增强dispatch,使用getState从store中获取新旧state)的过程。

正如上面所说的,我们的中间件其实就是一个包了三层的函数。借用业界的说法,这是一个洋葱模型。我们中间件的核心代码一般都是写在了最里面那一层。那下面我们来看看我们传给redux类库的中间件这个洋葱,是如何一层一层地被拨开的呢?而这就是中间件运行机制之所在。

剥洋葱的核心代码只有两行代码(在applyMiddleware.js):

//  ......

chain = middlewares.map(middleware => middleware(middlewareAPI)) // 这里剥洋葱模型的第一层
dispatch = compose(...chain)(store.dispatch) //  第二个函数调用剥洋葱模型的第二层

// .......
复制代码

还记得我们传给applyMiddleware方法的是一个中间件数组吗?通过对遍历中间件数组,以middlewareAPI入参,我们剥开了洋葱模型的第一层。也即是说chain数组中的每一个中间件都是这样的:

function (next) {
    return function (action) {
        // maybe some code here ...
        next(action)
        // maybe some code here ...
    }
}
复制代码

剥开洋葱模型的第一层的同时,redux往我们的中间件注入了一个简化版的store对象(只有getState方法和dispatch方法),仅此而已。而剥开洋葱模型的第二层,才是整个中间件运行机制的灵魂所在。我们目光往下移,只见“寥寥数语”:

dispatch = compose(...chain)(store.dispatch) //  第二个函数调用剥洋葱模型的第二层
复制代码

是的,compose方法才是核心所在。我们不防来看看compose方法源码:

/**
 * Composes single-argument functions from right to left. The rightmost
 * function can take multiple arguments as it provides the signature for
 * the resulting composite function.
 *
 * @param {...Function} funcs The functions to compose.
 * @returns {Function} A function obtained by composing the argument functions
 * from right to left. For example, compose(f, g, h) is identical to doing
 * (...args) => f(g(h(...args))).
 */

export default function compose(...funcs) {
  if (funcs.length === 0) {
    return arg => arg
  }

  if (funcs.length === 1) {
    return funcs[0]
  }
  // 此处的a和b是指中间件的第二层函数
  return funcs.reduce((a, b) => (...args) => a(b(...args)));
  
  // 为了看清楚嵌套层数,我们转成ES5语法来看看
  // return funcs.reduce(function(a,b){
  //   return function(...args){
  //     return a(b(...args));
  //   }
  // })
}

复制代码

compose方法的实现跟任何一个函数式编程范式里面的“compose”概念的实现没有多大的差别(又是函数嵌套 + return,哎,心累)。要想理解这行代码,个人觉得需要用到几个概念:

  • 函数可以被当成值来传递
  • 延迟求值
  • 闭包

我们使用了数组的reduce API把函数当作值来遍历了一遍,每个函数都被闭包在最后返回的那个函数的作用域里面。虽然表面上看到了对每个函数都使用了函数调用操作符,但是实际上每个函数都延迟执行了。这段代码需要在脑海里面好好品味一下。不过,为了快速理解后面的代码,我们的不防把它对应到这样的心智模型中去:

compose(f,g,h) === (...args) => f(g(h(...args)))
复制代码

一旦理解了compose的原理,我们就会知道我们中间件的洋葱模型的第二层是在compose(...chain)(store.dispatch)的最后一个函数调用发生时剥开的。而在剥开的时候,我们每一个中间件的第二层函数都会被注入一个经过后者中间件(按照注册中间时的顺序来算)增强后的dispatch方法。f(g(h(...args)))调用最后返回的是第一个中间件的最里层的那个函数,如下:

function (action) {
    //  ...
    next(action)
    //  ...
}
复制代码

也即是说,用户调用的最终是增强(经过各个中间件魔改)后的dispatch方法。因为这个被中间件增强后的dispatch关联着一条闭包链(这个增强后的dispatch类似于上面一章节所提到的totalReducer,它也是关联着一个闭包链),所以对于一个严格使用redux来管理数据流的应用,我们可以这么说:中间件核心代码(第三层函数)执行的导火索就是牢牢地掌握在用户的手中。

讲到这里,我们已经基本上摸清了中间件机制运行的原理了。下面,总结一下:

  1. 调用applyMiddleware方法的时候,redux会把我们的中间件剥去两层外衣。剥开第一层的时候,往里面注入简化版的store实例;剥开第二层的时候,往里面注入上一个中间件(按照注册中间时的顺序来算)的最里层的函数。以此类推,中间件数组最后一个中间件的第二层被剥开的时候,注入的是原生的dispatch方法。
  2. 用户调用的dispatch方法是经过所有中间件增强后的dispatch方法,已然不是原生的dispatch方法了。
  3. 用户调用dispatch方法的时候,程序会进入一个环环相扣的闭包链中。也即是说,用户的每dispatch一次action,我们注册的所有的中间件的第三层函数都会被执行一遍。

上面所提原理的心智模型图大概如下:

假如我们已经理解了中间件的运行机制与原理,我们不防通过自问自答的方式来巩固一下我们的理解。我们将会问自己三个问题:

1.不同的中间件注册顺序对程序的执行结果有影响吗?如果有,为什么有这样的影响?

答曰:有影响的。因为中间件的核心代码是写在第三层函数里面的,所以当我们在讨论中间件的执行顺序时,一般是指中间件第三层函数的被执行的顺序。正如上面探索中间件的运行机制所指出的,中间件第三层函数的执行顺序与中间件的注册顺序是一致的,都是从左到右。所以,不同的注册顺序也就意味着不同的中间件调用顺序。造成这种影响的原因我能想到的是以下两种场景:

  1. 后一个中间件对前一个中间件产生依赖。举个例子,假如某个中间件B需要依赖上一个中间件A对action进行添加的某个payload属性来判断是否执行某些逻辑,而在实际注册的时候,你却把中间件B放在所依赖中间件A的前面,那么这个中间件B的那段判断逻辑很有可能永远都不会被执行。
  2. 是否有影响有时候取决与该中间件所想要实现的功能。举个例子,假如某个中间件想要实现的功能是统计dispatch一次action所耗费的时间,它的大概实现是这样的:
let startTimeStamp = 0
const timeLogger = function (store) {
    return function (next) {
        return function (action) {
            startTimeStamp = Date.now();
            next(action)
            console.log(`整个dispatch耗费的时间是:${Date.now() - startTimeStamp}毫秒`, )
        }
    }
}
复制代码

基于是为了实现这种功能的目的,那么这个timeLogger中间件就必须是放在中间件数组的第一位了。否则的话,统计出来的耗时就不是整个dispatch执行完所耗费的时间了。

2.我们都知道中间件的核型代码会放在第三层函数那里,那如果我在第一层和第二层函数里面就开始写点代码(消费store这个实参)会是怎样呢?

在上面已经讨论过中间件运行机制片段中,我们了解到,中间件的第一层和第二层函数都是在我们调用applyMiddleware()时所执行的,也就是说不同于中间件的第三层函数,第一层和第二层函数在应用的整个生命周期只会被执行一次。

let dispatch = () => {
  throw new Error(
    `Dispatching while constructing your middleware is not allowed. ` +
      `Other middleware would not be applied to this dispatch.`
  )
}
let chain = []

const middlewareAPI = {
  getState: store.getState,
  dispatch: (...args) => dispatch(...args)
}
复制代码

看这几行代码可知,如果我们在第一层函数里面就调用dispatch方法,应该是会报错的。稍后我们来验证一下。 而对于调用getState()而言,因为在chain = middlewares.map(middleware => middleware(middlewareAPI))这行代码之前,我们已经创建了store实例,也即是说ActionTypes.INIT已经dispatch了,所以,state树的初始值已经计算出来的。这个时候,如果我们调用middlewareAPI的getState方法,得到的应该也是初始状态的state树。我们不防追想下去,在中间件的第二层函数里面消费middlewareAPI的这个两个方法,应该会得到同样的结果。因为,dispatch变量还是指向同一函数引用,而应用中的第二个action也没有dispatch 出来,所以state树的值不变,还是初始值。下面,我们不防写个中间件来验证一下我们的结论:

const testMiddleware = function (store) {
    
    store.dispatch(); // 经过验证,会报错:"Dispatching while constructing your middleware is not allowed. Other middleware would not be applied to this dispatch."
    
    console.log(store.getState()); // 在第一层,拿到的是整个state树的初始值(已验证)
    
    return function (next) {
    
        store.dispatch(); // 这里也不能写,也会包同样的错.
    
        console.log(store.getState()); // 在第二层,拿到的也是整个state树的初始值(已验证)

        return function (action) {
            next(action)
        }
    }
}
复制代码

3.在中间件的第三层函数里,写在next(action)之前的代码与写在之后的代码会有什么不同吗?

正如我们给出的步骤四的心智模型图,增强后的dispatch方法的代码执行流程是夹心饼干式的。对于每一个中间件(第三层函数)而言,写在next(action)语句前后的语句分别是夹心饼干的上下层,中心层永远是经过后一个(这里按照中间件注册顺序来说)中间件增强后的dispatch方法,也就是此时的next(action)。下面我们不防用代码去验证一下:

const m1 = store=> next=> action=> {
    console.log('m1: before next(action)');
    next(action);
    console.log('m1: after next(action)');
}

const m2 = store=> next=> action=> {
    console.log('m2: before next(action)');
    next(action);
    console.log('m2: after next(action)');
}

// 然后再在原生的dispatch方法里面打个log
function dispatch(action) {
    console.log('origin dispatch');
    if (!isPlainObject(action)) {
      throw new Error(
        'Actions must be plain objects. ' +
          'Use custom middleware for async actions.'
      )
    }
    // ........
  }
复制代码

经过验证,打印的顺序如下:

m1: before next(action)
m2: before next(action)
origin dispatch
m1: after next(action)
m2: after next(action)
复制代码

是不是挺像夹心饼干的啊?

回到这个问题的本身,我们不防也想想第二章节里面得到过的结论:dispatch一次action,实际上是一次计算,计算出state树的最新值。也就是说,只有原生的dispatch方法执行之后,我们才能拿到最新的state。结合各个中间件与原生的dispatch方法的执行顺序之先后,这个问题的答案就呼之欲出了。

那就是:“在调用getState方法去获取state值的场景下,写在next(action)之前的代码与写在之后的代码是不同的。这个不同点在于写在next(action)之前的getState()拿到的是旧的state,而写在next(action)之后的getState()拿到的是新的state。”

中间件应用

既然我们都探索了这么多,最后,我们不妨写几个简单的中间件来巩固一下战果。

  1. 实现一个用于统计dispatch执行耗时的中间件。
let startTimeStamp = 0
const timeLogger = function (store) {
       return function (next) {
           return async function (action) {
               startTimeStamp = Date.now();
               await next(action)
               console.log(`整个dispatch花费的时间是:${Date.now() - startTimeStamp}毫秒`, )
           }
       }
   }
}
复制代码

注意,注册的时候,这个中间要始终放在第一位,个中理由上文已经解释过。

  1. 实现一个鉴权的中间件。

function auth(name) {
   return new Promise((reslove,reject)=> {
       setTimeout(() => {
           if(name === 'sam') {
               reslove(true);
           } else {
               reslove(false);
           }
       }, 1000);
   })
}
       
const authMiddleware = function(store){
   return function(next){
       return async function(action) {
           if (action.payload.isNeedToAuth) {
               const isAuthorized = await auth(action.payload.name);

               if(isAuthorized) {
                   next(action);
               } else {
                   alert('您没有此权限');
               }
           } else {
               next(action);
           }
           
       }
   }
}
复制代码

对于写中间件事而言,只要把中间件的运行机制的原理明白,剩下的无非就是如何“消费”store,nextaction等实参的事情。到这里,中间件的剖析就结束了。希望大家可以结合实际的业务需求,发挥自己的聪明才智,早日写出有如《滕王阁序》中所提的“紫电青霜,王将军之武库”般的中间件库。




原文地址:访问原文地址
快照地址: 访问文章快照