你真的知道react-redux是怎么运行的么

最近我在想状态管理的一些技术, 突然想到, react这种只要一有数据变化就无脑进行reRender的框架, 再引入了redux的全局state后, 到底react-redux做了什么事情? 于是打算看看源码, 了解一下它幕后的故事,
由于目前我接触的项目绝大部分还停留在react 15上, 并且为了避免新增的hooks相关的代码干扰, 这次我主要看的是react-redux的5.x版本,毕竟更贴紧业务嘛!

通常看源码最好带一些问题去看效果比较好, 而我再看代码前, 我有以下几个疑问:

  1. 它是如果进行性能优化的
    2, 全局state的传递,是通过什么来实现的
  2. 如果使用context来传递store信息, 是如何不被CSU阻断的.(react15还在使用老的context api)

开始

react-redux有两个主要的api Provider connect. 他们俩类似于广播, 一个是广播塔, 一个是收音机, 我们先从广播塔Provider开始吧!

注: 以下的所有源码都被简化, 方便理解

Provider

我们先简单看一下用法, 方便对照调用和实现:

1
2
3
4
5
6
7
8
const store = createStore();
ReactDOM.render(
// 传递 全局状态到provider
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)

ok,调用很简单, 让我们直接进入代码, 显然react-redux确实是使用context api进行全局store的传递的, 通过Provider从根组件传递了两个属性:

1
2
3
4
5
6
7
8
9
10
11
class Provider extends Component {
getChildContext() {
// 添加了两个context属性, 一个store, 一个storeSubscription
return { store: this.store, storeSubscription: null }
}
constructor(props, context) {
super(props, context)
this.store = props.store;
}
...
}

可以看出来 Provider只做了很少的事情, 就是把redux的store往下传递, 而这里有一个storeSubscription只是做了一个null的赋值, 那么这个属性到底是干什么的呢? 我们先卖个关子, 接着往下看

connect

上面说的Provider像是广播塔, 那么收音机connect它的调用方式我们也来回顾一下, 方便后面对照

1
2
3
4
5
6
7
8
9
function mapStateToProps(state) {
return { todos: state.todos }
}
function mapDispatchToProps(dispatch) {
return { actions: bindActionCreators(actionCreators, dispatch) }
}
// 最简单的调用, connect是一个高级函数的科里化, 第一次调用传入对应的mapToProps
// 方法, 第二次调用传入需要包裹的组件
export default connect(mapStateToProps, mapDispatchToProps)(TodoApp)

通过阅读源码,我发现connect的整个功能被分成了两个部分:

  1. 提取需要的stateProps, 注入被包裹的组件, 并做有效的缓存和性能优化
  2. 通知全局state状态改变

提取需要的stateProps

connect是一个会大规模使用的公共组件, 其性能一定是倍受关注的, 对于react, 除了普通的代码执行层面的优化, 最有效的优化无疑是componentShouldUpdate了, 因为即使有virtue Dom的存在, 依然不可避免的需要走完render函数, 走完diff算法, 而componentShouldUpdate则异常的直接, 只要你返回的是false, 就直接不做后面的所有事情, 无需render, 直接复用之前的元素.

那么如何判断我们的connect是否需要渲染呢?

  1. 组件本身的props有变化
  2. 全局state有变化

如上, 组件本身的props,我们是可以很容易拿到的, 任何一个有变化, 都应该reRender, 但是全局State变化, 我们就不一定都需要reRender了, 这个时候, 我们需要依赖的实际上是传入给connect的 mapStateToProps mapDispatchToProps, 他们两个都类似于选择器. 我只需要确认这两个函数的返回值是否有变化, 就可以确认我是否需要reRender, 因为她们的返回值, 最后都会作用下面组件的props上

创建一个selector

所以最后, 最好结果是组合 mapStateToProps mapDispatchToProps 这些函数, 最后返回一个结果对象, 我们只要判断这个结果对象前后两次是否相同, 就可以判断是否需要reRender, 大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const stateA = { a: 1, b: 2, c: 3, d: { aa: 4, bb: 5}};
const stateB = { a: 1, b: 999, c: 999, d: { aa: 4, bb: 5}};

function mapStateToProps1(state) {
return {
props1: state.a,
props2: state.d.bb,
}
}

function mapStateToProps2(state) {
return {
props3: state.a,
props4: state.d.aa,
}
}

function selector(mapToProps1, mapToProps2, state) {
return { ...mapToProps1(state), ...mapToProps2(state)};
}

const mergedPropsA = selector(mapStateToProps1, mapStateToProps2, stateA);
const mergedPropsB = selector(mapStateToProps1, mapStateToProps2, stateB);

shadowCompile(mergePropsA, mergePropsB); // true

上面是简单的实例, 来说明我们的 mapStateToProps mapDispatchToProps是如何来发挥选择器作用的.

下面我们看看react-redux是怎么做的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// 这个函数会传入我们再connect传入的 mapStateToProps, mapDispatchToProps, mergeProps, 三个函数, 以及dispatch
function pureFinalPropsSelectorFactory(
mapStateToProps,
mapDispatchToProps,
mergeProps,
dispatch,
) {
// 记录是否是第一次执行
let hasRunAtLeastOnce = false
let state
let ownProps
let stateProps
let dispatchProps
// 本函数最后都会返回这个值, 是一个结果对象, 最后会之间传递到被包裹的组件上
let mergedProps

// 首次执行, 计算props, 并生成缓存
function handleFirstCall(firstState, firstOwnProps) {
state = firstState
ownProps = firstOwnProps
stateProps = mapStateToProps(state, ownProps)
dispatchProps = mapDispatchToProps(dispatch, ownProps)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
// 切换标志位
hasRunAtLeastOnce = true
return mergedProps
}

function handleNewPropsAndNewState() {
stateProps = mapStateToProps(state, ownProps)

if (mapDispatchToProps.dependsOnOwnProps)
dispatchProps = mapDispatchToProps(dispatch, ownProps)

mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
function handleNewProps() {
if (mapStateToProps.dependsOnOwnProps)
stateProps = mapStateToProps(state, ownProps)
if (mapDispatchToProps.dependsOnOwnProps)
dispatchProps = mapDispatchToProps(dispatch, ownProps)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
function handleNewState() {
const nextStateProps = mapStateToProps(state, ownProps)
const statePropsChanged = !areStatePropsEqual(nextStateProps, stateProps)
stateProps = nextStateProps
if (statePropsChanged)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
// 首次执行后都走这个函数
function handleSubsequentCalls(nextState, nextOwnProps) {
// 看组件自己接受的props是否相同
const propsChanged = !areOwnPropsEqual(nextOwnProps, ownProps)
// 判断收到的全局state是否相等, 上面两种方法都这是做浅比较
const stateChanged = !areStatesEqual(nextState, state)
state = nextState
ownProps = nextOwnProps
// 根据不同情况, 做不同处理, 最后的结果都是会去处理 mergedProps这个值
// 如果都相等就原封不动的返回mergedProps
if (propsChanged && stateChanged) return handleNewPropsAndNewState()
if (propsChanged) return handleNewProps()
if (stateChanged) return handleNewState()
return mergedProps
}
// 入口, 传入的是redux的全局state,和自己本身接受的props
return function selector(nextState, nextOwnProps) {
// 首次执行 和 之后的执行分别走了不同的函数
return hasRunAtLeastOnce
? handleSubsequentCalls(nextState, nextOwnProps)
: handleFirstCall(nextState, nextOwnProps)
}
}

上面我们看到, connect 生成了一个selector函数, 通过保存 mapStateToProps mapDispatchToProps的返回值,对两次结果进行比较来做优化,如果没变化则直接返回旧值.这样我们在每次调用selector之后,只要两次的返回值相等, 则说明我们并不需要渲染, 那么connectHOC的 componentShouldUpdate就可以返回true了
所以只要我们生成了select函数, 剩下只需要关注状态变化, 并渲染就可以了

state状态改变通知

再看代码前, 我们先来想一想应该怎样做状态变化的通知.

一种比较简单的状态通知方式是, 每次store改变, 触发根组件的刷新, 所有的connect都会在reRender的时机通过context取获取最新的全局state, 并通过selector进行属性提取, 最后把对应的props注入组件中

但是这有一个问题, 首先就是这种方式会在每次store有变化时,进行一次全应用的reRender, 性能可以说是非常差了. 所以要做性能优化, 上一节我们说了, 设计出来的selector是具被这些能力的,只需要使用
componentShouldUpdate优化, 使用它来判断是否需要对组件进行渲染.

但是如果我们仔细想一下,有任何一个connectHOC再做过前后stateProps对比后, 发现没有变化, 那么它就会停止了渲染, 这样会发生什么? 无疑, 这会导致 下级的connect
没有触发reRender, 从而也就不会触发它获取最新store, 整个数据的传递,再这里就被截断了, 在新的context api出现之前, 这个问题一直是倍受开发者诟病

上面这种方式状态的更新路径可以见下图;

既然有这样的问题, 我们就需要考虑能不能不让所有的connect去直接依赖store的变化? 如果这样,我们应该怎么办?

我们是不是可以通过嵌套层级进行一级一级的传递, 当顶级的store改变了, 向下一级进行通知, 对应的connect会自动向下一级进行转发, 这样就不再依赖于组件的渲染传递了, 具体的示意图如下:

实际上react-redux就是这样解决问题的, 每一个connectHOC都是一个订阅者, 同时也是一个发布者, 他们都包含一个subscription对象, 用来订阅上级connect, 并且通知下级connect,接下来上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// 对select进行一次包裹, 方便吧一些值保存在这个包裹对象里
// 除了run, 还有props用来记录当前最新的stateProps, 还有shouldComponentUpdate
// 用来记录下次reRender是否应该通过
function makeSelectorStateful(sourceSelector, store) {
const selector = {
run: function runComponentSelector(props) {
const nextProps = sourceSelector(store.getState(), props)
if (nextProps !== selector.props) {
selector.shouldComponentUpdate = true
selector.props = nextProps
}
},
}
return selector
}
// 真正的connect包裹组件
class Connect extends Component {
constructor(props, context) {
super(props, context)
this.store = context.store
this.initSelector() // 初始化selector
this.initSubscription() // 初始化订阅器
}
getChildContext() {
// 覆盖上级的context, 这样下级的ConnectHOC就会获取覆盖后的订阅器,
return { storeSubscription: this.subscription }
}
componentDidMount() {
this.subscription.trySubscribe()
this.selector.run(this.props) // 计算当前最新的props
if (this.selector.shouldComponentUpdate) this.forceUpdate() // 如果计算结果有变化就强制刷新
}
componentWillReceiveProps(nextProps) {
this.selector.run(nextProps) // 每次获取到新的props都从新进行计算
}
shouldComponentUpdate() {
return this.selector.shouldComponentUpdate
}
initSelector() {
// 初始化selector, 这里的selector, 就是我们上面提到的selector, 接受最新的全局State, 和当前组件的props
const sourceSelector = selectorFactory(this.store.dispatch, selectorFactoryOptions)
// 做一个包裹, 用来保存更多信息
this.selector = makeSelectorStateful(sourceSelector, this.store)
// 调用run, 等于直接调用selector, 获取最新的props
this.selector.run(this.props)
}
initSubscription() {
const parentSub = this.context[subscriptionKey] // 获取上一级的订阅器
// 再上一级订阅器里注册onStateChange回调, 并生成自己的订阅器
// 之前卖的关子就在这里, Provider传入的 context.storeSubscription 是null, 因为当 storeSubscription为空的话, connect的会直接到store上进行订阅
// 否则会在上级connect再context中传入storeSubscription上进行订阅
// 也就是说, 只有第一级connect会直接订阅store的变化, 其他级别都是直接订阅上级connect的storeSubscription
this.subscription = new Subscription(this.store, parentSub, this.onStateChange.bind(this))
this.notifyNestedSubs = this.subscription.notifyNestedSubs.bind(this.subscription)
}
// 这个方法会在每次store有变化时调用
onStateChange() {
// 首先是计算props
this.selector.run(this.props)
if (!this.selector.shouldComponentUpdate) {
// 无变动则只通知
this.notifyNestedSubs()
} else {
// 有变动则再下次didupdate的时候进行通知, 我猜测是为了防止一次出发所有变动导致性能卡顿???
this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
this.setState({})
}
}
// didUpdate生命周期的实际回调函数, 这么做官方解释也是为了能够减少boolean判断, 提高性能
notifyNestedSubsOnComponentDidUpdate() {
this.componentDidUpdate = undefined
this.notifyNestedSubs()
}
isSubscribed() {
return Boolean(this.subscription) && this.subscription.isSubscribed()
}
render() {
const selector = this.selector
selector.shouldComponentUpdate = false
return createElement(WrappedComponent,selector.props)
}
}

结语

OK, 看完这些, 我们基本上就把react-redux的核心理念都看完了,

  1. 首先是生成一个selector函数, 截取需要数据进行前后对比, 确定是否rerender
  2. 为了保证不被componentShouldUpdate截断, 使用发布订阅模式来对子级connect进行通知

上述的代码都是为了方便阅读而做了大量的删减, 只保存了最关键的代码, 实际上还有一些报错处理, 以及兼容处理, 比如如何应对一些react16已经不在安全的生命周期兼容,
ssr场景兼容, 以及更多的可扩展性的设计, 对了, 还有很多高阶函数的骚气调用, 看得我眼花缭乱. 总体来说, 收获不少, 能更深刻的理解设计者, 了解到他们代码之下的思考.