数万字长文!2022最新 react-redux8 源码超详细深入解析:再读 react-redux 源码

react-redux 这个库想必熟悉 react 的人都不陌生,用一句话描述它就是:它作为『redux 这个框架无关的数据流管理库』和『react 这个视图库』的桥梁,使得 react 中能更新 redux 的 store,并能监听 store 的变化并通知 react 的相关组件更新,从而能让 react 将状态放在外部管理(有利于 model 集中管理,能利用 redux 单项数据流架构,数据流易预测易维护,也极大的方便了任意层级组件间通信等等好处)。

react-redux 版本来自截止 2022.02.28 时的最新版本 v8.0.0-beta.2(有点悲催的是,读源码的时候还是 7 版本,没想到刚读完git pull一下就升到 8 了,所以把 8 又看了一遍)

react-redux 8相比于 7 版本包括但不限于这些改变:

  • 全部用 typescript 重构

  • 原来的 Subscription class 被 createSubscription 重构,用闭包函数代替 class 的好处,讲到那部分代码的时候会提到。

  • 使用 React18 的useSyncExternalStore代替原来自己实现的订阅更新(原来内部是 useReducer),useSyncExternalStore以及它的前身useMutableSource解决了 concurrent 模式下的tearing问题,也让库本身的代码更简洁,useSyncExternalStore相比于前辈useMutableSource不用关心selector(这里说的是useSyncExternalStore的 selector,不是 react-redux)的 immutable 心智负担。


下面的部分和源码解析没有直接关系,但读了也能有所收获,也能明白为什么要写这篇文章。想直接看源码解析部分的可以跳转到React-Redux 源码解析部分

正文前的吹水阶段 1:既然是『再读』,那『首读』呢?

不知道大家平时在逛技术论坛的时候,有没有看见过类似这样的评论:redux 性能不好,mobx 更香……

喜欢刨根问底的人(比如我)看到了不禁想问更多问题:

  1. 究竟是 redux 性能不好还是 react-redux 性能不好?
  2. 具体不好在哪里?
  3. 能不能避免?

这些问题你问了,可能得到的也是三言两语,不够深入。与此同时还有一个问题, react-redux 是如何关联起 redux 和 react 的?这个问题倒是有不少源码解析的文章,我曾经看过一篇很详细的,不过很可惜是老版本的,还在用 class component,所以当时的我决定自己去看源码。当时属于是粗读,读完之后的简单总结就是 Provider 中有 Subscription 实例,connect 这个高阶组件中也有 Subscription 实例,并且有负责自身更新的 hooks: useReducer,useReducer 的 dispatch 会被注册进 Subscription 的 listeners,listeners 中有一个方法 notify 会遍历调用每个 listener,notify 会被注册给 redux 的 subscribe,从而 redux 的 state 更新后会通知给所有 connect 组件,当然每个 connect 都有检查自己是否需要更新的方法 checkForUpdates 来避免不必要的更新,具体细节就不说了。

总之,当时我只粗读了整体逻辑,但是可以解答我上面的问题了:

  1. react-redux 确实有可能性能不好。而至于 redux,每次 dispatch 都会让 state 去每个 reducer 走一遍,并且为了保证数据 immutable 也会有额外的创建复制开销。不过 mutable 阵营的库如果频繁修改对象也会导致 V8 的对象内存结构由顺序结构变成字典结构,查询速度降低,以及内联缓存变得高度超态,这点上 immutable 算拉回一点差距。不过为了一个清晰可靠的数据流架构,这种级别的开销在大部分场景算是值得,甚至忽略不计。

  2. react-redux 性能具体不好在哪里?因为每个 connect 不管需不需要更新都会被通知一次,开发者定义的 selector 都会被调用一遍甚至多遍,如果 selector 逻辑昂贵,还是会比较消耗性能的。

  3. 那么 react-redux 一定会性能不好吗?不一定,根据上面的分析,如果你的 selector 逻辑简单(或者将复杂派生计算都放在 redux 的 reducer 里,但是这样可能不利于构建一个合理的 model),connect 用的不多,那么性能并不会被 mobx 这样的细粒度更新拉开太多。也就是说 selector 里业务计算不复杂、使用全局状态管理的组件不多的情况下,完全不会有可感知的性能问题。那如果 selector 里面的业务计算复杂怎么办呢?能不能完全避免呢?当然可以,你可以用 reselect 这个库,它会缓存 selector 的结果,只有原始数据变化时才会重新计算派生数据。

这就是我的『首读』,我带着目的和问题去读源码,现在问题已经解决了,按理说一切都结束了,那么『再读』是因何而起的呢?

正文前的吹水阶段 2:为什么要『再读』?

前段时间我关注了一个 github 上的 React 状态管理库zustand

zustand 是一个非常时髦的基于 hooks 的状态管理库,基于简化的 flux 架构,也是 2021 年 Star 增长最快的 React 状态管理库。可以说是 redux + react-redux 的有力竞争者。

它的 github 开头是这样介绍的

image

大意是:它是一个小巧、快速、可扩展的、使用简化的 flux 架构的状态管理解决方案。有基于 hooks 的 api,使用起来十分舒适、人性化。 不要因为它很可爱而忽视它(貌似作者把它比喻成小熊了,封面图也是一个可爱的小熊)。它有很多的爪子,花了大量的时间去处理常见的陷阱,比如可怕的子代僵尸问题(zombie child problem),react 并发模式(react concurrency),以及使用 portals 时多个 render 之间的 context 丢失问题(context loss)。它可能是 React 领域中唯一一个能够正确处理所有这些问题的状态管理器。

里面讲到一个东西:zombie child problem。当我点进 zombie child problem 时,是 react-redux 的官方文档,让我们一起来看看这个问题是什么以及 react-redux 是如何解决的。想看原文可以直接点链接。

"Stale Props" and "Zombie Children"(过期 Props 和僵尸子节点问题)

自 v7.1.0 版本发布以后,react-redux 就可以使用 hooks api 了,官方也推荐使用 hooks 作为组件中的默认使用方法。但是有一些边缘情况可能会发生,这篇文档就是让我们意识到这些事的。

react-redux 实现中最难的地方之一就是:如果你的 mapStateToProps 是(state, ownProps)这样使用的,它将会每次被传入『最新的』props。一直到版本 4 都一直有边缘场景下的重复的 bug 被报告,比如:有一个列表 item 的数据被删除了,mapStateToProps 里面就报错了。

从版本 5 开始,react-redux 试图保证 ownProps 的一致性。在版本 7 里面,每个 connect()内部都有一个自定义的 Subscription 类,从而当 connect 里面又有 connect,它能形成一个嵌套的结构。这确保了树中更低层的 connect 组件只会在离它最近的祖先 connect 组件更新后才会接受到来自 store 的更新。然而,这个实现依赖于每个 connect()实例里面覆写了内部 React Context 的一部分(subscription 那部分),用它自身的 Subscription 实例用于嵌套。然后用这个新的 React Context ( <ReactReduxContext.Provider> ) 渲染子节点。

如果用 hooks,没有办法渲染一个 context.Provider,这就代表它不能让 subscriptions 有嵌套的结构。因为这一点,"stale props" 和 "zombie child" 问题可能在『用 hooks 代替 connect』 的应用里重新发生。

具体来说,"stale props" 会出现在这种场景:

  • selector 函数会根据这个组件的 props 计算出数据
  • 父组件会重新 render,并传给这个组件新的 props
  • 但是这个组件会在 props 更新之前就执行 selector(译者注:因为子组件的来自 store 的更新是在 useLayoutEffect/useEffect 中注册的,所以子组件先于父组件注册,redux 触发订阅会先触发子组件的更新方法)

这种旧的 props 和最新 store state 算出来的结果,很有可能是错误的,甚至会引起报错。

"Zombie child"具体是指在以下场景:

  • 多个嵌套的 connect 组件 mounted,子组件比父组件更早的注册到 store 上
  • 一个 action dispatch 了在 store 里删除数据的行为,比如一个 todo list 中的 item
  • 父组件在渲染的时候就会少一个 item 子组件
  • 但是,因为子组件是先被订阅的,它的 subscription 先于父组件。当它计算一个基于 store 和 props 计算的值时,部分数据可能已经不存在了,如果计算逻辑不注意的话就会报错。

useSelector()试图这样解决这个问题:它会捕获所有来自 store 更新导致的 selector 计算中的报错,当错误发生时,组件会强制更新,这时 selector 会再次执行。这个需要 selector 是个纯函数并且你没有逻辑依赖 selector 抛出错误。

如果你更喜欢自己处理,这里有一个可能有用的事项能帮助你在使用 useSelector() 时避免这些问题

  • 不要在 selector 的计算中依赖 props
  • 如果在:你必须要依赖 props 计算并且 props 将来可能发生变化、依赖的 store 数据可能会被删除,这两种情况下时,你要防备性的写 selector。不要直接像 state.todos[props.id].name 这样读取值,而是先读取 state.todos[props.id],验证它是否存在再读取 todo.name 因为 connect 向 context provider 增加了必要的 Subscription,它会延迟执行子 subscriptions 直到这个 connected 组件 re-rendered。组件树中如果有 connected 组件在使用 useSelector 的组件的上层,也可以避免这个问题,因为父 connect 有和 hooks 组件同样的 store 更新(译者注:父 connect 组件更新后才会更新子 hooks 组件,同时 connect 组件的更新会带动子节点更新,被删除的节点在此次父组件的更新中已经卸载了:因为上文中说 state.todos[props.id].name ,说明 hooks 组件是上层通过 ids 遍历出来的。于是后续来自 store 的子 hooks 组件更新不会有被删除的)

以上的解释可能让大家明白了 "Stale Props" 和 "Zombie Children" 问题是如何产生的以及 react-redux 大概是怎么解决的,就是通过子代 connect 的更新被嵌套收集到父级 connect,每次 redux 更新并不是遍历更新所有 connect,而是父级先更新,然后子代由父级更新后才触发更新。但是似乎 hooks 的出现让它并不能完美解决问题了,而且具体这些设计的细节也没有说到。这部分的疑惑和缺失就是我准备再读 react-redux 源码的原因。

React-Redux 源码解析

react-redux 版本来自截止 2022.02.28 时的最新版本 v8.0.0-beta.2

阅读源码期间在 fork 的 react-redux 项目中写下了一些中文注释,作为一个新项目放在了react-redux-with-comment仓库,阅读文章需要对照源码的可以看一下,版本是 8.0.0-beta.2

在讲具体细节之前我想先说一下总体的抽象设计,让大家心中带着设计蓝图去读其中的细节,否则只看细节很难让它们之间串联起来明白它们是如何共同协作完成整个功能的。

React-Redux 的 Provider 和 connect 都提供了自己的贯穿子树的 context,它们的所有的子节点都可以拿到它们,并会将自己的更新方法交给它们。最终形成了根 <-- 父 <-- 子这样的收集顺序。根收集的更新方法会由 redux 触发,父收集的更新方法在父更新后再更新,于是保证了父节点被 redux 更新后子节点才更新的顺序。

react-redux过程抽象图

简单的宏观设计就如上所示,初次看不能理解的很深入,不过没关系,多看几遍源码和源码分析后再回过头看看这里会有新的收获。

首先从项目构建入口看起

image

可以看出它的 umd 包是通过 rollup 构建的(build:umdbuild:umd:min),esm 和 commonjs 包是通过 babel 编译输出的(build:commonjsbuild:es)。我们只看build:es"babel src --extensions \".js,.ts,.tsx\" --out-dir es"。意思是使用 babel 转换 src 目录下的.js,.ts,.tsx文件并输出到 es 目录(这一点和业务项目有些区别,因为 npm 包并不需要打包为一个文件,否则安装的不同 npm 包之间可能会打包进重复依赖,每个文件依然保持 import 引入只是内容编译就可以了,最终在开发者的项目里会把它们构建到一起的)。

下面看一下.babelrc.js 做了什么

image

可以看到 babel 的 presets 中的@babel/preset-typescript负责将 ts 编译为 js,@babel/preset-env负责将 ECMA 最新语法编译为 es5(只能编译 syntax,api 需要额外插件)。关于 babel 的 plugins,@babel/transform-modules-commonjs解决了 babel 重复 helper 的问题,可以按需引入统一的 corejs 库中的 api polyfill,这里是通过 useESModules 的配置来决定采用 esm 还是 commonjs 的 helper,但官方文档中在 7.13.0 开始已经废弃这个配置了,可以直接通过package.jsonexports来判断。其他的 plugin 也都是和语法编译相关的,比如私有方法、私有属性、静态属性、jsx、装饰器等语法的编译,以及@babel/plugin-transform-modules-commonjs这个将 esm 引入语法编译为 commonjs 的库,由环境变量 NODE_ENV 决定是否使用,它决定了最终输出的是 esm 库还是 commonjs 库。

image

根据 package.json 的 module 字段(关于 main、module、browser 字段的优先级),最终入口是根目录下的 es/index.js,由于它是由 babel 根据源目录输出的,所以源代码入口就是src/index.ts

image

从常用的 api 切入

从上图可以看出,入口文件的输出只有batchexports.ts文件的全部 export,所以我们去看 exports.ts

image

其中的ProviderconnectuseSelectoruseDispatch占据了我们平时使用的大部分场景,所以我们从这 4 个 api 切入。

Provider

image

Provider来自src/components/Provider.tsx

它是一个 React 组件,本身并没有任何视图内容,最终展示的是 children,只不过给 children 外面加了一层 Context Provider,这也是这个 api 为什么叫 Provider 的原因。那具体这个组件想往下面透传什么呢。

const contextValue = useMemo(() => {
  const subscription = createSubscription(store);
  return {
    store,
    subscription,
    getServerState: serverState ? () => serverState : undefined,
  };
}, [store, serverState]);
复制代码

可以看到透传的是一个由storesubscriptiongetServerState组成的对象。下面分别讲一下对象的 3 个属性作用。

store是 redux 的 store,是开发者通过 store prop 传给 Provider 组件的。

subscription 是由 createSubscription 这个对象工厂创建的,它生成了 subscription 对象,它是后续嵌套收集订阅的关键。关于 createSubscription 的代码细节后面会说。

getServerState是 8.0.0 版本新加的,它用于在 SSR 中,当初始『注水』hydrate 时获取服务器端状态快照的,以便保证两端状态一致性。它的控制权完全在开发者,只要把状态快照通过 serverState 这个 prop 给 Provider 组件即可。不了解 SSR、hydrate 相关概念的可以去读一下 Dan Abramov 的一篇discussions,虽然它的主题不是专门讲 SSR 的,但是开头介绍了它的相关概念,而且 Dan 的文章一向形象而通俗易懂。

Provider 组件紧接着做的事情是:

const previousState = useMemo(() => store.getState(), [store]);

useIsomorphicLayoutEffect(() => {
  const { subscription } = contextValue;
  subscription.onStateChange = subscription.notifyNestedSubs;
  subscription.trySubscribe();

  if (previousState !== store.getState()) {
    subscription.notifyNestedSubs();
  }
  return () => {
    subscription.tryUnsubscribe();
    subscription.onStateChange = undefined;
  };
}, [contextValue, previousState]);

const Context = context || ReactReduxContext;

return <Context.Provider value={contextValue}>{children}</Context.Provider>;
复制代码

获取了一次最新 state 并命名为 previousState,只要 store 单例不发生变化,它是不会更新的。一般项目中也不太会改变 redux 单例。

useIsomorphicLayoutEffect 只是一个 facade,从 isomorphic 的命名也可以看出它是和同构相关的。它内部会在 server 环境时使用 useEffect,在浏览器环境时使用 useLayoutEffect

它的代码很简单:

import { useEffect, useLayoutEffect } from "react";

// React currently throws a warning when using useLayoutEffect on the server.
// To get around it, we can conditionally useEffect on the server (no-op) and
// useLayoutEffect in the browser. We need useLayoutEffect to ensure the store
// subscription callback always has the selector from the latest render commit
// available, otherwise a store update may happen between render and the effect,
// which may cause missed updates; we also must ensure the store subscription
// is created synchronously, otherwise a store update may occur before the
// subscription is created and an inconsistent state may be observed

// Matches logic in React's `shared/ExecutionEnvironment` file
export const canUseDOM = !!(
  typeof window !== "undefined" &&
  typeof window.document !== "undefined" &&
  typeof window.document.createElement !== "undefined"
);

export const useIsomorphicLayoutEffect = canUseDOM
  ? useLayoutEffect
  : useEffect;
复制代码

但是这样做的原因并不简单:首先,在服务端使用 useLayoutEffect 会抛出警告,为了绕过它于是在服务端转而使用 useEffect。其次,为什么一定要在 useLayoutEffect/useEffect 里面做?因为一个 store 更新可能发生在 render 阶段和副作用阶段之间,如果在 render 时就做,可能会错过更新,必须要确保 store subscription 的回调拥有来自最新更新的 selector。同时还要确保 store subscription 的创建必须是同步的,否则一个 store 更新可能发生在订阅之前(如果订阅是异步的话),这时订阅还没有被创建,从而有了不一致的状态。

如果原因看了不是很明白,结合下面的例子就明白了。

Provider 在 useIsomorphicLayoutEffect 里做了这样的事:

subscription.trySubscribe();

if (previousState !== store.getState()) {
  subscription.notifyNestedSubs();
}
复制代码

首先收集 subscription 的订阅,然后看最新的状态和之前在 render 的状态是否一致,如果不一致则通知更新。如果这一段不放在 useLayoutEffect/useEffect 里,而是放在 render 里,那么现在仅仅订阅了它自己,它的子组件并没有订阅,如果子组件在渲染过程中更新了 redux store,那么子组件们就错过了更新通知。同时 react 的 useLayoutEffect/useEffect 是自下而上调用的,子组件的先调用,父组件的后调用。这里由于是 react-redux 的根节点了,它的 useLayoutEffect/useEffect 会在最后被调用,这时能确保子组件该注册订阅的都注册了,同时也能确保子组件渲染过程中可能发生的更新都已经发生了。所以再最后读取一次 state,比较一下是否要通知它们更新。这就是为什么要选择 useLayoutEffect/useEffect。

接下来我们完整的看一下 Provider 在 useIsomorphicLayoutEffect 中做的事情

useIsomorphicLayoutEffect(() => {
  const { subscription } = contextValue;
  subscription.onStateChange = subscription.notifyNestedSubs;
  subscription.trySubscribe();

  if (previousState !== store.getState()) {
    subscription.notifyNestedSubs();
  }
  return () => {
    subscription.tryUnsubscribe();
    subscription.onStateChange = undefined;
  };
}, [contextValue, previousState]);
复制代码

首先是设置 subscription 的 onStateChange(它初始是个空方法,需要注入实现),它会在触发更新时调用,它这里希望将来调用的是subscription.notifyNestedSubssubscription.notifyNestedSubs会触发这个 subscription 收集的所有子订阅。也就是说这里的更新回调和『更新』没有直接关系,而是触发子节点们的更新方法。

然后调用了subscription.trySubscribe(),它会将自己的 onStateChange 交给父级 subscription 或者 redux 去订阅,将来由它们触发 onStateChange

最后它会判断之前的 state 和最新的是否一致,如果不一致会调用subscription.notifyNestedSubs(),它会触发这个 subscription 收集的所有子订阅从而更新它们。

返回了注销相关的函数,它会注销在父级的订阅,将subscription.onStateChange重新置为空方法。这个函数会在组件卸载或 re-render (仅 store 变化时)时被调用(react useEffect 的特性)。

Provider 有很多地方都涉及到了 subscription,subscription 的那些方法只是讲了大概功能,关于 subscription 的细节会在后面 subscription 的部分讲到。

完整的Provider源码和注释如下:

function Provider<A extends Action = AnyAction>({
  store,
  context,
  children,
  serverState,
}: ProviderProps<A>) {
  // 生成了一个用于context透传的对象,包含redux store、subscription实例、SSR时可能用到的函数
  const contextValue = useMemo(() => {
    const subscription = createSubscription(store);
    return {
      store,
      subscription,
      getServerState: serverState ? () => serverState : undefined,
    };
  }, [store, serverState]);

  // 获取一次当前的redux state,因为后续子节点的渲染可能会修改state,所以它叫previousState
  const previousState = useMemo(() => store.getState(), [store]);

  // 在useLayoutEffect或useEffect中
  useIsomorphicLayoutEffect(() => {
    const { subscription } = contextValue;
    // 设置subscription的onStateChange方法
    subscription.onStateChange = subscription.notifyNestedSubs;
    // 将subscription的更新回调订阅给父级,这里会订阅给redux
    subscription.trySubscribe();

    // 判断state经过渲染后是否变化,如果变化则触发所有子订阅更新
    if (previousState !== store.getState()) {
      subscription.notifyNestedSubs();
    }
    // 组件卸载时的注销操作
    return () => {
      subscription.tryUnsubscribe();
      subscription.onStateChange = undefined;
    };
  }, [contextValue, previousState]);

  const Context = context || ReactReduxContext;

  // 最终Provider组件只是为了将contextValue透传下去,组件UI完全使用children
  return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}
复制代码

总结一下 Provider 其实很简单,Provider 组件只是为了将 contextValue 透传下去,让子组件能够拿到 redux store、subscription 实例、服务器端状态函数。

Subscription/createSubscription 订阅工厂函数

这里会讲到 Provider 中出镜率很高的 subscription 部分,它是 react-redux 能够嵌套收集订阅的关键。其实这个部分的标题叫做 Subscription 已经不太合适了,在 8.0.0 版本之前,react-redux 确实是通过 Subscription class 实现它的,你可以通过new Subscription()使用创建 subscription 实例。但在 8.0.0 之后,已经变成了createSubscription函数创建 subscription 对象,内部用闭包替代原先的属性。

用函数替代 class 有一个好处是,不需要关心 this 的指向,函数返回的方法修改的永远是内部的闭包,不会出现 class 方法被赋值给其他变量后出现 this 指向变化的问题,降低了开发时的心智负担。闭包也更加私有化,增加了变量安全。同时在一个支持 hooks 的库里,用函数实现也更符合开发范式。

下面我们先看一下 createSubscription 抽象后的代码,每个的职责都写在注释里了

注:下文出现的『订阅回调』具体是指,redux 状态更新后触发的组件的更新方法。组件更新方法被父级订阅收集,是订阅发布模式。

function createSubscription(store: any, parentSub?: Subscription) {
  // 自己是否被订阅的标志
  let unsubscribe: VoidFunc | undefined;
  // 负责收集订阅的收集器
  let listeners: ListenerCollection = nullListeners;

  // 收集订阅
  function addNestedSub(listener: () => void) {}

  // 通知订阅
  function notifyNestedSubs() {}

  // 自己的订阅回调
  function handleChangeWrapper() {}

  // 判断自己是否被订阅
  function isSubscribed() {}

  // 让自己被父级订阅
  function trySubscribe() {}

  // 从父级注销自己的订阅
  function tryUnsubscribe() {}

  const subscription: Subscription = {
    addNestedSub,
    notifyNestedSubs,
    handleChangeWrapper,
    isSubscribed,
    trySubscribe,
    tryUnsubscribe,
    getListeners: () => listeners,
  };

  return subscription;
}
复制代码

createSubscription函数是一个对象工厂,它定义了一些变量和方法,然后返回一个拥有这些方法的对象subscription

首先看一下 handleChangeWrapper,通过名字可以看出它只是一个外壳

function handleChangeWrapper() {
  if (subscription.onStateChange) {
    subscription.onStateChange();
  }
}
复制代码

其内部实际调用了onStateChange方法。究其原因是因为在订阅回调被父级收集时,可能自己的回调还没有确定,所以定义了一个外壳用于被收集,内部的回调方法在确定时会被重置,但外壳的引用不变,所以将来依然可以触发回调。这也是为什么在Provider.ts的源码里,在收集订阅之前先做一下subscription.onStateChange = subscription.notifyNestedSubs的原因。

然后看 trySubscribe

function trySubscribe() {
  if (!unsubscribe) {
    unsubscribe = parentSub
      ? parentSub.addNestedSub(handleChangeWrapper)
      : store.subscribe(handleChangeWrapper);

    listeners = createListenerCollection();
  }
}
复制代码

它的作用是让父级的 subscription 收集自己的订阅回调。首先它会判断如果unsubscribe标志了它已经被订阅了,那么不做任何事。其次它会判断当时创建subscription时的第二个参数parentSub是否为空,如果有parentSub则代表它上层有父级subscription,那么它会调用父级的addNestedSub方法,将自己的订阅回调注册给它;否则则认为自己在顶层,所以注册给 redux store。

由此引申到需要看看addNestedSub方法是什么

function addNestedSub(listener: () => void) {
  trySubscribe();
  return listeners.subscribe(listener);
}
复制代码

addNestedSub非常巧妙的运用了递归,它里面又调用了trySubscribe。于是它们就会达到这样的目的,当最底层subscription发起trySubscribe想被父级收集订阅时,它会首先触发父级的trySubscribe并继续递归直到根subscription,如果我们把这样的层级结构想象成树的话(其实 subscription.trySubscribe 也确实发生在组件树中),那么就相当于从根节点到叶子节点依次会被父级收集订阅。因为这是由叶子节点先发起的,这时除了叶子节点,其他节点的订阅回调还没有被设置,所以才设计了handleChangeWrapper这个回调外壳,注册的只是这个回调外壳,在将来非叶子节点设置好回调后,能被外壳触发。

在『递』过程结束后,从根节点开始到这个叶子节点的订阅回调handleChangeWrapper都正在被父级收集了,『归』的过程回溯做它的本职工作return listeners.subscribe(listener),将子subscription的订阅回调收集到收集器listeners中(将来更新发生时会触发相关的handleChangeWrapper,而它会间接的调用收集到所有的 listener)。

所以每个subscriptionaddNestedSub都做了两件事:1. 让自己的订阅回调先被父级收集;2. 收集子subscription的订阅回调。

结合addNestedSub的解释再回过头来看trySubscribe,它想让自己的订阅回调被父级收集,于是当它被传入父级subscription时,就会调用它的addNestedSub,这会导致从根subscription开始每一层subscription都被父级收集了回调,于是每个subscription都嵌套收集了它们子subscription,从而父级更新后子级才更新成为了可能。同时,因为unsubscribe这个锁的存在,如果某个父级subscriptiontrySubscribe被调用了,并不会重复的触发这个『嵌套注册』。

上面我们分析了『嵌套注册』时发生了什么,下面我们看看注册的实质性操作listeners.subscribe干了什么,注册的数据结构又是如何设计的。

function createListenerCollection() {
  const batch = getBatch();
  // 对listener的收集,listener是一个双向链表
  let first: Listener | null = null;
  let last: Listener | null = null;

  return {
    clear() {
      first = null;
      last = null;
    },

    // 触发链表所有节点的回调
    notify() {
      batch(() => {
        let listener = first;
        while (listener) {
          listener.callback();
          listener = listener.next;
        }
      });
    },

    // 以数组的形式返回所有节点
    get() {
      let listeners: Listener[] = [];
      let listener = first;
      while (listener) {
        listeners.push(listener);
        listener = listener.next;
      }
      return listeners;
    },

    // 向链表末尾添加节点,并返回一个删除该节点的undo函数
    subscribe(callback: () => void) {
      let isSubscribed = true;

      let listener: Listener = (last = {
        callback,
        next: null,
        prev: last,
      });

      if (listener.prev) {
        listener.prev.next = listener;
      } else {
        first = listener;
      }

      return function unsubscribe() {
        if (!isSubscribed || first === null) return;
        isSubscribed = false;

        if (listener.next) {
          listener.next.prev = listener.prev;
        } else {
          last = listener.prev;
        }
        if (listener.prev) {
          listener.prev.next = listener.next;
        } else {
          first = listener.next;
        }
      };
    },
  };
}
复制代码

listeners对象是由createListenerCollection创建的。listeners方法不多且逻辑易懂,是由clearnotifygetsubscribe组成的。

listeners 负责收集 listener(也就是订阅回调) ,listeners 内部将 listener 维护成了一个双向链表,头结点是first,尾节点是last

clear方法如下:

clear() {
  first = null
  last = null
}
复制代码

用于清空收集的链表

notify方法如下:

notify() {
  batch(() => {
    let listener = first
    while (listener) {
      listener.callback()
      listener = listener.next
    }
  })
}
复制代码

用于遍历调用链表节点,batch这里可以简单的理解为调用入参的那个函数,其中的细节可以衍生出很多 React 原理(如批量更新、fiber 等),放在文章的最后说。

get方法如下:

get() {
  let listeners: Listener[] = []
  let listener = first
  while (listener) {
    listeners.push(listener)
    listener = listener.next
  }
  return listeners
}
复制代码

用于将链表节点转为数组的形式并返回

subscribe方法如下:

subscribe(callback: () => void) {
  let isSubscribed = true

  // 创建一个链表节点
  let listener: Listener = (last = {
    callback,
    next: null,
    prev: last,
  })

  // 如果链表已经有了节点
  if (listener.prev) {
    listener.prev.next = listener
  } else {
    // 如果链表还没有节点,它则是首节点
    first = listener
  }

  // unsubscribe就是个双向链表的删除指定节点操作
  return function unsubscribe() {
    // 阻止无意义执行
    if (!isSubscribed || first === null) return
    isSubscribed = false

    // 如果添加的这个节点已经有了后续节点
    if (listener.next) {
      // next的prev应该为该节点的prev
      listener.next.prev = listener.prev
    } else {
      // 没有则说明该节点是最后一个,将prev节点作为last节点
      last = listener.prev
    }
    // 如果有前节点prev
    if (listener.prev) {
      // prev的next应该为该节点的next
      listener.prev.next = listener.next
    } else {
      // 否则说明该节点是第一个,把它的next给first
      first = listener.next
    }
  }
}
复制代码

用于向 listeners 链表添加一个订阅以及返回一个注销订阅的函数,涉及链表的增删操作,具体看注释即可。

所以每个subscription收集订阅实则是维护了一个双向链表。

subscription最后需要说的的部分只有notifyNestedSubstryUnsubscribe

notifyNestedSubs() {
  this.listeners.notify()
}

tryUnsubscribe() {
  if (this.unsubscribe) {
    this.unsubscribe()
    this.unsubscribe = null
    this.listeners.clear()
    this.listeners = nullListeners
  }
}
复制代码

notifyNestedSubs调用了listeners.notify,根据上面有关 listeners 的分析,这里会遍历调用所有的订阅

tryUnsubscribe则是进行注销相关的操作,this.unsubscribetrySubscribe方法的执行中被注入值了,它是addNestedSub或者redux subscribe函数的返回值,是取消订阅的 undo 操作。在this.unsubscribe()之下的分别是清除unsubscribe、清除listeners操作。

至此subscription就分析完了,它主要用于在嵌套调用时,可以嵌套收集订阅,以此做到父级更新后才执行子节点的订阅回调从而在父级更新之后更新。不太清楚 react-redux 的人可能会疑惑,不是只有Provider组件使用了subscription吗,哪里来的嵌套调用?哪里来的收集子订阅?不要着急,后续讲到connect高阶函数,它里面也用到了subscription,就是这里嵌套使用的。

connect 高阶组件

8.0.0 开始由connect.tsx代替connectAdvanced.js,本质上都是多层高阶函数,但重构后的connect.tsx结构显得更加清晰直观。

我们都知道在使用 connect 的时候都是:connect(mapStateToProps, mapDispatchToProps, mergeProps, connectOptions)(Component),因此它入口应该是接收mapStateToPropsmapDispatchToProps等参数,返回一个接收Component参数的高阶函数,这个函数最终返回JSX.Element

如果简单看 connect 的结构就如下所示:

function connect(
  mapStateToProps,
  mapDispatchToProps,
  mergeProps,
  {
    pure,
    areStatesEqual,
    areOwnPropsEqual,
    areStatePropsEqual,
    areMergedPropsEqual,
    forwardRef,
    context,
  }
) {
  const wrapWithConnect = (WrappedComponent) => {
    return <WrappedComponent />;
  };
  return wrapWithConnect;
}
复制代码

如果把 connect 做的事情分解的话,我认为有这几块:向父级订阅自己的更新、从 redux store select 数据、判断是否需要更新等其他细节

connect 的 selector

const initMapStateToProps = match(
  mapStateToProps,
  // @ts-ignore
  defaultMapStateToPropsFactories,
  "mapStateToProps"
)!;
const initMapDispatchToProps = match(
  mapDispatchToProps,
  // @ts-ignore
  defaultMapDispatchToPropsFactories,
  "mapDispatchToProps"
)!;
const initMergeProps = match(
  mergeProps,
  // @ts-ignore
  defaultMergePropsFactories,
  "mergeProps"
)!;

const selectorFactoryOptions: SelectorFactoryOptions<any, any, any, any> = {
  pure,
  shouldHandleStateChanges,
  displayName,
  wrappedComponentName,
  WrappedComponent,
  initMapStateToProps,
  initMapDispatchToProps,
  // @ts-ignore
  initMergeProps,
  areStatesEqual,
  areStatePropsEqual,
  areOwnPropsEqual,
  areMergedPropsEqual,
};

const childPropsSelector = useMemo(() => {
  // The child props selector needs the store reference as an input.
  // Re-create this selector whenever the store changes.
  return defaultSelectorFactory(store.dispatch, selectorFactoryOptions);
}, [store]);

const actualChildPropsSelector = childPropsSelector(
  store.getState(),
  wrapperProps
);
复制代码

match函数是首个需要被分析的

function match<T>(
  arg: unknown,
  factories: ((value: unknown) => T)[],
  name: string
): T {
  for (let i = factories.length - 1; i >= 0; i--) {
    const result = factories[i](arg);
    if (result) return result;
  }

  return ((dispatch: Dispatch, options: { wrappedComponentName: string }) => {
    throw new Error(
      `Invalid value of type ${typeof arg} for ${name} argument when connecting component ${
        options.wrappedComponentName
      }.`
    );
  }) as any;
}
复制代码

factories作为一个工厂数组,会被传入arg参数遍历调用,每个工厂都会检测处理arg,而这里的arg就是我们开发中写的mapStateToPropsmapDispatchToPropsmergeProps,直到factories[i](arg)有值才会 return,如果一直都不是 truly 值,则会报错。factories就像责任链模式一样,属于自己的工厂职责就会处理并返回。

factoriesinitMapStateToPropsinitMapDispatchToPropsinitMergeProps中是不同的,分别是defaultMapStateToPropsFactoriesdefaultMapDispatchToPropsFactoriesdefaultMergePropsFactories,我们来看看它们是什么。

// defaultMapStateToPropsFactories

function whenMapStateToPropsIsFunction(mapStateToProps?: MapToProps) {
  return typeof mapStateToProps === "function"
    ? wrapMapToPropsFunc(mapStateToProps, "mapStateToProps")
    : undefined;
}

function whenMapStateToPropsIsMissing(mapStateToProps?: MapToProps) {
  return !mapStateToProps ? wrapMapToPropsConstant(() => ({})) : undefined;
}

const defaultMapStateToPropsFactories = [
  whenMapStateToPropsIsFunction,
  whenMapStateToPropsIsMissing,
];
复制代码

遍历defaultMapStateToPropsFactories是调用了whenMapStateToPropsIsFunctionwhenMapStateToPropsIsMissing这两个工厂,由名字可以看出第一个是当mapStateToProps是函数时处理,第二个是省略mapStateToProps时处理。

里面的wrapMapToPropsFunc函数(即whenMapStateToPropsIsFunction)将 mapToProps 包装在一个代理函数中,它做了几件事:

  1. 检测被调用的 mapToProps 函数是否依赖于 props,其中 selectorFactory 使用它来决定它是否应该在 props 更改时重新调用。
  2. 在第一次调用时,如果mapToProps返回另一个函数,则处理 mapToProps,并处理把新函数作为后续调用的真正 mapToProps。
  3. 在第一次调用时,验证结果是否为一个平层的对象,以警告开发人员的 mapToProps 函数未返回有效结果。

wrapMapToPropsConstant函数(即whenMapStateToPropsIsMissing)在缺省时将来会返回空对象(并不是立即返回,返回的是高阶函数),有值时期望那个值是函数,将dispatch传入函数,最后返回这个函数的返回值(同样不是立即返回)

另外两个工厂组defaultMapDispatchToPropsFactoriesdefaultMergePropsFactories,职责和defaultMapStateToPropsFactories一样,本质上就是负责处理不同 case 时的arg

const defaultMapDispatchToPropsFactories = [
  whenMapDispatchToPropsIsFunction,
  whenMapDispatchToPropsIsMissing,
  whenMapDispatchToPropsIsObject,
];

const defaultMergePropsFactories = [
  whenMergePropsIsFunction,
  whenMergePropsIsOmitted,
];
复制代码

相信大家通过名字也能大概猜出它们负责什么,就不一一细说了。

经过match处理后,返回了initMapStateToPropsinitMapDispatchToPropsinitMergeProps这 3 个高阶函数 ,最终这些函数的目的是返回 select 的值

const selectorFactoryOptions: SelectorFactoryOptions<any, any, any, any> = {
  pure,
  shouldHandleStateChanges,
  displayName,
  wrappedComponentName,
  WrappedComponent,
  initMapStateToProps,
  initMapDispatchToProps,
  // @ts-ignore
  initMergeProps,
  areStatesEqual,
  areStatePropsEqual,
  areOwnPropsEqual,
  areMergedPropsEqual,
};
复制代码

它们以及其他属性组成名为selectorFactoryOptions的对象

最终交给defaultSelectorFactory使用

const childPropsSelector = useMemo(() => {
  // The child props selector needs the store reference as an input.
  // Re-create this selector whenever the store changes.
  return defaultSelectorFactory(store.dispatch, selectorFactoryOptions);
}, [store]);
复制代码

childPropsSelector就是最终返回真正需要值的函数(它真的是高阶函数的终点了~)

所以最后只需要看defaultSelectorFactory函数做了什么,它实际叫finalPropsSelectorFactory

export default function finalPropsSelectorFactory<
  TStateProps,
  TOwnProps,
  TDispatchProps,
  TMergedProps,
  State = DefaultRootState
>(
  dispatch: Dispatch<Action>,
  {
    initMapStateToProps,
    initMapDispatchToProps,
    initMergeProps,
    ...options
  }: SelectorFactoryOptions<
    TStateProps,
    TOwnProps,
    TDispatchProps,
    TMergedProps,
    State
  >
) {
  const mapStateToProps = initMapStateToProps(dispatch, options);
  const mapDispatchToProps = initMapDispatchToProps(dispatch, options);
  const mergeProps = initMergeProps(dispatch, options);

  if (process.env.NODE_ENV !== "production") {
    verifySubselectors(mapStateToProps, mapDispatchToProps, mergeProps);
  }

  return pureFinalPropsSelectorFactory<
    TStateProps,
    TOwnProps,
    TDispatchProps,
    TMergedProps,
    State
    // @ts-ignore
  >(mapStateToProps!, mapDispatchToProps, mergeProps, dispatch, options);
}
复制代码

mapStateToPropsmapDispatchToPropsmergeProps是会返回各自最终值的函数。更多应该关注的重点是pureFinalPropsSelectorFactory函数

export function pureFinalPropsSelectorFactory<
  TStateProps,
  TOwnProps,
  TDispatchProps,
  TMergedProps,
  State = DefaultRootState
>(
  mapStateToProps: MapStateToPropsParam<TStateProps, TOwnProps, State> & {
    dependsOnOwnProps: boolean;
  },
  mapDispatchToProps: MapDispatchToPropsParam<TDispatchProps, TOwnProps> & {
    dependsOnOwnProps: boolean;
  },
  mergeProps: MergeProps<TStateProps, TDispatchProps, TOwnProps, TMergedProps>,
  dispatch: Dispatch,
  {
    areStatesEqual,
    areOwnPropsEqual,
    areStatePropsEqual,
  }: PureSelectorFactoryComparisonOptions<TOwnProps, State>
) {
  let hasRunAtLeastOnce = false;
  let state: State;
  let ownProps: TOwnProps;
  let stateProps: TStateProps;
  let dispatchProps: TDispatchProps;
  let mergedProps: TMergedProps;

  function handleFirstCall(firstState: State, firstOwnProps: TOwnProps) {
    state = firstState;
    ownProps = firstOwnProps;
    // @ts-ignore
    stateProps = mapStateToProps!(state, ownProps);
    // @ts-ignore
    dispatchProps = mapDispatchToProps!(dispatch, ownProps);
    mergedProps = mergeProps(stateProps, dispatchProps, ownProps);
    hasRunAtLeastOnce = true;
    return mergedProps;
  }

  function handleNewPropsAndNewState() {
    // @ts-ignore
    stateProps = mapStateToProps!(state, ownProps);

    if (mapDispatchToProps!.dependsOnOwnProps)
      // @ts-ignore
      dispatchProps = mapDispatchToProps(dispatch, ownProps);

    mergedProps = mergeProps(stateProps, dispatchProps, ownProps);
    return mergedProps;
  }

  function handleNewProps() {
    if (mapStateToProps!.dependsOnOwnProps)
      // @ts-ignore
      stateProps = mapStateToProps!(state, ownProps);

    if (mapDispatchToProps.dependsOnOwnProps)
      // @ts-ignore
      dispatchProps = mapDispatchToProps(dispatch, ownProps);

    mergedProps = mergeProps(stateProps, dispatchProps, ownProps);
    return mergedProps;
  }

  function handleNewState() {
    const nextStateProps = mapStateToProps(state, ownProps);
    const statePropsChanged = !areStatePropsEqual(nextStateProps, stateProps);
    // @ts-ignore
    stateProps = nextStateProps;

    if (statePropsChanged)
      mergedProps = mergeProps(stateProps, dispatchProps, ownProps);

    return mergedProps;
  }

  function handleSubsequentCalls(nextState: State, nextOwnProps: TOwnProps) {
    const propsChanged = !areOwnPropsEqual(nextOwnProps, ownProps);
    const stateChanged = !areStatesEqual(nextState, state);
    state = nextState;
    ownProps = nextOwnProps;

    if (propsChanged && stateChanged) return handleNewPropsAndNewState();
    if (propsChanged) return handleNewProps();
    if (stateChanged) return handleNewState();
    return mergedProps;
  }

  return function pureFinalPropsSelector(
    nextState: State,
    nextOwnProps: TOwnProps
  ) {
    return hasRunAtLeastOnce
      ? handleSubsequentCalls(nextState, nextOwnProps)
      : handleFirstCall(nextState, nextOwnProps);
  };
}
复制代码

它的闭包hasRunAtLeastOnce用以区分是否首次调用,首次和后续是不同的函数,如果是首次调用则是使用handleSubsequentCalls函数,它里面产生stateProps、产生dispatchProps,然后将它们放入mergeProps计算出最终的 props,同时把hasRunAtLeastOnce设置为true,表示已经不是第一次执行了。

后续调用都走handleSubsequentCalls,它的主要目的是如果 state 和 props 都没有变化则使用缓存数据(state、props 是否变化的判断方法是外部传进来的,组件当然能知道自己有没有变化),如果 state、props 都有变化或者只是其中一个有变化,再分别调用各自的函数(里面主要是根据静态属性dependsOnOwnProps判断是否要重新执行)得到新值。

于是childPropsSelector函数就是返回的pureFinalPropsSelector函数,内部访问了闭包,闭包保存了持久值,从而在组件多次执行的情况下,可以决定是否需要使用缓存来优化性能。

selector 相关的分析完了。

总的来说,如果想实现一个最简单的selector,只需要

const selector = (state, ownProps) => {
  const stateProps = mapStateToProps(reduxState);
  const dispatchProps = mapDispatchToProps(reduxDispatch);
  const actualChildProps = mergeProps(stateProps, dispatchProps, ownProps);
  return actualChildProps;
};
复制代码

那为什么 react-redux 会写的如此复杂呢。就是为了connect组件在多次执行时能利用细粒度缓存的 mergedProps 值提升性能,React 只能做到在wrapperProps不变时使用 memo,但难以做更细粒度的区分,比如知道 selector 是否依赖 props,从而就算 props 变化了也不需要更新。要实现这一点需要大量嵌套的高阶函数储存持久化的闭包中间值,才能在组件多次执行时不丢失状态从而判断更新。

下面我们准备讲点别的了,如果你对一系列调用栈有点头晕,你只要记住看到了childPropsSelector就是返回 selector 后的值就好了。

connect 更新的注册订阅

function ConnectFunction<TOwnProps>(props: InternalConnectProps & TOwnProps) {
  const [propsContext, reactReduxForwardedRef, wrapperProps] = useMemo(() => {
    const { reactReduxForwardedRef, ...wrapperProps } = props;
    return [props.context, reactReduxForwardedRef, wrapperProps];
  }, [props]);

  // …………
  // …………
}
复制代码

首先从 props 里划分出了实际业务 props 和行为控制相关的 props,所谓的业务 props 就是指项目中的父级组件实际传给 connect 组件的 props,行为控制 props 则是 forward ref、context 等业务无关的、和内部注册订阅有关的 props。并且使用 useMemo 缓存了解构后的值。

const ContextToUse: ReactReduxContextInstance = useMemo(() => {
  return propsContext &&
    propsContext.Consumer &&
    // @ts-ignore
    isContextConsumer(<propsContext.Consumer />)
    ? propsContext
    : Context;
}, [propsContext, Context]);
复制代码

这一步确定了 context。还记得在 Provider 组件里的那个 context 吗,connect 这里就可以通过 context 拿到它。不过这里做了个判断,如果用户通过 props 传入了自定义的 context,那么优先用自定义 context,否则使用使用那个『可以看做全局『的 React.createContext(也是 Provider 或者其他 connect、useSelector 等使用的)

const store: Store = didStoreComeFromProps ? props.store! : contextValue!.store;

const getServerState = didStoreComeFromContext
  ? contextValue.getServerState
  : store.getState;

const childPropsSelector = useMemo(() => {
  // The child props selector needs the store reference as an input.
  // Re-create this selector whenever the store changes.
  return defaultSelectorFactory(store.dispatch, selectorFactoryOptions);
}, [store]);
复制代码

接着获取 store(它可能来自 props 也可能来自 context),还获取了服务端渲染状态(如果有的话)。然后创建了一个能返回 selected 值的 selector 函数,selector 的细节上面讲过了。

下面出现了订阅的重点!

const [subscription, notifyNestedSubs] = useMemo(() => {
  if (!shouldHandleStateChanges) return NO_SUBSCRIPTION_ARRAY;

  const subscription = createSubscription(
    store,
    didStoreComeFromProps ? undefined : contextValue!.subscription
  );

  const notifyNestedSubs = subscription.notifyNestedSubs.bind(subscription);

  return [subscription, notifyNestedSubs];
}, [store, didStoreComeFromProps, contextValue]);

const overriddenContextValue = useMemo(() => {
  if (didStoreComeFromProps) {
    return contextValue!;
  }

  return {
    ...contextValue,
    subscription,
  } as ReactReduxContextValue;
}, [didStoreComeFromProps, contextValue, subscription]);
复制代码

通过 createSubscription 函数创建了一个订阅实例,createSubscription 的细节上面讲过了,它里面有一个嵌套订阅的逻辑,这里就会用到。createSubscription 的第 3 个参数传入了 context 里的 subscription 订阅实例,根据嵌套订阅逻辑(忘了的可以回头看看函数创建了一个订阅实例,createSubscription 的第 3 个参数起到了什么作用),这个 connect 里的订阅回调实际上是注册给父级的这个contextValue.subscription的,如果这个父级是顶层的 Provider,那么它的订阅回调才真正注册给redux,如果父级还不是顶层的话,那么还是会像这样一层层的嵌套注册回调。通过这个实现了『父级先更新-子级后更新』从而避免过期 props 和僵尸节点问题。

为了让子级 connect 的订阅回调注册给自己,于是用自己的 subscription 生成了一个新的 ReactReduxContextValue: overriddenContextValue,以便后续的嵌套注册。

const lastChildProps = useRef<unknown>();
const lastWrapperProps = useRef(wrapperProps);
const childPropsFromStoreUpdate = useRef<unknown>();
const renderIsScheduled = useRef(false);
const isProcessingDispatch = useRef(false);
const isMounted = useRef(false);

const latestSubscriptionCallbackError = useRef<Error>();
复制代码

然后定义了一批『持久化数据』(不会随着组件重复执行而初始化),这些数据主要为了将来的『更新判断』和『由父组件带动的更新、来自 store 的更新不重复发生』,后面会用到它们。

前面只看到了 subscription 的创建,并没有具体更新相关的,接下来的代码会走到。

const subscribeForReact = useMemo(() => {
  // 这里订阅了更新,并且返回一个注销订阅的函数
}, [subscription]);

useIsomorphicLayoutEffectWithArgs(captureWrapperProps, [
  lastWrapperProps,
  lastChildProps,
  renderIsScheduled,
  wrapperProps,
  childPropsFromStoreUpdate,
  notifyNestedSubs,
]);

let actualChildProps: unknown;

try {
  actualChildProps = useSyncExternalStore(
    subscribeForReact,
    actualChildPropsSelector,
    getServerState
      ? () => childPropsSelector(getServerState(), wrapperProps)
      : actualChildPropsSelector
  );
} catch (err) {
  if (latestSubscriptionCallbackError.current) {
    (
      err as Error
    ).message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n`;
  }

  throw err;
}
复制代码

subscribeForReact后面再看,里面主要是判断是否要更新的,它是发起更新的主要入口。

useIsomorphicLayoutEffectWithArgs是一个工具函数,内部是useIsomorphicLayoutEffect,这个函数前面也讲过。它们最终做的是:将第 2 个数组参数的每项作为参数给第一个参数调用,第 3 个参数是useIsomorphicLayoutEffect的缓存依赖。

被执行的第一个参数captureWrapperProps,它主要功能是判断如果是来自 store 的更新,则在更新完成后(比如 useEffect)触发subscription.notifyNestedSubs,通知子订阅更新。

接着它想生成actualChildProps,也就是 select 出来的业务组件需要的 props,其中主要使用了useSyncExternalStore,如果你追到useSyncExternalStore的代码里看,会发现它是一个空方法,直接调用会抛出错误,所以它是由外部注入的。在入口index.ts里,initializeConnect(useSyncExternalStore)对它进行初始化了,useSyncExternalStore来自 React 。所以actualChildProps实际是React.useSyncExternalStore( subscribeForReact, actualChildPropsSelector, getServerState ? () => childPropsSelector(getServerState(), wrapperProps) : actualChildPropsSelector)的结果。

useSyncExternalStore是 react18 的新 API,前身是useMutableSource,为了防止在 concurrent 模式下,任务中断后第三方 store 被修改,恢复任务时出现tearing从而数据不一致。外部 store 的更新可以通过它引起组件的更新。在react-redux8之前,是由useReducer手动实现的,这是react-redux8首次使用新 API。这也意味着你必须跟着使用 React18+。但我认为其实 react-redux8 可以用 shim: import { useSyncExternalStore } from 'use-syncexternal-store/shim';来做到向下兼容。

useSyncExternalStore第一个参数是一个订阅函数,订阅触发时会引起该组件的更新,第二个函数返回一个 immutable 快照,用于标记该不该更新,以及得到返回的结果。

下面看看订阅函数subscribeForReact做了什么。

const subscribeForReact = useMemo(() => {
  const subscribe = (reactListener: () => void) => {
    if (!subscription) {
      return () => {};
    }

    return subscribeUpdates(
      shouldHandleStateChanges,
      store,
      subscription,
      // @ts-ignore
      childPropsSelector,
      lastWrapperProps,
      lastChildProps,
      renderIsScheduled,
      isMounted,
      childPropsFromStoreUpdate,
      notifyNestedSubs,
      reactListener
    );
  };

  return subscribe;
}, [subscription]);
复制代码

首先用 useMemo 缓存了函数,用 useCallback 也可以,而且个人觉得useCallback更符合语义。这个函数实际调用的是subscribeUpdates,那我们再看看subscribeUpdates

function subscribeUpdates(
  shouldHandleStateChanges: boolean,
  store: Store,
  subscription: Subscription,
  childPropsSelector: (state: unknown, props: unknown) => unknown,
  lastWrapperProps: React.MutableRefObject<unknown>,
  lastChildProps: React.MutableRefObject<unknown>,
  renderIsScheduled: React.MutableRefObject<boolean>,
  isMounted: React.MutableRefObject<boolean>,
  childPropsFromStoreUpdate: React.MutableRefObject<unknown>,
  notifyNestedSubs: () => void,
  additionalSubscribeListener: () => void
) {
  if (!shouldHandleStateChanges) return () => {};

  let didUnsubscribe = false;
  let lastThrownError: Error | null = null;

  const checkForUpdates = () => {
    if (didUnsubscribe || !isMounted.current) {
      return;
    }

    const latestStoreState = store.getState();

    let newChildProps, error;
    try {
      newChildProps = childPropsSelector(
        latestStoreState,
        lastWrapperProps.current
      );
    } catch (e) {
      error = e;
      lastThrownError = e as Error | null;
    }

    if (!error) {
      lastThrownError = null;
    }

    if (newChildProps === lastChildProps.current) {
      if (!renderIsScheduled.current) {
        notifyNestedSubs();
      }
    } else {
      lastChildProps.current = newChildProps;
      childPropsFromStoreUpdate.current = newChildProps;
      renderIsScheduled.current = true;

      additionalSubscribeListener();
    }
  };

  subscription.onStateChange = checkForUpdates;
  subscription.trySubscribe();

  checkForUpdates();

  const unsubscribeWrapper = () => {
    didUnsubscribe = true;
    subscription.tryUnsubscribe();
    subscription.onStateChange = null;

    if (lastThrownError) {
      throw lastThrownError;
    }
  };

  return unsubscribeWrapper;
}
复制代码

其中的重点是checkForUpdates,它里面获取了最新的 Store 状态: latestStoreState(注意这里依然是手动获取的,将来 react-redux 会把它交给uSES做)、最新的要交给业务组件的 props: newChildProps,如果 childProps 和上一次一样,那么不会更新,只会通知子 connect 尝试更新。如果 childProps 变了,则会调用 React.useSyncExternalStore 传入的更新方法,这里叫additionalSubscribeListener,它会引起组件更新。react-redux8 以前这里用的是 useReducerdispatchcheckForUpdates会被交给subscription.onStateChange,前面我们分析过,subscription.onStateChange最终会在 redux store 更新的时候被嵌套调用。

subscribeUpdates函数里面还调用了subscription.trySubscribe()onStateChange收集到父级订阅中。接着调用了 checkForUpdates 以防首次渲染时数据就变了。最后返回了一个注销订阅的函数。

由上述分析可知,组件实际的更新是checkForUpdates完成的。它会由两个途径调用:

  1. redux store 更新后,被父级级联调用

  2. 组件自身 render(父级 render 带动、组件自身 state 带动),同时 useSyncExternalStore 的快照发生了变化,导致调用

我们会发现在一次总更新中,单个 connectcheckForUpdates 是会被多次调用的。比如一次来自 redux 的更新导致父级 render 了,它的子元素有 connect 组件,一般我们不会对 connect 组件做 memo,于是它也会被 render,正好它的 selectorProps 也变化了,所以在 render 期间checkForUpdates调用。当父级更新完成后,触发自身 listeners,导致子 connect 的checkForUpdates再次被调用。这样不会让组件 re-render 多次吗?当初我首次看代码的时候,就有这样的疑问。经过大脑模拟各种场景的代码调度,发现它是这样避免重复 render 的,归纳起来可以分为这几种场景:

  1. 来自 redux store 更新,且自身的 stateFromStore 有更新

  2. 来自 redux store 更新,且自身的 stateFromStore 没有更新

  3. 来自父组件 render 的更新,且自身的 stateFromStore 有更新

  4. 来自父组件 render 的更新,且自身的 stateFromStore 没有更新

  5. 来自 自身 state 的更新,且自身的 stateFromStore 有更新

  6. 来自 自身 state 的更新,且自身的 stateFromStore 没有更新

其中 6 的 stateFromStore 和 props 都没有变化,actualChildProps直接使用缓存结果,并不会调用checkForUpdates,不会担心多次 render 的问题

1 和 2 的更新来自 redux store,所以必然是父组件先更新(除非该 connect 是除 Provider 的顶层)该 connect 后更新,connect render 时,来自父组件的 props 可能变了,自身的 stateFromStore 可能也变了,于是checkForUpdates被调用,useRef childPropsFromStoreUpdate被设置新的 childProps,中断当前 render,重新 render,组件在 render 中获得新 childProps 值。接着由父 connect 组件的 useEffect 带来第二波checkForUpdates,这时 childProps 已经和上一次没有不同了,所以并不会更新,只是触发更底层子 connect 的checkForUpdates,更底层 connect 逻辑同理。

3 和 4 类型的更新其实是 1 和 2 中的一部分,就不细讲了。

5 类型的更新可能发生在同时调用了 setState 和 redux dispatch,根据 react-redux 的嵌套策略,redux dispatch 的更新肯定发生在 setState 之后的,在 render 过程中childPropsSelector(store.getState(), wrapperProps)获取到最新的childProps,它显然是变了。于是checkForUpdates,后续的 redux dispatch 更新childProps已经和上次相同了,所以只走notifyNestedSubs

至此所有场景所有链路的更新都有了闭环。

在 connect 组件的最后:

const renderedWrappedComponent = useMemo(() => {
  return (
    // @ts-ignore
    <WrappedComponent {...actualChildProps} ref={reactReduxForwardedRef} />
  );
}, [reactReduxForwardedRef, WrappedComponent, actualChildProps]);

const renderedChild = useMemo(() => {
  if (shouldHandleStateChanges) {
    return (
      <ContextToUse.Provider value={overriddenContextValue}>
        {renderedWrappedComponent}
      </ContextToUse.Provider>
    );
  }

  return renderedWrappedComponent;
}, [ContextToUse, renderedWrappedComponent, overriddenContextValue]);

return renderedChild;
复制代码

WrappedComponent就是用户传入的业务组件,ContextToUse.Provider会将该 connect 的subscription传给下层,如果业务组件里还有 connect 就可以嵌套订阅。是否需要 context 透传是由shouldHandleStateChanges变量决定的,如果没有mapStateToProps的话,它则是false。也就是说如果连mapStateToProps都没有,那这个组件及其子组件也就没有订阅 redux 的必要。

useSelector

然后我们看一下useSelector

function createSelectorHook(
  context = ReactReduxContext
): <TState = DefaultRootState, Selected = unknown>(
  selector: (state: TState) => Selected,
  equalityFn?: EqualityFn<Selected>
) => Selected {
  const useReduxContext =
    context === ReactReduxContext
      ? useDefaultReduxContext
      : () => useContext(context);

  return function useSelector<TState, Selected extends unknown>(
    selector: (state: TState) => Selected,
    equalityFn: EqualityFn<Selected> = refEquality
  ): Selected {
    const { store, getServerState } = useReduxContext()!;

    const selectedState = useSyncExternalStoreWithSelector(
      store.subscribe,
      store.getState,
      getServerState || store.getState,
      selector,
      equalityFn
    );

    useDebugValue(selectedState);

    return selectedState;
  };
}
复制代码

useSelector是由createSelectorHook()创建的

connect一样,通过ReactReduxContext拿到 Providerstore 等数据。

useSyncExternalStoreWithSelector同样是空方法,被/src/index.ts设置为import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'useSyncExternalStoreWithSelector,和useSyncExternalStore作用类似。它直接订阅给了redux.store.subscribe。redux store 更新时,会触发使用它的组件更新,从而拿到新的selectedState

hooks 只是状态逻辑,它不能像connect组件那样做到给子组件提供 Context,于是它只能平级的直接订阅在 redux 里,这就是文章开头部分讲到的『僵尸节点』问题时提到的:hooks 没有嵌套订阅的原因。useSelector的代码比 7 版本的简洁多了,可以发现去除了非生产环境代码后并没有多少,相比之下 7 版本的要冗长不少(165 行),有兴趣的可以去看看。

衍生出来的 React 原理加餐

useSelector和 7 版本还有一个重要区别!了解它可以帮助你知道更多 React 内部的细节!

在 7 版本里,注册订阅是在 useEffect/useLayoutEffect里执行的。而根据 React 的 fiber 架构逻辑,它会以前序遍历的顺序遍历 fiber 树,首先使用 beginWork 处理 fiber,当到了叶子节点时调用 completeWork,其中 completeWork 会将诸如 useEffectuseLayoutEffect等放入 effectList,将来在 commit 阶段顺序执行。而按照前序遍历的顺序,completeWork是自下而上的,也就是说子节点的useEffect会比父节点先执行,于是在 7 版本里,子组件 hooks 比父组件更早注册,将来执行时也更早执行,这就典型地陷入了开头说的『stale props』、『zombie children』问题。

因为我知道 React 的内部机制,所以刚开始我认为 react-redux7 的 hooks 是会出 bug 的,于是我通过npm link用几个测试用例本地跑了代码,结果出乎我意料,listener确实被调用了多次,这意味着有多个 connect 组件将会更新,就当我以为子组件将先于父组件被更新时,但最终 render 只有一次,是由最上层的父 connect render 的,它将带动下面的子 connect 更新。

这就引出了 React 的批量更新策略。比如 React16 里面,所有的 React 事件、生命周期都被装饰了一个逻辑,开头会设置一个锁,于是里面的所有 setState 这样的更新操作都不会真的发起更新,等代码的最后放开锁,再批量的一起更新。于是 react-redux 正好借用这个策略,让需要更新的组件整体自上而下的批量更新了,这源于它的一处不起眼的地方:setBatch(batch),而我也是因为没注意这里的用处,而误判它会出问题,setBatch(batch)实际做了什么后面会讲到。

关于批量更新,再举个例子,比如 A 有子组件 B,B 有子组件 C,分别顺序调用 C、B、A 的 setState,正常来说 C、B、A 会被按顺序各自更新一次,而批量更新会将三次更新合并成一个,直接从组件 A 更新一次,B 和 C 就顺带被更新了。

不过这个批量更新策略的『锁』是在同一个『宏任务』里的,如果代码中有异步任务,那么异步任务中的 setState 是会『逃脱』批量更新的,也就是说这种情况下每次 setState 就会让组件更新一次。比如 react-redux 不能保证用户不会在一个请求回调里调用dispatch(实际上这么做太普遍了),所以 react-redux 在/src/index.ts中做了setBatch(batch)操作,batch来自import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates'unstable_batchedUpdates 是由 react-dom 提供的手动批量更新方法,可以帮助脱离管控的 setState 重新批量更新。在Subscription.ts中的createListenerCollection里用到了batch

const batch = getBatch();
// ............
return {
  notify() {
    batch(() => {
      let listener = first;
      while (listener) {
        listener.callback();
        listener = listener.next;
      }
    });
  },
};
复制代码

所以subscription里的listenersnotify方法,是会对所有的更新订阅手动批量更新的。从而在 react-redux7 中,就算 hooks 注册的订阅是自下而上的,也不会引起问题。

而 react-redux8 直接使用新 API useSyncExternalStoreWithSelector订阅,是在 render 期间发生的,所以订阅的顺序是自上而下的,避免了子订阅先执行的问题。但是 8 版本依然有上述batch的逻辑,代码和 7 一模一样,因为批量更新能节省不少性能。

useDispatch

最后的部分是useDispatch

function createDispatchHook<S = RootStateOrAny, A extends Action = AnyAction>(
  context?: Context<ReactReduxContextValue<S, A>> = ReactReduxContext
) {
  const useStore =
    context === ReactReduxContext ? useDefaultStore : createStoreHook(context);

  return function useDispatch<
    AppDispatch extends Dispatch<A> = Dispatch<A>
  >(): AppDispatch {
    const store = useStore();
    return store.dispatch;
  };
}

export const useDispatch = createDispatchHook();
复制代码

useDispatch非常简单,就是通过useStore()拿到 redux store,然后返回store.dispatch,用户就能使用这个dispatch派发action了。

除了上述 4 个 api 以外,/src/index.ts里还有一些 api,不过最难的部分我们已经分析完了,剩下的相信可以交给大家自行研究。

阅读源码期间在 fork 的 react-redux 项目中写下了一些中文注释,作为一个新项目放在了react-redux-with-comment仓库,阅读文章需要对照源码的可以看一下,版本是 8.0.0-beta.2

最后的最后

react-redux 源码的解析就到这里了。从最初对 react-redux 性能的疑惑于是首次阅读源码,到后来对官网中『stale props』、『zombie children』问题的解决方案的好奇,驱使我们探究更深入的细节。通过原理的探究,然后反哺我们在业务上的应用,并借鉴优秀框架的设计引发更多思考,这是一个良性循环。带着你的好奇与问题去阅读,并最终应用于项目,而不是为了达成某些目的而阅读(比如应付面试),一个不能应用于工程的技术没有任何价值。如果你看到了最后,说明你对技术是很有好奇心的,希望你始终保持一颗好奇的心。

欢迎关注我的githubshare-technology这个仓库会不定期有高质量前端技术文章分享。

猜你喜欢

转载自juejin.im/post/7069667325074489357