使用redux
进行状态控制的同学对connect
肯定都不陌生了,我们都知道通过connect
可以将store
中的状态绑定到当前组件的props
上,其中涉及到一些Context API
的使用,但是究竟是什么触发了我们绑定组件的rerender呢,这个执行时机底层是怎么处理的呢,这成为了本文的研究主题。
本文主要进行react-redux
源码阅读,基于7.2.2
版本。
组件
可以看到react-redux
在组件目录下仅有3个文件,Context.js
最简单,就是导出通过Context API初始化一个Context
对象并导出。我们主要看Provider.js
和connectAdvanced
做了些什么。
Provider.js
1 | function Provider({ store, context, children }) { |
可以看到当前版本下封装的Provider
组件是基于Hooks构造的,主要做了几件事情:
- 通过
useMemo
检测store
是否产生变化,返回一个记忆值对象,它由新的store
以及对新store
进行订阅的subscription
实例组成,其中有一个将notifyNestedSubs
属性赋值给onStateChange
的动作,我们后文会分析为什么(subscription
由Subscription
构造生成,后文会分析该构造函数,这里可以简单理解成是一个发布订阅模式的实现)。 - 通过
useMemo
检测store
是否产生变化,记录变化前的store
的state
。 - 取1,2的记忆值作
deps
,在useEffect
中进行发布订阅的控制,此处能看到的逻辑是,初始化会对消息订阅器(subscription
)进行初始订阅,之后当store
的状态发生改变后,通知子孙订阅节点(notifyNestedSubs
)。后文会集中分析Subscription
内部实现。
connectAdvanced.js
这个文件代码内容就比较多了…494行,慢慢看其实也不复杂,先折叠代码看下整体结构:
先从头部导入的内容来说,像那些工具类校验方法的我们就跳过了,这里简单聊一下hoist-non-react-statics
和useIsomorphicLayoutEffect
。
hoist-non-react-statics
首先,这是一个进行静态方法拷贝的工具库。主要应用在我们进行HOC高阶组件封装的场景。因为当我们进行HOC设计时,被增强的内部函数上绑定的静态方法是无法被映射到增加后的函数上的,这需要我们手动拷贝。拿官方的例子来说:
1 | // Define a static method |
我们自己要去重新绑定的话,就会出现类似下面这样的方式:
1 | function enhance(WrappedComponent) { |
但是一个React组件上绑定的静态属性还是比较多的,不同类型间还需要区分。目前社区中其实已经有比较成熟的库做了这件事,就是hoist-non-react-statics
,通过该库,我们可以比较容易的通过设置我们返回的高阶函数组件targetComponent
以及需要拷贝的源函数组件sourceComponent
进行静态内容拷贝(若是有不想拷贝的静态方法,也可以传第三个可选参数,进行过滤表设置)。
1 | // official usage |
useIsomorphicLayoutEffect
其实这个方法就是一个使用useEffect
还是useLayoutEffect
的问题,两者的差别主要是执行时机上的,前者异步发生在Render
阶段,后者同步发生在Commit
阶段。而根据这个方法的isomorphic
我们也知道这其实是一个兼容同构的api。从源码的判断和注释也可以分析出来,另外在react-native
中和node
一样也是直接使用useLayoutEffect
。
1 | // useIsomorphicLayoutEffect.native.js |
庖丁解牛
了解了上述内容后,我们继续源码的阅读,先看折叠的第13行,发现其实是一个将组件字符串化的方法,目的是为了后面校验高阶函数入参是否符合标准React
元素,如果不符合会抛出一个Error
字符串,将我们传入的组件信息以字符串格式输出。了解后,发现没什么营养,继续往后面看第21行。
storeStateUpdatesReducer
1 | function storeStateUpdatesReducer(state, action) { |
storeStateUpdatesReducer
方法实际上是后面React HooksuseReducer
的第一个入参。
1 | const EMPTY_ARRAY = [] |
结合前后文,我们可以发现这个useReducer
真实目的就是为了获取一个dispatch
执行句柄,便于后续store
状态更新后,子组件的rerender
。
useIsomorphicLayoutEffectWithArgs
下面的useIsomorphicLayoutEffectWithArgs
也比较简单,它是将前文我们提到的useIsomorphicLayoutEffect
进行了一个工厂化处理,支持传入这个hook需要的函数、函数需要的入参以及重新触发函数的依赖。
1 | function useIsomorphicLayoutEffectWithArgs( |
captureWrapperProps
captureWrapperProps
这个方法实际上是用在后面HOC函数wrapWithConnect
中的一个hook回调,每当这个高阶组件重新渲染时,captureWrapperProps
就会被重新执行:
1 | function captureWrapperProps( |
从captureWrapperProps
内部的使用来说,我们可以很容易发现lastWrapperProps
、lastChildProps
、renderIsScheduled
、childPropsFromStoreUpdate
都是通过useRef
进行黑盒状态保存的。其中的值不会被rerender
所改变。代码块干的事情主要为:
- 在每次
render
中更新最新的我们导出HOC的入参props
,实际上它是解构后的属性,第一个入参是操作当前节点的ref
:
1 | const [ |
- 在每次
render
中更新最新的返回函数的props
- 标记正在进行
render
- 如果发现是由
store
更新引起的rerender
,通知所有子订阅节点进行更新
subscribeUpdates
subscribeUpdates
同样在wrapWithConnect
的一个hook回调中,不过它只有在它的deps
即[store, subscription, childPropsSelector]
改变后才重新触发。
1 | function subscribeUpdates( |
内容比较多,我们一点点看:
- 首先有个标记变量
shouldHandleStateChanges
用于判断我们是否需要处理该回调,而该参数我们没配置connectOptions
时,默认情况为true
。 - 通过
didUnsubscribe
和lastThrownError
进行组件是否卸载以及是否抛出异常的记录。 checkForUpdates
是挂载给消息订阅实例subscription
的onStateChange
方法上的。在该方法内:
- 首先判断了当前组件是否已卸载,卸载了就直接
return
否则会继续执行,否则根据最近store
状态进行selector
,得到最新的孩子props
。 - 如果最新的孩子属性和之前的相同,则进行级联的子节点订阅器消息通讯(本质上是一个链表结构,后文会看),否则会更新组件内部的
ref
快照属性lastChildProps
、childPropsFromStoreUpdate
、renderIsScheduled
。 - 此时确认属性确实发生了更新,会调用前文我们说的
dispatch
句柄,进行当前组件的rerender
。 - 至于为什么会
rerender
,因为这个useReducer
返回的更新内容作为了actualChildProps
的deps
,而actualChildProps
又作为了我们最终返回渲染元素的deps
。
1 | const actualChildProps = usePureOnlyMemo(() => { |
1 | // Now that all that's done, we can finally try to actually render the child component. |
connectAdvanced
终于回到正主了…首先connectAdvanced
接收两个入参selectorFactory
和connectOptions
。
selectorFactory
selectorFactory
实际上内容存在于src/connect/selectorFactory.js
中,此处我们简单说下是做什么的,其实react-redux
作者也在源码中提供了注释:返回了一个提供了从新state
、props
、dispatch
变化中计算新props
能力的函数。
connectOptions
connectOptions
主要是一些对于该connect
生成函数的配置项:
- 计算生成获取HOC函数名的
getDisplayName
方法。 - HOC的方法名
methodName
,默认值为connectAdvanced
。 renderCountProp
,结合react devtools
分析是否有冗余的render
,默认值为undefined
。shouldHandleStateChanges
,决定当前HOC是否订阅store
变化,默认值为true
。storeKey
,props
和context
通过该key
值可以访问store
,默认值为store
。withRef
,旧版本的react
通过refs
访问,默认值为false
。forwardRef
,通过react
的forwardRef
API暴露ref
,默认值为false
。context
,Context API消费者使用的context
,默认值为前文我们生成的ReactReduxContext
。
ConnectFunction
代码主体内容,其实可以分为三部分:
- 传参校验
- 主体
ConnectFunction
方法实现 - HOC恢复处理(
displayName
及静态属性复制等)
1,3其实没什么好说的,我们主要看ConnectFunction
做了什么:
- 提取传入的
context
属性,因为除了react-redux
本身自定义初始化的属性外,用户自己也可能会传一个自定义的。获取ref
,及其余props
。 - 根据真实获取到的
context
进行后续判断使用。 - 判断
store
属性是否存在,会从props
和context
中尝试读取,若没有则抛出异常。 childPropsSelector
每次会在store
更新后重新生成一个selector
方法供相关取其作deps
的钩子进行新props
映射。subscription
订阅构成链式。
5中的subscription
订阅器构造链表结构我认为是connect
步骤中最核心的一步,没有它我们就无法完成整个状态树的更新。先看代码:
1 | const [subscription, notifyNestedSubs] = useMemo(() => { |
shouldHandleStateChanges
这个控制是否订阅的标记,实际上就是对齐了我们connect
组件时,如果仅是为了使用其dispatch
的能力,而不需要订阅store
中的值进行rerender
的:
1 | return connectHOC(selectorFactory, { |
核心就是看你有没有配置mapStateToProps
,所以我们平时使用要注意场景,无需订阅的,传null
就行。
1 | export default connect(null, { |
之后就是根据store
, didStoreComeFromProps
, contextValue
这些deps
进行subscription
实例的重新构造,其中有一个判断主要是区分store
是从props
数据源来的还是context
中获取的。然后提取实例方法notifyNestedSubs
绑定当前实例上下文,返回tuple
供后续的钩子使用。这里的钩子就是指前文中我们聊过的进行subscribeUpdates
回调的钩子,在里面会重新发起trySubscribe
订阅。
要了解subscription
是怎么产生关联的我们就要分析Subscription.js
文件了。
Subscription.js
Subscription.js
主要由两部分组成,一个是链表结构生成器,里面提供了基本的清空、通知调用、获取所有订阅内容以及订阅和取消订阅的方法。另外一部分就是暴露出去的Subscription
类,用于前者的调度操作。
createListenerCollection
该函数中实际上只有notify
和subscribe
方法值得我们留意一下:
1 | const batch = getBatch() |
notify
其实很简单,就是从头指针,遍历整个链表,执行所有回调。subscribe
则进行一个listener
实体的构造,由prev
、next
和callback
组成,是我们消息订阅节点的基本结构。另外它最终会返回一个取消当前节点订阅的句柄方法,供我们使用。细节结合Subscription
看会更清晰。
Subscription
Subscription
要理解其实要分初始化,及和后代子节点建立关联的情景来看:
1 | export default class Subscription { |
构造函数中主要是传入最新的store
内容及上一个订阅器节点parentSub
。核心代码主要是trySubscribe
方法,它做了这么几件事:
- 当该
subscription
实例未订阅时(通常都是初始化情况,也有可能被手动取消了订阅),判断是否有parentSub
传入,即确认下是否为根订阅器。 - 如果是根订阅器,则我们直接使用
react-redux
的store
自带的subscribe
方法进行订阅,这个方法会在store
内容改变时进行回调。同样这个store
自带的订阅方式也会返回一个移除监听的句柄。 - 如果非根订阅器,就会走我们的
createListenerCollection
构造的链表节点的subscribe
订阅方法,同样也会拿到取消订阅的句柄。这里要注意的是,如果存在父订阅器传入,实际上是在父订阅器上添加callback
。
总结
看到这里,实际上connect
要了解的代码也看得差不多了,我的疑惑是,我们不同组件的connect
是如何关联到一起的呢?下面细品一下:
- HOC中通过
useMemo
获取一个[subscription, notifyNestedSubs]
的tuple。每次deps
更新,其中的实例和方法会重新生成和绑定。 - 同样通过
useMemo
计算一个能够重载更新的context
值,功能主要是为了将1中生成的subscription
作为context
值关联进去:
1 | // Determine what {store, subscription} value should be put into nested context, if necessary, |
- 这个新的
context
值overriddenContextValue
会被我们最终connect
的组件所访问:
1 | // If React sees the exact same element reference as last time, it bails out of re-rendering |
contextValue
通过源码分析,我们可以得到它本质上是生成后的HOC传下来的属性props.context
。这里有同学就表示很迷惑了,我们不是只是connect
一个组件嘛,哪里传了这玩意?我们先按下不表,往后看。前文中我们知道在实体方法
ConnectFunction
中还进行了subscription
的更新。subscribeUpdates
回调中进行了订阅器subscription
上的listeners
链表节点的callback
更新。这个callback
做了什么呢?如果传入connect
组件的属性没有发生改变,会使用1中的notifyNestedSubs
,将链表上的callback
依次执行。否则就会更新属性并进行当前组件的rerender
。另外它除了绑定在当前subscription
的listeners
上外,钩子触发时,也会拉取一次,目的是在首次render
后拉取一次,以防这之间store
发生了改变。在阅读源码的过程中我发现了一个干扰因素,实际上就是
context
的入参问题,我们使用react-redux
时,显少会直接将context
作为props
往下传,所以内部的context
均视作ReactReduxContext
会使逻辑更为清晰。事实上,作为项目入口顶层的
Provider
已经将store
和根subscription
配置到了context
内,即我们组件内的useContext
可以直接读取,加以操作。
1 | // 通常项目入口 |
- 综上,我们发现实际上进行
connect
的组件,内部是一个独立的subscription
。在我们常规的使用场景下,store
这个值来源于顶层的ReactReduxContext.Provider
。于是我们在connect
的组件内部能读取到根的subscription
,并将其作为Subscription
构造函数的第二个入参,即parentSub
,传入当前connect
组件自身构造的subscription
实例当中。并且会调用trySubscribe
,将当前组件的checkForUpdates
绑定到根subscription
(或者说父级的)实例的listener
链表上,这样当上层发生变化时,通过notify
方法,就会遍历整个链表触发所有有子孙关系的组件进行rerender
(通过子组件自己的forceComponentUpdateDispatch
)。具体细节,前文也有了分析,比如当前组件会将自身的subscription
覆盖顶层在contextValue
中传下来的subscription
,并将重载后组装的contextValue
通过当前HOC中的Provider
传下去给我们connect
的组件。