使用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的forwardRefAPI暴露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的组件。
