从Redux源码探索最佳实践
前言
Redux 已经历了几个年头,很多 React 技术栈开发者选用它,我也是其中一员。期间看过数次源码,从最开始为了弄清楚某一部分运行方式来解决一些 Bug,到后来看源码解答我的一些假设性疑问,到最后想揭开它的面纱获得更多指导。在这个过程中我逐渐对 Redux 有了更多认识和收获,因此也决定写下这篇文章来和更多开发者一起交流。本文主要是赏析源码实现技巧,从源码层面介绍 Redux 使用中需要注意的地方。
用法简述
Redux 可以解耦 React(View层)与数据管理和对数据的操作,保持 React(层)的纯净,使职责划分清晰。同时降低了 React 数据传递难度与不可控性。它还提供可预测化的状态管理。Redux 采用了中间件机制,既保证了自身的最少代码量,又增加了可扩展性。下图为 Redux 的工作流程图。
工作流程
- View 层通过 dispatch 方法发出 action,通知 store 用户有新操作。
- store 收到通知会将处理权利交给 reducer,并传递给 reducer 两个参数:previousState 和 action,而 reducer 函数正是由你编写。
- reducer 针对收到的用户操作(action)进行对应的数据处理,最后将处理完生成的新数据返回给 store。
- store 收到新数据会通知 View 进行更新。
细节梳理
- action 和 actionCreator 只是两种写法,actionCreator 允许用户编写的 action 中携带的数据是个变量,因此更通用。
- dispatch 方法使用了中间件机制,增强了 dispatch 功能,如在每次 dispatch 时打印日志。
- 当用一个 reducer 来处理整个项目的所有 action 操作过于复杂时,可借助 combineReducers 分开处理。
- state 是 store 在当前状态生成的数据对象。
走进源码
先看下文件目录,各文件的作用已在下图中标出。除内部工具函数外,本文会按照目录中的模块逐个梳理。同时,为保证文中能尽量多的留下一些干货,不去逐行梳理源码,会着重讲解结论和梳理代码常用技巧。
Index
作为入口文件,抛出了 Redux 可以使用的全部 API(见下图)。可以看到,每个 API 对应目录中一个文件。其中 __DO_NOT_USE__ActionTypes
是内部使用的 actionTypes,是随机生成的,自定义的 actionType 基本不会和这个冲突,因此这个 API 一般是用来做判断的。
代码技巧
这里用到一个常用来判断代码混淆的技巧,通过定义一个空函数,随后判断这个空函数的 name 是否改变来确定代码是否进行了混淆。
CreateStore
这是 Redux 最核心的一个 API。有多核心?它覆盖了 Redux 的整个工作流程(见下图),如果你想自己实现一个简单的 Redux,看这个 API 就足够了!
createStore 用来生成 store,该方法的签名为 ( reducer, preloadedState?, enhancer? ) => {dispatch, subscribe, getState, replaceReducer, [$observable]}
。下面分析参数和返回值用法以及在工作流中的作用。
reducer参数: 是提供给用户实现根据发出的 action 更新 state 的函数,根据源码中调用方式(见下图)可知其签名为 ( previousState, action ) => newState
,在工作流中注册了更新数据的函数等待被调用。
enhancer参数: 即 applyMiddleware() 返回的函数,createStore 方法中,有没有 enhancer 参数直接决定后面会走向哪里(见下图),但是不必恐慌,这只是为了提供给用户更多用法,即使这里 return 出去了,走一圈流程后,createStore 还是会返回上述签名中的内容,提供的更多用法会在 applyMiddleware 中说到。
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 })
来更新数据。
代码技巧
技巧一: 有三个形参时,实现第二个形参可以选填:
技巧二: isDispatching 规范 reducer 中不允许使用 getState、subscribe 和 dispatch 的实现方法:
关键代码在 dispatch 方法中(见下图),可以看到在开始执行 reducer 之前 isDispatching 先置为 true,一直到 reducer 全部执行完才会再设置为 false,随后只要分别在 getState、subscribe、dispatch 三个方法中判断当 isDispatching 为 true 时抛出异常。
ApplyMiddleware
这是很重要的一个工具方法,代码量很小,但无论从代码技巧上还是使用地位上都很重要。
Redux 中间件的作用实际是增强了 dispatch 功能。看源码最后 return { …store, dispatch} 可以知道是用增强后的 dispatch 替代了原来的 store.dispatch。下图为 applyMiddleware 在工作流中的作用。
用法
根据 applyMiddleware 的调用方式再结合 createStore 中提到的 enhancer 参数总结一下用法。
- const store = createStore(reducer, applyMiddleware(…middlewares))
- const store = createStore(reducer, {}, applyMiddleware(…middlewares))
- applyMiddleware(…middlewares)(createStore)(reducer, preloadedState)
同时还可以借助 Redux 提供的 compose 方法来使用,只需将上述用法中的 applyMiddleware(…middlewares)
替换成 compose(applyMiddleware(middleware1),…,applyMiddleware(middlewareN))
即可。
注意一种错误用法,该用法在 createStore 中做了限制:
createStore(reducer, applyMiddleware(middleware1), applyMiddleware(middleware2), …)
写法
applyMiddleware 源码中调用中间件的方法见下图。
其中用到了 compose 方法,这个方法是Redux的一个API,后面详细讲解,调用 compose(middleware1, middleware2, middleware3)(store.dispatch)
相当于调用 middleware1( middleware2( middleware3( store.dispatch ) ) )
。
现在我们以源码里中间件的调用方式倒推 middleware 的写法。
-
源码中
compose(middleware1, middleware2, middleware3)(store.dispatch)
即middleware1( middleware2( middleware3( store.dispatch ) ) )
调用后生成了新的 dispatch,所以 middleware 函数应是(next) => dispatch
即(next) => (action) => action
, 其中 next 是上一个 middleware 的返回值。 -
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 中都用到了,那区别是什么呢?下面代码技巧中解决这个问题。
代码技巧
细心的同学可能发现了 middlewareAPI 中的 getState 就是 store.getState,而 dispatch 却是一个抛出异常的新函数!
另外, 为什么不直接将 dispatch 变量当成参数,而要再包一层函数呢?
其实 dispatch 的这两种定义方式的区别就在于赋值时机不同:
-
{ dispatch: dispatch }
这种方式定义,MiddlewareAPI.dispatch 被赋值的永远是抛了一段异常的 dispatch 变量。 -
{ dispatch: (...args) => dispatch(…args) }
这种方式定义,函数内的 dispatch 只有在执行 MiddlewareAPI.dispatch 时才会去找此时 dispatch 变量到底是哪个函数。第一次执行中间件操作时,在中间件内部使用的 dispatch 变量是那个只抛了异常的函数。第一次执行完毕后,由上图可以看到 dispatch 会被重新赋值,因此当用户调用从 redux-thunk 传出的 dispatch 时,已经是增强后的 dispatch 了。
多个中间件调用顺序
直接看一个小 demo,执行顺序已用序号注释在后面,也可用断点看执行顺序。
通过 demo 可以看出,会先执行 f 中间件中的 fFn 再执行 g 中的 gFn。同时 gFn 的执行是需要在 f 中间件中调用 next(action) 才能执行到。如果你也想开发中间件,不要忘记这点。
compose
在 applyMiddleware 中我们知道调用 compose(middleware1, middleware2, middleware3)(store.dispatch)
相当于调用 middleware1( middleware2( middleware3( store.dispatch ) ) )
。compose 源码中只用了数组的原生方法 reduce 就优雅的解决了函数层层嵌套的问题(见下图)。
reduce 的基本用法可以参看 MDN,里面的小例子也很好。
下面为源码中 reduce 解决函数嵌套的运行原理,其中 m1,m2,m3,m4 为中间件。
这对我们平时写代码是个很好的启发,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 函数然后帮你合并成一个,在工作流中的作用见下图。
既然是要合并 reducer,那么合并后的函数也要和 reducer 写法一致。因此:
comineReducers: (reducers) => (state, action) => newState
这里的 reducers 是一个对象,如 { a: reducer1, b: reducer2 }
。不过一般都会让 key 和 value 的函数名一致,es6 语法即可写为 { reducer1, reducer2 }
,核心代码如下:
从图中可看到直接调用了 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))
这种调用方式,下图为优化前后使用姿势对比。
根据上面的对比很容易得出结论,这个方法只是将 store.dispatch 调用进行了封装,简化了调用写法,核心代码见下图:
总结
本文围绕源码实现技巧和使用注意事项展开,希望尽可能给小伙伴们提供一些思想上的启发和开发上的帮助。阅读源码就像读一本好书,每次阅读都会有不同的收获。在这个过程中,我总结了自己的阅读源码的方法供大家参考。
- 准备一个使用源码的 demo,随时用来运行调试,大部分库也可以选用它本身提供的例子。
- 快速梳理清楚源码的结构及每部分功能的大致位置。
- 明确目标,想好自己看完代码想有什么结论,决定切入点。阅读源码时从不同切入点去读最后都会有不一样的收获,在这个过程中也会慢慢熟悉整个源码的设计思想及编写者的习惯。
- 第一遍阅读时如果有什么猜想及时记录下来但不去马上投入研究,要紧跟能达到你目标的骨干流程去看。未来这些猜想会成为对源码理解升华的必要条件。
- 看源码过程中充分利用函数名,对象名,类名等快速对每一小段代码有个初始定位。遇到复杂部分可以直接 debug 执行流程,也可以借助注释的帮助,还可以自己尝试去一步步实现一下基本流程,一个好的库在各种名字和注释方面也是做的很好的,因此在写自己代码的时候也尽量去做好这部分工作,降低阅读和维护成本。
Redux 源码设计上采用了很多函数式编程的思想,以后还会继续研究函数式编程相关内容,这对改善代码设计很有帮助,欢迎有兴趣的小伙伴一起交流。