redux真的不复杂——源码解读

13,452 阅读17分钟

前言

阅读对象:使用过redux,对redux实现原理不是很理解的开发者。

在我实习入职培训的时候,给我培训的老哥就跟我说过,redux的核心源码很简洁,建议我有空去看一下,提升对redux系列的理解。

入职一个多月了,已经参与了公司的不少项目,redux也使用了一段时间,对于redux的理解却一直没有深入,还停留在“知道怎么用,但是不知道其核心原理”的阶段。

所以就在github上拉了redux的源码,看了一会,发现东西确实不多,比较简洁。

redux本身的功能是什么

在项目中,我们往往不会纯粹的使用redux,而是会配合其他的一些工具库提升效率,比如react-redux,让react应用使用redux更容易,类似的也有wepy-redux,提供给小程序框架wepy的工具库。

但是在本文中,我们讨论的范围就纯粹些,仅仅讨论redux本身

redux本身有哪些作用?我们先来快速的过一下redux的核心思想(工作流程):

  • 将状态统一放在一个state中,由store来管理这个state。
  • 这个store按照reducer的“shape”(形状)创建。
  • reducer的作用是接收到action后,输出一个新的状态,对应地更新store上的状态。
  • 根据redux的原则指导,外部改变state的最佳方式是通过调用store的dispatch方法,触发一个action,这个action被对应的reducer处理,完成state更新。
  • 可以通过subscribe在store上添加一个监听函数。每当调用dispatch方法时,会执行所有的监听函数。
  • 可以添加中间件(中间件是干什么的我们后面讲)处理副作用。

在这个工作流程中,redux需要提供的功能是:

  • 创建store,即:createStore()
  • 创建出来的store提供subscribedispatchgetState这些方法。
  • 将多个reducer合并为一个reducer,即:combineReducers()
  • 应用中间件,即applyMiddleware()

没错,就这么多功能,我们看下redux的源码目录:

redux的源码目录

确实也就这么多,至于compose,bindActionCreators,则是一些工具方法。

下面我们就逐个来看看createStorecombineReducersapplyMiddlewarecompose的源码实现。

建议打开链接:redux源码地址,参照本文的解释阅读源码。

createStore的实现

这个函数的大致结构是这样:

function createStore(reducer, preloadedState, enhancer) {
    if(enhancer是有效的){  // 这个我们后面会解释,可以先忽略
        return enhancer(createStore)(reducer, preloadedState)
    } 
    
    let currentReducer = reducer // 当前store中的reducer
    let currentState = preloadedState // 当前store中存储的状态
    let currentListeners = [] // 当前store中放置的监听函数
    let nextListeners = currentListeners // 下一次dispatch时的监听函数
    // 注意:当我们新添加一个监听函数时,只会在下一次dispatch的时候生效。
    
    //...
    
    // 获取state
    function getState() {
        //...
    }
    
    // 添加一个监听函数,每当dispatch被调用的时候都会执行这个监听函数
    function subscribe() {
        //...
    }
    
    // 触发了一个action,因此我们调用reducer,得到的新的state,并且执行所有添加到store中的监听函数。
    function dispatch() {
        //...
    }
   
    //...
    
    //dispatch一个用于初始化的action,相当于调用一次reducer
    //然后将reducer中的子reducer的初始值也获取到
    //详见下面reducer的实现。
    
    
    return {
        dispatch,
        subscribe,
        getState,
        //下面两个是主要面向库开发者的方法,暂时先忽略
        //replaceReducer,
        //observable
    }
}

可以看出,createStore方法创建了一个store,但是并没有直接将这个store的状态state返回,而是返回了一系列方法,外部可以通过这些方法(getState)获取state,或者间接地(通过调用dispatch)改变state。

至于state呢,被存在了闭包中。(不理解闭包的同学可以先去了解一下先)

我们再来详细的看看每个模块是如何实现的(为了让逻辑更清晰,省略了错误处理的代码):

getState
function getState() {
    return currentState
}

简单到发指。其实这很像面向对象编程中封装只读属性的方法,只提供数据的getter方法,而不直接提供setter。(虽然这里返回的是一个state的引用,你可以直接修改state,但是一般来说,redux不建议这样做。)

subscribe
function subscribe(listener) {
    // 添加到监听函数数组,
    // 注意:我们添加到了下一次dispatch时才会生效的数组
    nextListeners.push(listener)
    
    let isSubscribe = true //设置一个标志,标志该监听器已经订阅了
    // 返回取消订阅的函数,即从数组中删除该监听函数
    return function unsubscribe() {
        if(!isSubscribe) {
            return // 如果已经取消订阅过了,直接返回
        }
        
        isSubscribe = false
        // 从下一轮的监听函数数组(用于下一次dispatch)中删除这个监听器。
        const index = nextListeners.indexOf(listener)
        nextListeners.splice(index, 1)
    }
}

subscribe返回的是一个取消订阅的方法。取消订阅是非常必要的,当添加的监听器没用了之后,应该从store中清理掉。不然每次dispatch都会调用这个没用的监听器。

dispatch
function dispatch(action) {
    //调用reducer,得到新state
    currentState = currentReducer(currentState, action);
    
    //更新监听数组
    currentListener = nextListener;
    //调用监听数组中的所有监听函数
    for(let i = 0; i < currentListener.length; i++) {
        const listener = currentListener[i];
        listener();
    }
}

createStore这个方法的基本功能我们已经实现了,但是调用createStore方法需要提供reducer,让我们来思考一下reducer的作用。

combineReducers

在理解combineReducers之前,我们先来想想reducer的功能:reducer接受一个旧的状态和一个action,当这个action被触发的时候,reducer处理后返回一个新状态。

也就是说 ,reducer负责状态的管理(或者说更新)。在实际使用中,我们应用的状态是可以分成很多个模块的,比如一个典型社交网站的状态可以分为:用户个人信息,好友列表,消息列表等模块。理论上,我们可以用一个reducer去处理所有状态的维护,但是这样做的话,我们一个reducer函数的逻辑就会太多,容易产生混乱。

因此我们可以将逻辑(reducer)也按照模块划分,每个模块再细分成各个子模块,开发完每个模块的逻辑后,再将reducer合并起来,这样我们的逻辑就能很清晰的组合起来。

对于我们的这种需求,redux提供了combineReducers方法,可以把子reducer合并成一个总的reducer。

来看看redux源码中combineReducers的主要逻辑:

function combineReducers(reducers) {
    //先获取传入reducers对象的所有key
    const reducerKeys = Object.keys(reducers)
    const finalReducers = {} // 最后真正有效的reducer存在这里
    
    //下面从reducers中筛选出有效的reducer
    for(let i = 0; i < reducerKeys.length; i++){
        const key  = reducerKeys[i]
        
        if(typeof reducers[key] === 'function') {
            finalReducers[key] = reducers[key] 
        }
    }
    const finalReducerKeys = Object.keys(finalReducers);
    
    //这里assertReducerShape函数做的事情是:
    // 检查finalReducer中的reducer接受一个初始action或一个未知的action时,是否依旧能够返回有效的值。
    let shapeAssertionError
  	try {
    	assertReducerShape(finalReducers)
  	} catch (e) {
    	shapeAssertionError = e
  	}
    
    //返回合并后的reducer
    return function combination(state= {}, action){
  		//这里的逻辑是:
    	//取得每个子reducer对应的state,与action一起作为参数给每个子reducer执行。
    	let hasChanged = false //标志state是否有变化
        let nextState = {}
        for(let i = 0; i < finalReducerKeys.length; i++) {
                    //得到本次循环的子reducer
            const key = finalReducerKeys[i]
            const reducer = finalReducers[key]
            //得到该子reducer对应的旧状态
            const previousStateForKey = state[key]
            //调用子reducer得到新状态
            const nextStateForKey = reducer(previousStateForKey, action)
            //存到nextState中(总的状态)
            nextState[key] = nextStateForKey
            //到这里时有一个问题:
            //就是如果子reducer不能处理该action,那么会返回previousStateForKey
            //也就是旧状态,当所有状态都没改变时,我们直接返回之前的state就可以了。
            hasChanged = hasChanged || previousStateForKey !== nextStateForKey
        }
        return hasChanged ? nextState : state
    }
} 

为什么需要中间件

在redux的设计思想中,reducer应该是一个纯函数

维基百科关于纯函数的定义:

程序设计中,若一个函数符合以下要求,则它可能被认为是纯函数

  • 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由I/O设备产生的外部输出无关。
  • 该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。

纯函数的输出可以不用和所有的输入值有关,甚至可以和所有的输入值都无关。但纯函数的输出不能和输入值以外的任何资讯有关。纯函数可以传回多个输出值,但上述的原则需针对所有输出值都要成立。若引数是传引用调用,若有对参数物件的更改,就会影响函数以外物件的内容,因此就不是纯函数。

总结一下,纯函数的重点在于:

  • 相同的输入产生相同的输出(不能在内部使用Math.random,Date.now这些方法影响输出)
  • 输出不能和输入值以外的任何东西有关(不能调用API获得其他数据)
  • 函数内部不能影响函数外部的任何东西(不能直接改变传入的引用变量),即不会突变

reducer为什么要求使用纯函数,文档里也有提到,总结下来有这几点:

  • state是根据reducer创建出来的,所以reducer是和state紧密相关的,对于state,我们有时候需要有一些需求(比如打印每一次更新前后的state,或者回到某一次更新前的state)这就对reducer有一些要求。

  • 纯函数更易于调试

    • 比如我们调试时希望action和对应的新旧state能够被打印出来,如果新state是在旧state上修改的,即使用同一个引用,那么就不能打印出新旧两种状态了。
    • 如果函数的输出具有随机性,或者依赖外部的任何东西,都会让我们调试时很难定位问题。
  • 如果不使用纯函数,那么在比较新旧状态对应的两个对象时,我们就不得不深比较了,深比较是非常浪费性能的。相反的,如果对于所有可能被修改的对象(比如reducer被调用了一次,传入的state就可能被改变),我们都新建一个对象并赋值,两个对象有不同的地址。那么浅比较就可以了。

至此,我们已经知道了,reducer是一个纯函数,那么如果我们在应用中确实需要处理一些副作用(比如异步处理,调用API等操作),那么该怎么办呢?这就是中间件解决的问题。下面我们就来讲讲redux中的中间件。

中间件处理副作用的机制

中间件在redux中位于什么位置,我们可以通过这两张图来看一下。

先来看看不用中间件时的redux工作流程:

redux工作流程_同步

  1. dispatch一个action(纯对象格式)
  2. 这个action被reducer处理
  3. reducer根据action更新store(中的state)

而用了中间件之后的工作流程是这样的:

redux工作流程_中间件

  1. dispatch一个“action”(不一定是标准的action)
  2. 这个“action”先被中间件处理(比如在这里发送一个异步请求)
  3. 中间件处理结束后,再发送一个"action"(有可能是原来的action,也可能是不同的action因中间件功能不同而不同)
  4. 中间件发出的"action"可能继续被另一个中间件处理,进行类似3的步骤。即中间件可以链式串联。
  5. 最后一个中间件处理完后,dispatch一个符合reducer处理标准的action(纯对象action)
  6. 这个标准的action被reducer处理,
  7. reducer根据action更新store(中的state)

那么中间件该如何融合到redux中呢?

在上面的流程中,2-4的步骤是关于中间件的,但凡我们想要添加一个中间件,我们就需要写一套2-4的逻辑。

如果我们需要多个中间件,我们就需要考虑如何让他们串联起来。如果每次串联都写一份串联逻辑的话,就不够灵活,万一需要增删改或调整中间件的顺序,都需要修改中间件串联的逻辑。

所以redux提供了一种解决方案,将中间件的串联操作进行了封装,经过封装后,上面的步骤2-5就可以成为一个整体,如下图:

封装中间件后的逻辑

我们只需要改造store自带的dispatch方法。action发生后,先给中间件处理,最后再dispatch一个action交给reducer去改变状态。

在redux中使用中间件

还记得redux 的createStore()方法的第三个参数enhancer吗:

function createStore(reducer, preloadedState, enhancer) {
    if(enhancer是有效的){  
        return enhancer(createStore)(reducer, preloadedState)
    } 
    
    //...
}

在这里,我们可以看到,enhancer(可以叫做强化器)是一个函数,这个函数接受一个「普通createStore函数」作为参数,返回一个「加强后的createStore函数」。

这个加强的过程中做的事情,其实就是改造dispatch,添加上中间件。

redux提供的applyMiddleware()方法返回的就是一个enhancer。

applyMiddleware,顾名思义,「应用中间件」。输入为若干中间件,输出为enhancer。下面来看看它的源码:

function applyMiddleware(...middlewares) {
    // 返回一个函数A,函数A的参数是一个createStore函数。
    // 函数A的返回值是函数B,其实也就是一个加强后的createStore函数,大括号内的是函数B的函数体
    return createStore => (...args) => {
        //用参数传进来的createStore创建一个store
        const store  = createStore(...args)
        //注意,我们在这里需要改造的只是store的dispatch方法
        
        let dispatch = () => {  //一个临时的dispatch
            					//作用是在dispatch改造完成前调用dispatch只会打印错误信息
            throw new Error(`一些错误信息`)
        } 
        //接下来我们准备将每个中间件与我们的state关联起来(通过传入getState方法),得到改造函数。
        const middlewareAPI = {
            getState: store.getState,
            dispatch: (...args) => dispatch(...args)
        }
        //middlewares是一个中间件函数数组,中间件函数的返回值是一个改造dispatch的函数
        //调用数组中的每个中间件函数,得到所有的改造函数
        const chain = middlewares.map(middleware => middleware(middlewareAPI))
        
        //将这些改造函数compose(翻译:构成,整理成)成一个函数
        //用compose后的函数去改造store的dispatch
        dispatch = compose(...chain)(store.dispatch)
        // compose方法的作用是,例如这样调用:
        // compose(func1,func2,func3)
        // 返回一个函数: (...args) => func1( func2( func3(...args) ) )
        // 即传入的dispatch被func3改造后得到一个新的dispatch,新的dispatch继续被func2改造...
        
        // 返回store,用改造后的dispatch方法替换store中的dispatch
        return {
            ...store,
            dispatch
        }
    }
}

总结一下,applyMiddleware的工作方式是:

  1. 调用(若干个)中间件函数,获取(若干个)改造函数
  2. 把所有改造函数compose成一个改造函数
  3. 改造dispatch方法

中间件的工作方式是:

  • 中间件是一个函数,不妨叫做中间件函数
  • 中间件函数的输入是store的getStatedispatch,输出为改造函数(改造dispatch的函数)
  • 改造函数输入是一个dispatch,输出「改造后的dispatch

源码中用到了一个很有用的方法:compose(),将多个函数组合成一个函数。理解这个函数对理解中间件很有帮助,我们来看看它的源码:

function compose(...funcs) {
    // 当未传入函数时,返回一个函数:arg => arg
    if(funcs.length === 0) {
        return arg => arg
    }
    
    // 当只传入一个函数时,直接返回这个函数
    if(funcs.length === 1) {
        return funcs[0]
    }
    
    // 返回组合后的函数
    return funcs.reduce((a, b) => (...args) => a(b(...args)))
    
    //reduce是js的Array对象的内置方法
    //array.reduce(callback)的作用是:给array中每一个元素应用callback函数
    //callback函数:
    /*
     *@参数{accumulator}:callback上一次调用的返回值
     *@参数{value}:当前数组元素
     *@参数{index}:可选,当前元素的索引
     *@参数{array}:可选,当前数组
     *
     *callback( accumulator, value, [index], [array])
    */
}

画一张图来理解compose的作用:

compose

在applyMiddleware方法中,我们传入的「参数」是原始的dispatch方法,返回的「结果」是改造后的dispatch方法。通过compose,我们可以让多个改造函数抽象成一个改造函数。

中间件的实现

作者注:本来只想讲redux,但是讲着讲着却发现:理解中间件,是理解redux的中间件机制的前提。

下面我们以redux-thunk为例,看看一个中间件是如何实现的。

redux-thunk的功能

你可能没用过redux-thunk,所以在阅读源码前,我先简要的讲一下redux-thunk的作用:

正常的dispatch函数的参数action应该是一个纯对象。像这样:

store.dispatch({
    type:'REQUEST_SOME_THING',
    payload: {
        from:'bob',
    }
})

使用了thunk之后,我们可以dispatch一个函数:

function logStateInOneSecond(name) {
    return (dispatch, getState, name) => {  // 这个函数会在合适的时候dispatch一个真正的action
        setTimeout({
            console.log(getState())
            dispatch({
                type:'LOG_OK',
                payload: {
                    name,
                }
            })
        }, 1000)
    }
}

store.dispatch(logStateInOneSecond('jay')) //dispatch的参数是一个函数

为什么需要这个功能?或者说「dispatch一个函数」能解决什么问题?

从上面的例子中你会发现,如果dispatch一个函数,我们可以在这个函数内做任何我们想要的操作(异步处理,调用接口等等),不受任何限制,为什么?

因为我们「还没有dispatch一个真正的action」,所以不会调用reducer,我们并没有将副作用放在reducer中,而是在使用reducer之前就处理了副作用

如果你还不明白redux-thunk的功能,可以去它的github仓库查看更详细的解释。

redux-thunk的实现

如何实现redux-thunk中间件呢?

首先中间件肯定是改造dispatch方法,改造后的dispatch应该具有这样的功能:

  1. 如果传入的参数是函数,就执行这个函数。
  2. 否则,就认为传入的是一个标准的action,就调用「改造前的dispatch」方法,dispatch这个action。

现在我们来看看redux-thunk的源码(8行有效代码):

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

如果三个箭头函数让你有点头晕,我来帮你展开一下:

//createThunkMiddleware的作用是返回thunk中间件(middleware)
function createThunkMiddleware(extraArgument) {
    
    return function({ dispatch, getState }) { // 这是「中间件函数」
        
        return function(next) { // 这是中间件函数创建的「改造函数」
            
            return function(action) { // 这是改造函数改造后的「dispatch方法」
                if (typeof action === 'function') {
                  return action(dispatch, getState, extraArgument);
                }
                
                return next(action);
            }
        } 
    }
}

再加点注释?

function createThunkMiddleware(extraArgument) {
    
    return function({ dispatch, getState }) { // 这是「中间件函数」
        //参数是store中的dispatch和getState方法
        
        return function(next) { // 这是中间件函数创建的「改造函数」
            //参数next是被当前中间件改造前的dispatch
            //因为在被当前中间件改造之前,可能已经被其他中间件改造过了,所以不妨叫next
            
            return function(action) { // 这是改造函数「改造后的dispatch方法」
                if (typeof action === 'function') {
                  //如果action是一个函数,就调用这个函数,并传入参数给函数使用
                  return action(dispatch, getState, extraArgument);
                }
                
                //否则调用用改造前的dispatch方法
                return next(action);
            }
        } 
    }
}

讲完了。可以看出redux-thunk严格遵循了redux中间件的思想:在原始的dispatch方法触发reducer处理之前,处理副作用。

总结

至此,redux的核心源码已经讲完了,最后不得不感叹,redux写的真的美,真tm的简洁。

一句话总结redux的核心功能:「创建一个store来管理state」

关于中间件,我会尝试着写一篇《如何自己实现一个redux中间件》,更深入的理解redux中间件的意义。

关于store如何与其他框架(如react)共同工作,我会再写一篇《react-redux源码解读》的博客探究探究这个问题。

敬请期待。


第一次更新(2018-09-12)

  • 添加了对compose()的源码解释
  • 修正了错误的描述”改变state的唯一方式是触发dispatch”;补充了关于unSubscribe的解释
  • 添加了中间件的实现原理,以redux-thunk为例