从Redux源码探索最佳实践

前言

Redux 已经历了几个年头,很多 React 技术栈开发者选用它,我也是其中一员。期间看过数次源码,从最开始为了弄清楚某一部分运行方式来解决一些 Bug,到后来看源码解答我的一些假设性疑问,到最后想揭开它的面纱获得更多指导。在这个过程中我逐渐对 Redux 有了更多认识和收获,因此也决定写下这篇文章来和更多开发者一起交流。本文主要是赏析源码实现技巧,从源码层面介绍 Redux 使用中需要注意的地方。

用法简述

Redux 可以解耦 React(View层)与数据管理和对数据的操作,保持 React(层)的纯净,使职责划分清晰。同时降低了 React 数据传递难度与不可控性。它还提供可预测化的状态管理。Redux 采用了中间件机制,既保证了自身的最少代码量,又增加了可扩展性。下图为 Redux 的工作流程图。

01_workflow

工作流程

  1. View 层通过 dispatch 方法发出 action,通知 store 用户有新操作。
  2. store 收到通知会将处理权利交给 reducer,并传递给 reducer 两个参数:previousState 和 action,而 reducer 函数正是由你编写。
  3. reducer 针对收到的用户操作(action)进行对应的数据处理,最后将处理完生成的新数据返回给 store。
  4. store 收到新数据会通知 View 进行更新。

细节梳理

  • action 和 actionCreator 只是两种写法,actionCreator 允许用户编写的 action 中携带的数据是个变量,因此更通用。
  • dispatch 方法使用了中间件机制,增强了 dispatch 功能,如在每次 dispatch 时打印日志。
  • 当用一个 reducer 来处理整个项目的所有 action 操作过于复杂时,可借助 combineReducers 分开处理。
  • state 是 store 在当前状态生成的数据对象。

走进源码

先看下文件目录,各文件的作用已在下图中标出。除内部工具函数外,本文会按照目录中的模块逐个梳理。同时,为保证文中能尽量多的留下一些干货,不去逐行梳理源码,会着重讲解结论和梳理代码常用技巧。

02_files

Index

作为入口文件,抛出了 Redux 可以使用的全部 API(见下图)。可以看到,每个 API 对应目录中一个文件。其中 __DO_NOT_USE__ActionTypes 是内部使用的 actionTypes,是随机生成的,自定义的 actionType 基本不会和这个冲突,因此这个 API 一般是用来做判断的。

03_indexexport

代码技巧

这里用到一个常用来判断代码混淆的技巧,通过定义一个空函数,随后判断这个空函数的 name 是否改变来确定代码是否进行了混淆。

04_iscrushed

CreateStore

这是 Redux 最核心的一个 API。有多核心?它覆盖了 Redux 的整个工作流程(见下图),如果你想自己实现一个简单的 Redux,看这个 API 就足够了!

05_createstoreflow

createStore 用来生成 store,该方法的签名为 ( reducer, preloadedState?, enhancer? ) => {dispatch, subscribe, getState, replaceReducer, [$observable]}。下面分析参数和返回值用法以及在工作流中的作用。

reducer参数: 是提供给用户实现根据发出的 action 更新 state 的函数,根据源码中调用方式(见下图)可知其签名为 ( previousState, action ) => newState,在工作流中注册了更新数据的函数等待被调用。

06_reducerapi

enhancer参数: 即 applyMiddleware() 返回的函数,createStore 方法中,有没有 enhancer 参数直接决定后面会走向哪里(见下图),但是不必恐慌,这只是为了提供给用户更多用法,即使这里 return 出去了,走一圈流程后,createStore 还是会返回上述签名中的内容,提供的更多用法会在 applyMiddleware 中说到。

07_enhancer

store.subscribe方法: 注册监听事件,并返回了取消监听的方法:( listener ) => unsubscribe,等待数据更新后被调用,在工作流中用来注册根据 state 变化来更新 View 的事件。

store.getState方法: 获取当前 state 数据对象,签名为 () => currentState

store.replaceReducer方法: 用来更新 reducer 参数传入的函数,签名为 ( nextReducer ) => undefined

store.[$$observable]方法: 可以理解为 store.subscribe 的一种 observable 形式的封装,功能和 store.subscribe 一致,供相应工具使用。该方法使用并不多,不再赘述。

store.dispatch方法: store 中最核心的方法,没有之一,打通了整个工作流程,其签名为 ( action ) => action。它首先调用了 reducer 参数传入的函数更新数据,随后遍历并调用了 store.subscribe 方法注册的监听事件,告知数据发生了变更。为了在使用 createStore 生成 store 时就直接生成一个初始 state 对象,这个方法内调用了一次 dispatch({ type: ActionTypes.INIT }),由于 ActionTypes.INIT 类型不存在于你定义的 reducer 中任何处理函数,因此会返回你定义的初始状态,这也是 preloadedState 参数很少使用的原因。

使用注意

  • createStore 中直接调用 reducer 来生成数据并未额外操作数据,因此使用时需注意:
    • 返回的 newState 要和参数 state 没有引用关系。
    • 任何未知 action,必须返回当前状态(参数 state 获取到的状态)。若当前状态未定义,必须返回初始状态(自定义的 initState)。
  • isDispatching 规范了 reducer 函数中不允许使用 getState、subscribe 和 dispatch 方法。
  • store 并不储存所有数据,而是储存的更改数据的方法(reducer),因此生成初始数据需要内部先调用 dispatch({ type: ActionTypes.INIT }),并且在需要更新 reducer 时要调用 replaceReducer 才能更新。为保证数据实时性,更新 reducer 后需要再调用一次 dispatch({ type: ActionTypes.REPLACE }) 来更新数据。

代码技巧

技巧一: 有三个形参时,实现第二个形参可以选填:

08_nonesecondparam

技巧二: isDispatching 规范 reducer 中不允许使用 getState、subscribe 和 dispatch 的实现方法:
关键代码在 dispatch 方法中(见下图),可以看到在开始执行 reducer 之前 isDispatching 先置为 true,一直到 reducer 全部执行完才会再设置为 false,随后只要分别在 getState、subscribe、dispatch 三个方法中判断当 isDispatching 为 true 时抛出异常。

09_isdispatching

ApplyMiddleware

这是很重要的一个工具方法,代码量很小,但无论从代码技巧上还是使用地位上都很重要。

Redux 中间件的作用实际是增强了 dispatch 功能。看源码最后 return { …store, dispatch} 可以知道是用增强后的 dispatch 替代了原来的 store.dispatch。下图为 applyMiddleware 在工作流中的作用。

10_applymiddlewareflow

用法

根据 applyMiddleware 的调用方式再结合 createStore 中提到的 enhancer 参数总结一下用法。

  1. const store = createStore(reducer, applyMiddleware(…middlewares))
  2. const store = createStore(reducer, {}, applyMiddleware(…middlewares))
  3. applyMiddleware(…middlewares)(createStore)(reducer, preloadedState)

同时还可以借助 Redux 提供的 compose 方法来使用,只需将上述用法中的 applyMiddleware(…middlewares) 替换成 compose(applyMiddleware(middleware1),…,applyMiddleware(middlewareN)) 即可。

注意一种错误用法,该用法在 createStore 中做了限制:
createStore(reducer, applyMiddleware(middleware1), applyMiddleware(middleware2), …)

写法

applyMiddleware 源码中调用中间件的方法见下图。

11_callmiddlewares

其中用到了 compose 方法,这个方法是Redux的一个API,后面详细讲解,调用 compose(middleware1, middleware2, middleware3)(store.dispatch) 相当于调用 middleware1( middleware2( middleware3( store.dispatch ) ) )

现在我们以源码里中间件的调用方式倒推 middleware 的写法。

  1. 源码中 compose(middleware1, middleware2, middleware3)(store.dispatch)middleware1( middleware2( middleware3( store.dispatch ) ) ) 调用后生成了新的 dispatch,所以 middleware 函数应是 (next) => dispatch(next) => (action) => action, 其中 next 是上一个 middleware 的返回值。

  2. middleware 函数在传入 compose 之前用 middleware(middlewareAPI) 获得了 dispatch 和 getState 供中间件内部使用,因此还需要能接收这个参数,于是:
    ({dispatch, getState}) => (next) => (action) => action

没错,到这里 middleware 函数就已经写完了!

可以看到 middleware 的写法 ({dispatch, getState}) => (next) => (action) => action 是一种典型的柯里化函数,柯里化函数很适合做偏底层一些的函数抽象,方便再次封装时拿到任何一层的返回结果去做相应操作,随后也可选择是否再继续调用、何时调用。另外通过 middleware 结合 compose 的使用知道,柯里化函数很方便做函数组合。

最后,放出一张 redux-thunk 中间件源码截图,供大家检验。看 redux-thunk 又有一个问题需要解决:刚刚说 next 是上一个中间件返回的 dispatch,同时 middlewareAPI 中也有 dispatch。这两个在 redux-thunk 中都用到了,那区别是什么呢?下面代码技巧中解决这个问题。

12_reduxthunk

代码技巧

细心的同学可能发现了 middlewareAPI 中的 getState 就是 store.getState,而 dispatch 却是一个抛出异常的新函数!

13_dispatcherror

另外, 为什么不直接将 dispatch 变量当成参数,而要再包一层函数呢?

14_middlewareapi

其实 dispatch 的这两种定义方式的区别就在于赋值时机不同:

  • { dispatch: dispatch } 这种方式定义,MiddlewareAPI.dispatch 被赋值的永远是抛了一段异常的 dispatch 变量。

  • { dispatch: (...args) => dispatch(…args) } 这种方式定义,函数内的 dispatch 只有在执行 MiddlewareAPI.dispatch 时才会去找此时 dispatch 变量到底是哪个函数。第一次执行中间件操作时,在中间件内部使用的 dispatch 变量是那个只抛了异常的函数。第一次执行完毕后,由上图可以看到 dispatch 会被重新赋值,因此当用户调用从 redux-thunk 传出的 dispatch 时,已经是增强后的 dispatch 了。

多个中间件调用顺序

直接看一个小 demo,执行顺序已用序号注释在后面,也可用断点看执行顺序。

15_middlewareorder

通过 demo 可以看出,会先执行 f 中间件中的 fFn 再执行 g 中的 gFn。同时 gFn 的执行是需要在 f 中间件中调用 next(action) 才能执行到。如果你也想开发中间件,不要忘记这点。

compose

在 applyMiddleware 中我们知道调用 compose(middleware1, middleware2, middleware3)(store.dispatch) 相当于调用 middleware1( middleware2( middleware3( store.dispatch ) ) )。compose 源码中只用了数组的原生方法 reduce 就优雅的解决了函数层层嵌套的问题(见下图)。

20_compose

reduce 的基本用法可以参看 MDN,里面的小例子也很好。

下面为源码中 reduce 解决函数嵌套的运行原理,其中 m1,m2,m3,m4 为中间件。

21_composeapply

这对我们平时写代码是个很好的启发,reduce 方法还很擅长做求和、去重、数组扁平化、数据分类等复杂操作,可以替代很多递归操作来实现功能(参见MDN中的小例子)。下面将用多种方法实现异步回调,也可以实现依次执行动画的需求,一起来感受一下reduce写法的简介吧。

// 写法一:回调嵌套写法
function fn0() { // 此写法不易阅读且无法封装函数实现无限嵌套
    console.log(0)
    setTimeout(function fn1() {
        console.log(1)
        setTimeout(function fn2(){
            console.log(2)
            setTimeout(function fn3(){
                console.log(3)
            }, 3000)
        }, 2000)
    }, 1000)
}

fn0()

// 写法二:递归实现
function fn0(next) {
    console.log(0)
    setTimeout(next, 1000)
}
function fn1(next) {
    return () => {
        console.log(1)
        setTimeout(next, 2000)
    }
}
function fn2(next) {
    return ()=>{
        console.log(2)
        setTimeout(next, 3000)
    }
}
function fn3(next) {
    return ()=>{
        console.log(3)
    }
}

function recursiveReduce (...fns) { // 用递归实现源码中 compose 方法
    if (fns.length < 2) { // 空数组或只有一个元素
        return fns[0]
    }
    return recursiveReduce((...args) => fns[0](fns[1](...args)), ...fns.slice(2))
}

recursiveReduce(fn0, fn1, fn2, fn3)()

// 写法三:reduce 写法,其中 fn0, fn1, fn2, fn3 函数定义复用写法二的
function compose(...fns) { // 与源码中的 compose 方法一致
    return fns.reduce((a, b) => (...args) => a(b(...args)))
}

compose(fn0, fn1, fn2, fn3)()

// 写法四:自定义 next
function fn0(next){
    console.log(0)
    setTimeout(next, 1000)
}
function fn1(next){
    console.log(1)
    setTimeout(next, 2000)
}
function fn2(next){
    console.log(2)
    setTimeout(next, 3000)
}
function fn3(next){
    console.log(3)
}

function nextFn(...fns) {  // 借鉴 express 中间件实现方法
    let i = 0;
    function next() {
        const fn = fns[i++];
        if (!fn) return
        fn(next)
    }
    next()
}

nextFn(fn0, fn1, fn2, fn3)

测试一下几种写法的运行速度(见下表),由快到慢依次是:回调嵌套写法、自定义 next、reduce 写法、递归实现。

Chrome 中:

写法 运行速度(ops/sec)
回调嵌套写法 56,919
递归实现 44,346
reduce 写法 47,450
自定义 next 49,950

Firfox 中:

写法 运行速度(ops/sec)
回调嵌套写法 10,660
递归实现 8,411
reduce 写法 8,411
自定义 next 9,095

Safari 中:

写法 运行速度(ops/sec)
回调嵌套写法 298,880
递归实现 117,379
reduce 写法 161,663
自定义 next 220,944

CombineReducers

该方法代码虽多但只做了一件事,就是允许你定义多个 reducer 函数然后帮你合并成一个,在工作流中的作用见下图。

16_combinereducersflow

既然是要合并 reducer,那么合并后的函数也要和 reducer 写法一致。因此:
comineReducers: (reducers) => (state, action) => newState
这里的 reducers 是一个对象,如 { a: reducer1, b: reducer2 }。不过一般都会让 key 和 value 的函数名一致,es6 语法即可写为 { reducer1, reducer2 },核心代码如下:

17_combinereducerscore

从图中可看到直接调用了 reducer,并未对生成数据做其他处理(这和 createStore 中调用 reducer 是一致的),同时 hasChange 只做了浅比较,这样一来我们编写的时候需要注意什么呢?浅比较又有什么好处呢?不妨接着看完使用注意。

使用注意

  • 通过 combineReducers 合并会使生成的 state 对象树在顶层增加一层。
  • 代码中有大量校验,其中就限制了编写的 reducer 不能返回 undefined,如果想清空数据返回 null,想还原数据返回原来的 state。
  • 源码中 comineReducers 生成的 rootReducer 被执行的时候会依次执行每个 reducer。当 reducer 中有两个方法都处理了同一个 action,那么这两个处理方法都会被执行。为避免这这种不确定性可能导致的 bug,将所有 action.type 的字符串都统一定义在一个文件中是很有帮助的,当然这样做还有其他好处。
  • 源码在进行 hashChange 判断时,对每个 reducer 生成的数据都是进行的浅比较,最后通过 hasChange 判断应返回 nextState 还是 state。因此如果 state 发生了变更,要保证 reducer 返回的 state 和原 state 没有引用关系,否则无法更新。另外这里用浅比较的好处是如果没有更改或者没有命中任何 action 处理方法返回原 state,这样可以避免更新提高性能。

BindActionCreators

该方法代码量少做的事情也简单,目的是优化 store.dispatch(actionCreator(data)) 这种调用方式,下图为优化前后使用姿势对比。

18_bindactioncreatorsapi

根据上面的对比很容易得出结论,这个方法只是将 store.dispatch 调用进行了封装,简化了调用写法,核心代码见下图:

19_bindactioncreatorscore

总结

本文围绕源码实现技巧和使用注意事项展开,希望尽可能给小伙伴们提供一些思想上的启发和开发上的帮助。阅读源码就像读一本好书,每次阅读都会有不同的收获。在这个过程中,我总结了自己的阅读源码的方法供大家参考。

  1. 准备一个使用源码的 demo,随时用来运行调试,大部分库也可以选用它本身提供的例子。
  2. 快速梳理清楚源码的结构及每部分功能的大致位置。
  3. 明确目标,想好自己看完代码想有什么结论,决定切入点。阅读源码时从不同切入点去读最后都会有不一样的收获,在这个过程中也会慢慢熟悉整个源码的设计思想及编写者的习惯。
  4. 第一遍阅读时如果有什么猜想及时记录下来但不去马上投入研究,要紧跟能达到你目标的骨干流程去看。未来这些猜想会成为对源码理解升华的必要条件。
  5. 看源码过程中充分利用函数名,对象名,类名等快速对每一小段代码有个初始定位。遇到复杂部分可以直接 debug 执行流程,也可以借助注释的帮助,还可以自己尝试去一步步实现一下基本流程,一个好的库在各种名字和注释方面也是做的很好的,因此在写自己代码的时候也尽量去做好这部分工作,降低阅读和维护成本。

Redux 源码设计上采用了很多函数式编程的思想,以后还会继续研究函数式编程相关内容,这对改善代码设计很有帮助,欢迎有兴趣的小伙伴一起交流。

posted @ 2019-01-24 19:33  冰凌哒雪花  阅读(513)  评论(0编辑  收藏  举报