A thorough understanding of react-redux in one article, super detailed and in-depth analysis of the latest react-redux8 source code in 2022: read the react-redux source code again

Time:2022-11-24

The react-redux library must be familiar to anyone who is familiar with react. To describe it in one sentence: it serves as a bridge between “redux, a framework-independent data flow management library” and “react, a view library”, so that redux can be updated in react store, and can monitor the changes of the store and notify the relevant components of react to update, so that react can put the state on the external management (it is conducive to the centralized management of the model, and can use the redux single-item data flow architecture, the data flow is easy to predict and maintain, and also It greatly facilitates the communication between components at any level, etc.).

The react-redux version comes from the latest version v8.0.0-beta.2 as of 2022.02.28 (It’s a bit sad that when I read the source code, it was still version 7. I didn’t expect to just read itgit pullIt rose to 8 in one go, so I read 8 again)

react-redux 8Compared to version 7, including but not limited to these changes:

  • All refactored with typescript
  • The original Subscription class is refactored by createSubscription, and the benefit of replacing the class with a closure function will be mentioned when we talk about that part of the code.
  • Using React18’suseSyncExternalStoreInstead of the original subscription update implemented by itself (the original internaluseReducer),useSyncExternalStoreand its predecessoruseMutableSourceSolved the problem in concurrent modetearingproblem, and also makes the code of the library itself more concise,useSyncExternalStorecompared to predecessorsuseMutableSourcedon’t careselector(here saysuseSyncExternalStoreselector, not the immutable mental burden of react-redux).

The following part is not directly related to source code analysis, but you can gain something after reading it, and you can understand why this article is written. If you want to see the source code analysis part directly, you can jump toReact-Redux source code analysispart

Blowing stage 1 before the main text: Since it is “rereading”, what about “first reading”?

I don’t know if you have seen comments like this when you visit technical forums: redux performance is not good, mobx is more fragrant…

People who like to get to the bottom of things (such as me) can’t help but want to ask more questions:

  1. Is it the bad performance of redux or the bad performance of react-redux?
  2. Where is the specific problem?
  3. Can it be avoided?

If you ask these questions, you may get a few words, which is not deep enough. At the same time, there is another question, how does react-redux associate redux and react? There are many articles on source code analysis on this issue. I have read a very detailed article, but it is a pity that it is an old version and still uses class components, so I decided to read the source code by myself. At that time, it was a rough reading. After reading it, the brief summary is that there is a Subscription instance in the Provider, and there is also a Subscription instance in the connect high-level component, and there are hooks responsible for its own update: useReducer, the dispatch of useReducer will be registered into the listeners of Subscription, There is a method notify in listeners that will traverse and call each listener, and notify will be registered to subscribe of redux, so that after the state of redux is updated, it will notify all connect components. Of course, each connect has a method checkForUpdates to check whether it needs to be updated. To avoid unnecessary updates, the specific details will not be mentioned.

In short, I only read the overall logic roughly, but I can answer my above questions:

  1. react-redux does have the possibility of poor performance. As for redux, each dispatch will let the state go to each reducer, and there will be additional creation and replication overhead to ensure that the data is immutable. butmutableIf the library of the camp frequently modifies the object, the object memory structure of V8 will change from a sequential structure to a dictionary structure, the query speed will decrease, and the inline cache will become highly hypermorphic. In this regard, immutable can pull back a little gap. However, for a clear and reliable data flow architecture, this level of overhead is worthwhile or even negligible in most scenarios.
  2. Where is the performance of react-redux? Because each connect will be notified once whether it needs to be updated or not, and the selector defined by the developer will be called once or more times. If the selector logic is expensive, it will still consume performance.
  3. So react-redux must have poor performance? Not necessarily, according to the above analysis, if your selector logic is simple (or put complex derivative calculations in the reducer of redux, but this may not be conducive to building a reasonable model), connect is not used much, then the performance is not good Will be pulled too much by fine-grained updates like mobx. That is to say, when the business calculation in the selector is not complicated and there are not many components that use global state management, there will be no perceivable performance problems at all. So what if the business calculation in the selector is complicated? Can it be avoided completely? Of course, you can use the reselect library, which will cache the results of the selector and recalculate the derived data only when the original data changes.

This is my “first reading”. I read the source code with purpose and problems. Now that the problem has been solved, it stands to reason that everything is over, so why did the “re-reading” start?

Blowing stage 2 before the main text: Why “re-read”?

Some time ago I followed a React state management library on githubzustand

zustand is a very fashionable hooks-based state management library, based on a simplified flux architecture, and it is also the fastest-growing React state management library in Star in 2021. It can be said to be a strong competitor of redux + react-redux.

itsgithubIt was introduced like this at the beginning

A thorough understanding of react-redux in one article, super detailed and in-depth analysis of the latest react-redux8 source code in 2022: read the react-redux source code again

The general idea is: it is a small, fast, scalable state management solution using a simplified flux architecture. There is an api based on hooks, which is very comfortable and user-friendly to use.
Don’t ignore it because it is cute (it seems that the author compared it to a bear, and the cover picture is also a cute bear). It has a lot of claws and spends a lot of time dealing with common pitfalls like the dreaded zombie child problem, react concurrency, and lost context between multiple renders when using portals Problem (context loss). It’s probably the only state manager in the React world that handles all of these issues correctly.

There is one thing mentioned in it: zombie child problem. when i click inzombie child problem, is the official documentation of react-redux, let’s take a look at what this problem is and how react-redux solves it. If you want to read the original text, you can click the link directly.

“Stale Props” and “Zombie Children” (expired Props and zombie children problem)

Since the release of v7.1.0, react-redux can use the hooks api, and the official recommendation is to use hooks as the default method in components. But there are some edge cases that can happen, and this document is to make us aware of those things.

One of the hardest things about the react-redux implementation is this: if your mapStateToProps is used like (state, ownProps), it will be passed the “latest” props every time. Until version 4, repeated bugs in edge scenarios have been reported. For example, if the data of a list item is deleted, an error will be reported in mapStateToProps.

Since version 5, react-redux tries to guaranteeownPropsconsistency. In version 7, eachconnect()There is a custom Subscription class inside, so when there is another connect in the connect, it can form a nested structure. This ensures that a connect component lower in the tree will only receive updates from the store if its closest ancestor connect component has been updated. However, this implementation relies on eachconnect()The instance overrides part of the internal React Context (the subscription part), using its own Subscription instance for nesting. Then use this new React Context ( \<ReactReduxContext.Provider\> ) to render child nodes.

With hooks, there is no way to render a context.Provider, which means it cannot allow subscriptions to have a nested structure. Because of this, the “stale props” and “zombie child” problems may recur in “hooks instead of connect” applications.

Specifically, “stale props” will appear in this scenario:

  • The selector function will calculate the data according to the props of this component
  • The parent component will re-render and pass the new props to this component
  • But this component will execute selector before props update update method)

The result calculated by the old props and the latest store state is likely to be wrong, and may even cause an error.

“Zombie child” specifically refers to the following scenarios:

  • Multiple nested connect components are mounted, and the child components are registered to the store earlier than the parent components
  • An action dispatches the behavior of deleting data in the store, such as an item in a todo list
  • When the parent component is rendered, there will be one less item child component
  • However, because the child component is subscribed first, its subscription precedes the parent component. When it calculates a value calculated based on store and props, some data may no longer exist, and an error will be reported if the calculation logic does not pay attention.

useSelector()Try to solve this problem like this: it will catch all errors in the selector calculation caused by store updates. When an error occurs, the component will force an update, and the selector will be executed again. This requires that the selector is a pure function and that you have no logical dependencies on the selector throwing errors.

If you prefer to do it yourself, here’s a potentially useful item to help you when usinguseSelector()avoid these problems when

  • Don’t rely on props in selector calculations
  • If you must rely on props calculation and props may change in the future, and the dependent store data may be deleted, in these two cases, you must write selectors defensively. don’t directly likestate.todos[props.id].nameTo read the value like this, instead read firststate.todos[props.id], verify that it exists and then readtodo.name
    becauseconnectAdded necessary to context providerSubscription, which delays execution of child subscriptions until the connected component is re-rendered. If a connected component is in use in the component treeuseSelectorThe upper layer of the component can also avoid this problem, because the parent connect has the same store update as the hooks component (Translator’s Note: The child hooks component will be updated after the parent connect component is updated, and the update of the connect component will drive the child node update, The deleted node has been uninstalled in the update of the parent component: because the above saidstate.todos[props.id].name, indicating that the hooks component is traversed by the upper layer through ids. So subsequent sub-hooks component updates from the store will not be deleted)

The above explanation may let you understand how the “Stale Props” and “Zombie Children” problems arise and how react-redux probably solves them, that is, the update of the child connect is nested and collected into the parent connect. The redux update does not traverse and update all connects, but the parent is updated first, and then the child is updated by the parent before the update is triggered. But it seems that the emergence of hooks makes it unable to solve the problem perfectly, and the details of these specific designs have not been mentioned. The doubts and lack of this part are the reasons why I am going to read the react-redux source code again.

React-Redux source code analysis

The react-redux version comes from the latest version v8.0.0-beta.2 as of 2022.02.28

During the reading of the source code, I wrote some Chinese comments in the react-redux project of the fork, and put it as a new projectreact-redux-with-commentWarehouse, if you need to compare the source code to read the article, you can take a look, the version is 8.0.0-beta.2

Before I talk about the specific details, I would like to talk about the overall abstract design, so that everyone can read the details with the design blueprint in mind, otherwise it is difficult to connect them together to understand how they work together to complete the entire function just by looking at the details .

React-Redux’s Provider and connect both provide their own context that runs through the subtree, and all their child nodes can get them and give them their own update methods. finally formedroot <– parent <– childSuch a collection sequence. The update method of the root collection will be triggered by redux, and the update method of the parent collection will be updated after the parent is updated, thus ensuring the order in which the child nodes are updated after the parent node is updated by redux.

A thorough understanding of react-redux in one article, super detailed and in-depth analysis of the latest react-redux8 source code in 2022: read the react-redux source code again

The simple macro design is as shown above. At first glance, you can’t understand it very deeply, but it doesn’t matter. After reading the source code and source code analysis a few times, you will find new gains when you look back.

First look at the project construction entry

A thorough understanding of react-redux in one article, super detailed and in-depth analysis of the latest react-redux8 source code in 2022: read the react-redux source code again

It can be seen that its umd package is built by rollup (build:umdbuild:umd:min), esm and commonjs packages are compiled and output by babel (build:commonjsbuild:es). we just look atbuild:es"babel src --extensions \".js,.ts,.tsx\" --out-dir es". It means to use babel to transform the src directory.js,.ts,.tsxfile and output to the es directory (this is somewhat different from the business project, because the npm package does not need to be packaged as a file, otherwise the different installed npm packages may be packaged into repeated dependencies, and each file still maintains the import to introduce only the content Compilation is enough, and it will eventually be built together in the developer’s project).

Let’s take a look at what .babelrc.js does

A thorough understanding of react-redux in one article, super detailed and in-depth analysis of the latest react-redux8 source code in 2022: read the react-redux source code again

You can see in babel’s presets@babel/preset-typescriptResponsible for compiling ts to js,@babel/preset-envResponsible for compiling the latest syntax of ECMA into es5 (only syntax can be compiled, API requires additional plug-ins). About babel plugins,@babel/transform-modules-commonjsSolved the problem of babel duplicating helpers, and can introduce the api polyfill in the unified corejs library as needed, here is throughuseESModulesconfiguration to decide whether to use esm or commonjs helper, butofficial documentIn 7.13.0, this configuration has been discarded, you can directly passpackage.jsonofexportsto judge. Other plugins are also related to grammar compilation, such as the compilation of grammars such as private methods, private properties, static properties, jsx, decorators, and@babel/plugin-transform-modules-commonjsThe library that imports esm and compiles it into commonjs is determined by the environment variable NODE_ENV, which determines whether the final output is an esm library or a commonjs library.

A thorough understanding of react-redux in one article, super detailed and in-depth analysis of the latest react-redux8 source code in 2022: read the react-redux source code again

According to the module field of package.json (About the priority of main, module, browser fields), the final entry is es/index.js in the root directory, because it is output by babel according to the source directory, so the source code entry issrc/index.ts

A thorough understanding of react-redux in one article, super detailed and in-depth analysis of the latest react-redux8 source code in 2022: read the react-redux source code again

Cut from common api

As can be seen from the figure above, the output of the entry file is onlybatchandexports.tsAll export files, so let’s go toexports.ts

A thorough understanding of react-redux in one article, super detailed and in-depth analysis of the latest react-redux8 source code in 2022: read the react-redux source code again

one of themProviderconnectuseSelectoruseDispatchOccupies most of the scenarios we usually use, so we cut in from these 4 APIs.

Provider

A thorough understanding of react-redux in one article, super detailed and in-depth analysis of the latest react-redux8 source code in 2022: read the react-redux source code again

Providerfromsrc/components/Provider.tsx

It is a React component, which does not have any view content itself, and finally displays children, but adds a layer outside the childrenContext Provider, which is why this api is called Provider. So what exactly does this component want to transparently transmit below.

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

It can be seen that the transparent transmission is astoresubscriptiongetServerStatecomposed objects. Let’s talk about the role of the three attributes of the object.

storeIt is the store of redux, which is passed to the Provider component by the developer through the store prop.

subscriptionCreated by the object factory createSubscription, which generates a subscription object, which is the subsequent nested collection subscriptionThe essential. The code details about createSubscription will be mentioned later.

getServerStateIt is newly added in version 8.0.0, it is used in SSR, when the initial “water injection”hydrateGet a snapshot of the server-side state at any time, so as to ensure the consistency of the state at both ends. Its control is entirely in the developer, as long as the state snapshot is given to the Provider component through the serverState prop. If you don’t understand the concepts of SSR and hydrate, you can read an article by Dan Abramovdiscussions, although its theme is not specifically about SSR, but it introduces its related concepts at the beginning, and Dan’s articles are always vivid and easy to understand.

The next thing the Provider component does is:

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>;

Get the latest state once and name it aspreviousState, as long as the store singleton does not change, it will not be updated. In general projects, the redux singleton is not likely to be changed.

useIsomorphicLayoutEffectonly onefacade, from the naming of isomorphic we can also see that it is related to isomorphism. It internally uses useEffect in the server environment and useLayoutEffect in the browser environment

Its code is simple:

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;

But the reason for this is not simple: first, using useLayoutEffect on the server side will throw a warning, so in order to bypass it, use useEffect on the server side. Secondly, why must it be done in useLayoutEffect/useEffect? Because a store update may occur between the render phase and the side effect phase, and if it is done during render, the update may be missed. It is necessary to ensure that the store subscription callback has the selector from the latest update. Also make sure that the creation of the store subscription must be synchronous, otherwise a store update may happen before the subscription (if the subscription is asynchronous), and the subscription has not yet been created, resulting in an inconsistent state.

If the reason is not very clear after reading it, it will be understood by combining the following examples.

Provider inuseIsomorphicLayoutEffectHere did something like this:

subscription.trySubscribe();

if (previousState !== store.getState()) {
  subscription.notifyNestedSubs();
}

First collect the subscription subscription, and then check whether the latest state is consistent with the previous state in render, and notify the update if they are inconsistent. If this paragraph is not placed in useLayoutEffect/useEffect, but in render, then only itself is subscribed, and its subcomponents are not subscribed. If the subcomponent updates the redux store during rendering, then the subcomponents Just missed the update notification. At the same time, react’s useLayoutEffect/useEffect is called from bottom to top, the child component is called first, and the parent component is called later. Since it is the root node of react-redux, its useLayoutEffect/useEffect will be called at the end. At this time, it can ensure that all subcomponents that should be registered and subscribed are registered, and it can also ensure that any updates that may occur during the rendering of subcomponents Already happened. So read the state one last time and compare whether to notify them to update. That’s why useLayoutEffect/useEffect is chosen.

Next, let’s take a complete look at the Provider inuseIsomorphicLayoutEffectdoing things in

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

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

The first is to set the onStateChange of the subscription (it is initially an empty method and needs to be injected into the implementation), it will be called when the update is triggered, and what it hopes to call in the future issubscription.notifyNestedSubssubscription.notifyNestedSubsAll sub-subscriptions collected by this subscription will be triggered. In other words, the update callback here is not directly related to “update”, but triggers the update method of child nodes.

and then calledsubscription.trySubscribe(), it will give its own onStateChange to the parent subscription or redux to subscribe, and they will trigger onStateChange in the future

Finally, it will judge whether the previous state is consistent with the latest one, and if not, it will callsubscription.notifyNestedSubs(), which triggers all sub-subscriptions collected by this subscription to update them.

Returns a function related to logout, which will log out the subscription at the parent level, and willsubscription.onStateChangeReset to null method. This function will be called when the component unmounts or re-renders (only when the store changes) (a feature of react useEffect).

Subscription is involved in many places of Provider, and those methods of subscription are only about the general functions, and the details of subscription will be mentioned in the subscription part later.

completeProviderThe source code and comments are as follows:

function Provider<A extends Action = AnyAction>({
  store,
  context,
  children,
  serverState,
}: ProviderProps<A>) {
  // Generate an object for context transparent transmission, including redux store, subscription instance, and functions that may be used in SSR
  const contextValue = useMemo(() => {
    const subscription = createSubscription(store);
    return {
      store,
      subscription,
      getServerState: serverState ? () => serverState : undefined,
    };
  }, [store, serverState]);

  // Get the current redux state once, because the rendering of subsequent child nodes may modify the state, so it is called previousState
  const previousState = useMemo(() => store.getState(), [store]);

  // in useLayoutEffect or useEffect
  useIsomorphicLayoutEffect(() => {
    const { subscription } = contextValue;
    // Set the subscription's onStateChange method
    subscription.onStateChange = subscription.notifyNestedSubs;
    // Subscribe the subscription update callback to the parent, here it will be subscribed to redux
    subscription.trySubscribe();

    // Determine whether the state has changed after rendering, if it changes, trigger all sub-subscription updates
    if (previousState !== store.getState()) {
      subscription.notifyNestedSubs();
    }
    // Logout operation when the component is unmounted
    return () => {
      subscription.tryUnsubscribe();
      subscription.onStateChange = undefined;
    };
  }, [contextValue, previousState]);

  const Context = context || ReactReduxContext;

  // In the end, the Provider component is just to pass the contextValue transparently, and the component UI completely uses children
  return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}

To sum up, Provider is actually very simple. The Provider component is just to pass the contextValue transparently, so that subcomponents can get the redux store, subscription instance, and server-side state function.

Subscription/createSubscription subscription factory function

Here we will talk about the subscription part of Provider, which is very popular, and it is the key to react-redux’s nested collection of subscriptions. In fact, the title of this section is calledSubscriptionIt is not suitable anymore. Before version 8.0.0, react-redux did implement it through Subscription class, you can passnew Subscription()Use to create a subscription instance. But after 8.0.0, it has becomecreateSubscriptionThe function creates a subscription object, and internally replaces the original attributes with closures.

One advantage of using a function instead of a class is that you don’t need to care about the point of this. The method returned by the function will always modify the internal closure, and there will be no problem that the point of this will change after the class method is assigned to other variables, which reduces the development time. time mental burden. Closures are also more private, increasing variable safety. At the same time, in a library that supports hooks, it is more in line with the development paradigm to implement it with functions.

Let’s take a look at the abstract code of createSubscription first, and each responsibility is written in the comment

Note: The “subscription callback” that appears below specifically refers to the update method of the component triggered after the redux state is updated. The component update method is collected by the parent subscription, which is a subscription-publishing model.

function createSubscription(store: any, parentSub?: Subscription) {
  // The flag of whether you are subscribed
  let unsubscribe: VoidFunc | undefined;
  // Collector responsible for collecting subscriptions
  let listeners: ListenerCollection = nullListeners;

  // Collect subscriptions
  function addNestedSub(listener: () => void) {}

  // notification subscription
  function notifyNestedSubs() {}

  // own subscription callback
  function handleChangeWrapper() {}

  // Determine if you are subscribed
  function isSubscribed() {}

  // Get yourself subscribed by the parent
  function trySubscribe() {}

  // Unregister own subscription from parent
  function tryUnsubscribe() {}

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

  return subscription;
}

createSubscriptionA function is an object factory that defines some variables and methods and returns an object with those methodssubscription

take a look firsthandleChangeWrapper, it can be seen from the name that it is just a shell

function handleChangeWrapper() {
  if (subscription.onStateChange) {
    subscription.onStateChange();
  }
}

which actually calls theonStateChangemethod. The reason is that when the subscription callback is collected by the parent, the own callback may not be determined yet, so a shell is defined for being collected, and the internal callback method will be reset when it is determined, but the reference of the shell remains unchanged , so the callback can still be triggered in the future. That’s why inProvider.tsIn the source code, before collecting subscriptions, dosubscription.onStateChange = subscription.notifyNestedSubss reason.

then looktrySubscribe

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

    listeners = createListenerCollection();
  }
}

Its role is to let the parent’s subscription collect its own subscription callback. First it judges ifunsubscribeto mark it as already subscribed, then do nothing. Secondly, it will judge the createdsubscriptionThe second parameter whenparentSubIs it empty, if anyparentSubmeans that it has a parent on the upper layersubscription, then it calls the parent’saddNestedSubmethod, register your own subscription callback to it; otherwise, you think you are at the top level, so you register with the redux store.

This leads to the need to look ataddNestedSubwhat is the method

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

addNestedSubIt uses recursion very cleverly, and it callstrySubscribe. So they will achieve this purpose, when the bottomsubscriptioninitiatetrySubscribeWhen wanting to be collected and subscribed by the parent, it will first trigger the parent’strySubscribeand continue recursively until the rootsubscription, if we think of such a hierarchical structure as a tree (in fact, subscription.trySubscribe does happen in the component tree), then it is equivalent to being collected and subscribed by the parent in turn from the root node to the leaf node. Because this is initiated by the leaf node first, at this time, except for the leaf node, the subscription callback of other nodes has not been set, so it is designedhandleChangeWrapperThis callback shell only registers this callback shell, which can be triggered by the shell after setting callbacks on non-leaf nodes in the future.

After the “delivery” process ends, the subscription callback from the root node to the leaf nodehandleChangeWrapperare being collected by the parent, and the process of “returning” goes back to do its own workreturn listeners.subscribe(listener), MasakosubscriptionThe subscription callbacks are collected into the collectorlistenersMedium (the relatedhandleChangeWrapper, and it will indirectly call to collect all listeners).

so eachsubscriptionofaddNestedSubBoth have done two things: 1. Let your own subscription callback be collected by the parent first; 2. Collect the childsubscriptionThe subscription callback.

combineaddNestedSubLooking back at the explanationtrySubscribe, it wants its subscription callback to be collected by the parent, so when it is passed to the parentsubscription, it will be calledaddNestedSub, which results in rootsubscriptionstart each layersubscriptionare collected by the parent callback, so eachsubscriptionare nested to collect their childrensubscription, so that it is possible to update the child after the parent is updated. At the same time, becauseunsubscribeThe existence of this lock, if a parentsubscriptionoftrySubscribeIt is called, and this “nested registration” will not be triggered repeatedly.

Above we analyzed what happened during “nested registration”, let’s look at the substantive operation of registrationlisteners.subscribeWhat did it do, and how was the registered data structure designed.

function createListenerCollection() {
  const batch = getBatch();
  // Collection of listener, listener is a doubly linked list
  let first: Listener | null = null;
  let last: Listener | null = null;

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

    // Trigger the callback of all nodes in the linked list
    notify() {
      batch(() => {
        let listener = first;
        while (listener) {
          listener.callback();
          listener = listener.next;
        }
      });
    },

    // return all nodes as an array
    get() {
      let listeners: Listener[] = [];
      let listener = first;
      while (listener) {
        listeners.push(listener);
        listener = listener.next;
      }
      return listeners;
    },

    // Add a node to the end of the linked list and return an undo function that deletes the node
    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;
        }
      };
    },
  };
}

listenersobject is made ofcreateListenerCollectionCreated.listenersThere are not many methods and the logic is easy to understand.clearnotifygetsubscribeconsist of.

Listeners are responsible for collecting listeners (that is, subscription callbacks). Listeners internally maintain listeners as a doubly linked list, and the head node isfirst, the tail node islast

clearMethods as below:

clear() {
  first = null
  last = null
}

Linked list for clearing collection

notifyMethods as below:

notify() {
  batch(() => {
    let listener = first
    while (listener) {
      listener.callback()
      listener = listener.next
    }
  })
}

Used to traverse the calling list nodes,batchThis can be simply understood as calling the function that enters the parameter, and the details can derive many React principles (such as batch update, fiber, etc.), which are put at the end of the article.

getMethods as below:

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

Used to convert the linked list nodes into an array and return

subscribeMethods as below:

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

  // Create a linked list node
  let listener: Listener = (last = {
    callback,
    next: null,
    prev: last,
  })

  // If the linked list already has a node
  if (listener.prev) {
    listener.prev.next = listener
  } else {
    // If the linked list does not have a node yet, it is the first node
    first = listener
  }

  // unsubscribe is a doubly linked list delete specified node operation
  return function unsubscribe() {
    // prevent meaningless execution
    if (!isSubscribed || first === null) return
    isSubscribed = false

    // If the added node already has a successor node
    if (listener.next) {
      // The prev of next should be the prev of this node
      listener.next.prev = listener.prev
    } else {
      // If not, it means that the node is the last one, and the prev node is used as the last node
      last = listener.prev
    }
    // If there is a previous node prev
    if (listener.prev) {
      // The next of prev should be the next of this node
      listener.prev.next = listener.next
    } else {
      // Otherwise, it means that the node is the first one, give its next to first
      first = listener.next
    }
  }
}

It is used to add a subscription to the listeners linked list and return a function to unsubscribe, which involves the addition and deletion of the linked list, see the comments for details.

so eachsubscriptionCollecting subscriptions actually maintains a doubly linked list.

subscriptionThe last part that needs to be said is onlynotifyNestedSubsandtryUnsubscribeup

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

tryUnsubscribe() {
  if (this.unsubscribe) {
    this.unsubscribe()
    this.unsubscribe = null
    this.listeners.clear()
    this.listeners = nullListeners
  }
}

notifyNestedSubscalledlisteners.notify, according to the above analysis about listeners, all subscriptions will be traversed here

tryUnsubscribeIt is to perform cancellation-related operations.this.unsubscribeexisttrySubscribeThe value is injected in the execution of the method, which isaddNestedSuborredux subscribeThe return value of the function is the undo operation for unsubscribing. existthis.unsubscribe()The difference below is to clearunsubscribe, clearlistenersoperate.

so farsubscriptionThe analysis is over. It is mainly used for nested collection of subscriptions during nested calls, so that the subscription callback of the child node is executed after the parent is updated, so that it is updated after the parent is updated. People who don’t know much about react-redux may be confused, not onlyProviderThe component usessubscriptionWell, where does the nested call come from? Where does the collection sub-subscription come from? Don’t worry, we will talk about it laterconnectHigher-order functions, which are also used insubscription, which is nested here.

connect Higher-order components

8.0.0 started byconnect.tsxreplaceconnectAdvanced.js, are essentially multi-level higher-order functions, but the refactoredconnect.tsxThe structure appears clearer and more intuitive.

We all know that when using connect:connect(mapStateToProps, mapDispatchToProps, mergeProps, connectOptions)(Component), so its entry should be receivingmapStateToPropsmapDispatchToPropsand other parameters, return a receivingComponentA higher-order function of parameters, this function finally returnsJSX.Element

If you simply look at the structure of connect, it looks like this:

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

If you decompose what connect does, I think there are several parts: subscribing to the parent’s own updates, selecting data from the redux store, judging whether it needs to be updated, and other details

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
);

matchFunctions are the first to be analyzed

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;
}

factoriesAs a factory array, it will be passed inargParameter traversal calls, each factory will detect and processarg, and here theargIt is written in our developmentmapStateToPropsmapDispatchToPropsmergeProps,untilfactories[i](arg)It will return only if there is a value, and if it is not a true value all the time, an error will be reported.factorieslikechain of responsibility modelSimilarly, your own factory responsibilities will be processed and returned.

factoriesexistinitMapStateToPropsinitMapDispatchToPropsinitMergePropsare different indefaultMapStateToPropsFactoriesdefaultMapDispatchToPropsFactoriesdefaultMergePropsFactories, let’s see what they are.

// 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,
];

traversedefaultMapStateToPropsFactoriesis calledwhenMapStateToPropsIsFunctionwhenMapStateToPropsIsMissingFrom the names of these two factories, it can be seen that the first one was whenmapStateToPropsIt is processed when it is a function, and the second is omittedmapStateToPropstime processing.

insidewrapMapToPropsFuncfunction (iewhenMapStateToPropsIsFunction)WillmapToPropsWrapped in a proxy function, it does several things:

  1. Detects whether the called mapToProps function depends on props, which is used by selectorFactory to decide whether it should be called again when props change.
  2. On the first call, ifmapToPropsreturns another function, then handlesmapToProps, and handle the new function as the real mapToProps for subsequent calls.
  3. On the first call, verify that the result is a flat object to warn developers that the mapToProps function did not return a valid result.

wrapMapToPropsConstantfunction (iewhenMapStateToPropsIsMissing) will return an empty object in the future by default (not immediately, but a higher-order function), and expect that value to be a function when there is a value, and willdispatchPass in the function, and finally return the return value of this function (also not immediately return)

Two other factory groupsdefaultMapDispatchToPropsFactoriesdefaultMergePropsFactories, responsibilities anddefaultMapStateToPropsFactoriesThe same, it is essentially responsible for handling different casesarg

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

const defaultMergePropsFactories = [
  whenMergePropsIsFunction,
  whenMergePropsIsOmitted,
];

I believe that everyone can roughly guess what they are responsible for through their names, so I won’t go into details one by one.

go throughmatchAfter processing, returnedinitMapStateToPropsinitMapDispatchToPropsinitMergePropsThese 3 higher order functions
, the final purpose of these functions is to return the value of select

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

These, along with other properties, make up theselectorFactoryOptionsObject

finally handed overdefaultSelectorFactoryuse

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]);

andchildPropsSelectorIt is the function that finally returns the value that is really needed (it is really the end of the higher-order function~)

So in the end just lookdefaultSelectorFactoryfunction does what it actually callsfinalPropsSelectorFactory

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);
}

mapStateToPropsmapDispatchToPropsmergePropsare functions that return their respective final values. More attention should be paid to thepureFinalPropsSelectorFactoryfunction

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);
  };
}

its closurehasRunAtLeastOnceIt is used to distinguish whether it is called for the first time. The first and subsequent functions are different functions. If it is the first call, usehandleSubsequentCallsfunction, which generatesstateProps,producedispatchProps, and put them inmergePropsCalculate the final props, and puthasRunAtLeastOnceSet astrue, indicating that this is not the first time it has been executed.

Subsequent calls are gonehandleSubsequentCalls, its main purpose is to use the cached data if neither the state nor props have changed (the method for judging whether the state and props have changed is passed in from the outside, and the component can of course know whether it has changed), if the state and props have changed or Only one of them has changed, and then call their respective functions (which are mainly based on static attributesdependsOnOwnPropsDetermine whether to re-execute) to get the new value.

thenchildPropsSelectorThe function is what returnspureFinalPropsSelectorThe function accesses the closure internally, and the closure saves the persistent value, so that in the case of multiple executions of the component, it can be decided whether to use the cache to optimize performance.

The analysis related to selector is finished.

In general, if you want to implement the simplestselector,only need to

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

Then why react-redux is so complicated to write. just forconnectComponents can take advantage of fine-grained caching when executed multiple timesmergedPropsvalue to improve performance, React can only do it inwrapperPropsUse memo when it remains unchanged, but it is difficult to make a finer-grained distinction, such as knowing whether the selector depends on props, so even if the props change, it does not need to be updated. To achieve this, a large number of nested higher-order functions are required to store persistent closure intermediate values, so that the update can be judged without losing state when the component is executed multiple times.

Now we are going to talk about something else, if you are a little dizzy with a series of call stacks, just remember to seechildPropsSelectorJust return the value after the selector.

connect updated registration subscription

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

  // …………
  // …………
}

First, the actual business props and props related to behavior control are divided from props. The so-called business props refer to the props actually passed to the connect component by the parent component in the project, and the behavior control props areforward ref, context and other business-independent props related to internal registration subscriptions. And use useMemo to cache the destructured value.

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

This step determines the context. Remember the context in the Provider component, connect can get it through the context here. But here is a judgment, if the user passes in a custom context through props, then use the custom context first, otherwise use the one that “can be regarded as a global”React.createContext(It is also used by Provider or other connect, useSelector, etc.)

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]);

Then it gets the store (which may come from props or context), and also gets the server-side rendering state (if any). Then create a selector function that can return the selected value, the details of the selector are mentioned above.

Highlights for subscriptions appear below!

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]);

A subscription instance is created through the createSubscription function. The details of createSubscription have been mentioned above. There is anested subscriptionThe logic will be used here. The third parameter of createSubscription is passed into the subscription subscription instance in the context, according to the nested subscription logic (if you forget, you can look back at the function to create a subscription instance, what is the role of the third parameter of createSubscription), this connect The subscription callback in is actually registered to the parentcontextValue.subscriptionYes, if the parent is the top-level Provider, then its subscription callback is actually registered toredux, if the parent is not the top level, then there will still be nested registration callbacks like this. Through this, “the parent is updated first – the child is updated later” to avoid the problem of expired props and zombie nodes.

In order to register the subscription callback of the sub-level connect to itself, a new ReactReduxContextValue is generated with its own subscription:overriddenContextValue, for subsequent nested registrations.

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>();

Then define a batch of “persistent data” (which will not be initialized with repeated execution of components), these data are mainly for future “update judgment” and “updates driven by parent components, updates from store do not recur”, They will be used later.

I only saw the creation of subscription, and there is no specific update related to it. The next code will come.

const subscribeForReact = useMemo(() => {
  // Updates are subscribed here, and a function to unsubscribe is returned
}, [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;
}

subscribeForReactLooking at it later, it is mainly to judge whether to update, and it is the main entrance to initiate the update.

useIsomorphicLayoutEffectWithArgsis a utility function, internallyuseIsomorphicLayoutEffect, this function has also been mentioned earlier. What they finally do is: call each item of the second array parameter as a parameter to the first parameter, and the third parameter isuseIsomorphicLayoutEffectcache dependencies.

The first parameter to be executedcaptureWrapperProps, its main function is to judge if it is an update from the store, it will be triggered after the update is completed (such as useEffect)subscription.notifyNestedSubs, notifies the sub-subscription of updates.

Then it wants to generateactualChildProps, that is, the props required by the selected business components, which mainly useuseSyncExternalStore, if you catchuseSyncExternalStoreLooking at the code, you will find that it is an empty method, and calling it directly will throw an error, so it is injected from the outside. at the entranceindex.tsinside,initializeConnect(useSyncExternalStore)initialized it,useSyncExternalStorefrom React. soactualChildPropsActuallyReact.useSyncExternalStore( subscribeForReact, actualChildPropsSelector, getServerState ? () => childPropsSelector(getServerState(), wrapperProps) : actualChildPropsSelector)the result of.

useSyncExternalStoreIt is a new API of react18, formerlyuseMutableSource, in order to prevent the third-party store from being modified after the task is interrupted in concurrent mode, it will appear when resuming the tasktearingThus the data is inconsistent. The update of the external store can cause the update of the component through it. existreact-redux8Before, byuseReducerImplemented manually, this isreact-redux8Use the new API for the first time. This also means that you have to use React18+ along with it. but i think actuallyreact-redux8You can use shim:import { useSyncExternalStore } from 'use-syncexternal-store/shim';to achieve backward compatibility.

useSyncExternalStoreThe first parameter is a subscription function, which will cause the component to be updated when the subscription is triggered, and the second function returns an immutable snapshot, which is used to mark whether it should be updated, and to get the returned result.

Let’s look at the subscription functionsubscribeForReactWhat have you done.

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]);

first useuseMemoThe function is cached, usinguseCallbackIt’s also possible, and I personally thinkuseCallbackmore semantically. What this function actually calls issubscribeUpdates, let’s seesubscribeUpdates

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;
}

The point of this ischeckForUpdates, which gets the latest Store status:latestStoreState(Note that it is still obtained manually, and react-redux will hand it over touSESDo), the latest props to be handed over to business components:newChildProps, if childProps is the same as last time, it will not be updated, and only the child connect will be notified to try to update. If childProps changed, it will callReact.useSyncExternalStoreThe incoming update method, called hereadditionalSubscribeListener, which causes the component to update. react-redux8 was used here beforeuseReducerofdispatchcheckForUpdateswill be handed oversubscription.onStateChange, we analyzed earlier,subscription.onStateChangeIt will eventually be called nestedly when the redux store is updated.

subscribeUpdatesThe function also callssubscription.trySubscribe()WillonStateChangeCollected into the parent subscription. then calledcheckForUpdatesIn case the data changes when it is first rendered. Finally, a function to unsubscribe is returned.

From the above analysis, it can be seen that the actual update of the component ischeckForUpdatesCompleted. It will be called in two ways:

  1. After the redux store is updated, it is called by the parent cascade
  2. The component’s own render (driven by the parent’s render, driven by the component’s own state), and at the same timeuseSyncExternalStoreThe snapshot has changed, resulting in a call to

We will find that in a total update, a singleconnectofcheckForUpdatesis called multiple times. For example, an update from redux causes the parent to render, and its child element has a connect component. Generally, we don’t do memo on the connect component, so it will also be rendered. It happens that its selectorProps also changes, so during the rendercheckForUpdatestransfer. When the parent update is complete, trigger its own listeners, resulting in the child connectcheckForUpdatesis called again. Won’t this make the component re-render multiple times? When I first looked at the code, I had this question. After simulating the code scheduling of various scenarios in the brain, it is found that it avoids repeated rendering in this way. In summary, it can be divided into the following scenarios:

  1. Update from redux store, and its own stateFromStore has been updated
  2. Update from redux store, and its own stateFromStore is not updated
  3. The update from the parent component render, and its own stateFromStore has been updated
  4. The update from the parent component render, and its own stateFromStore is not updated
  5. Updates from its own state, and its own stateFromStore has been updated
  6. An update from its own state, and its own stateFromStore is not updated

Among them, the stateFromStore and props of 6 have not changed,actualChildPropsUse the cached result directly without callingcheckForUpdates, will not worry about the problem of multiple renders

The update of 1 and 2 comes from the redux store, so the parent component must be updated first (unless the connect is the top layer except the Provider) and then updated after the connect. When connect renders, the props from the parent component may change, and its own stateFromStore may also change socheckForUpdatesis called, useRefchildPropsFromStoreUpdateThe new childProps is set, the current render is interrupted, and the render is re-rendered. The component gets the new childProps value in the render. Then by the parent connect component’suseEffectbring the second wavecheckForUpdates, at this time childProps is no different from last time, so it will not be updated, it just triggers the lower-level sub-connectcheckForUpdates, and the underlying connect logic is the same.

Updates of types 3 and 4 are actually part of 1 and 2, so I won’t go into details.

5 types of updates may occur when setState and redux dispatch are called at the same time. According to the nesting strategy of react-redux, the update of redux dispatch must occur after setState, during the render processchildPropsSelector(store.getState(), wrapperProps)get the latestchildProps, it has apparently changed. thencheckForUpdates, subsequent redux dispatch updateschildPropsAlready the same as last time, so just gonotifyNestedSubs

So far, the updates of all links in all scenarios have closed loops.

At the end of the connect component:

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;

WrappedComponentIt is the business component passed in by the user.ContextToUse.Providerwill connect thesubscriptionPass it to the lower layer, if there is connect in the business component, you can nest the subscription. Whether context transparent transmission is required is determined byshouldHandleStateChangesvariable determined if nomapStateToProps, it isfalse. That is to say, if evenmapStateToPropsNone, then this component and its subcomponents do not need to subscribe to redux.

useSelector

then we look atuseSelector

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;
  };
}

useSelectorBycreateSelectorHook()created

andconnectsame, throughReactReduxContextgotProviderofstoreand other data.

useSyncExternalStoreWithSelectorIt is also an empty method, which is/src/index.tsSet asimport { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'ofuseSyncExternalStoreWithSelector,anduseSyncExternalStoreThe effect is similar. it directly subscribes toredux.store.subscribe. When the redux store is updated, it will trigger the update of the components that use it, so as to get the newselectedState

hooksis just state logic, it can’t be likeconnectComponents provide Context to subcomponents in this way, so they can only subscribe directly in redux at the same level. This is the reason mentioned in the “zombie node” problem mentioned at the beginning of the article: hooks do not have nested subscriptions.useSelectorThe code of the 7 version is much more concise than that of the 7 version. It can be found that there is not much after the non-production environment code is removed. In contrast, the 7 version is much more verbose (165 lines). If you are interested, you can take a look.

Derived React principle plus meal

useSelectorThere is another important difference from version 7! Knowing it can help you know more details about React internals!

In version 7, registering a subscription is atuseEffect/useLayoutEffectimplemented in. According to React’s fiber architecture logic, it will traverse the fiber tree in the order of preorder traversal, first usingbeginWorkProcess the fiber and call it when the leaf node is reachedcompleteWork,incompleteWorkwill be such asuseEffectuseLayoutEffectPut it into the effectList, and execute it sequentially in the commit phase in the future. And in the order of preorder traversal,completeWorkis bottom-up, that is to say the child nodesuseEffectIt will be executed before the parent node, so in version 7, the child component hooks are registered earlier than the parent component, and will be executed earlier in the future, which typically falls into the problem of “stale props” and “zombie children” mentioned at the beginning .

Because I know the internal mechanism of React, at first I thought that the hooks of react-redux7 would have bugs, so I passednpm linkI ran the code locally with several test cases, and the result was beyond my expectation.listenerIt is indeed called multiple times, which means that multiple connect components will be updated, just when I thought that the child component would be updated before the parent component, but in the end there is only one render, which is rendered by the top parent connect render, It will drive the following sub-connect updates.

This leads to React’s batch update strategy. For example, in React16, all React events and life cycles are decorated with a logic, and a lock will be set at the beginning, so all update operations such as setState inside will not actually initiate an update, and the lock will be released at the end of the code. Update together in batches. So react-redux just borrowed this strategy to let the components that need to be updated be updated in batches from top to bottom, which stems from one of its inconspicuous places:setBatch(batch), and I misjudged that it would cause problems because I didn’t pay attention to the usefulness here.setBatch(batch)What is actually done will be mentioned later.

Regarding batch updates, let’s take another example. For example, A has a subcomponent B, and B has a subcomponent C, and the setState of C, B, and A are called in sequence. Normally, C, B, and A will be updated once in order, and Batch update will combine three updates into one, update directly from component A, B and C will be updated by the way.

However, the “lock” of this batch update strategy is in the same “macro task”. If there is an asynchronous task in the code, then the setState in the asynchronous task will “escape” the batch update. That is to say, in this case, every Each setState will update the component once. For example, react-redux cannot guarantee that the user will not call in a request callbackdispatch(actually this is too common), so react-redux in/src/index.tsdone insetBatch(batch)operate,batchfromimport { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates'unstable_batchedUpdatesByreact-domThe manual batch update method provided can help out-of-control setState re-batch update. existSubscription.tsneutralcreateListenerCollectionused inbatch

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

sosubscriptioninnerlistenersofnotifyThe method is to manually batch update all update subscriptions. Therefore, in react-redux7, even if the subscription registered by hooks is bottom-up, it will not cause problems.

And react-redux8 directly uses the new APIuseSyncExternalStoreWithSelectorSubscriptions happen during render, so the order of subscriptions is top-down, avoiding the problem of sub-subscriptions being executed first. But version 8 still has the abovebatchThe logic and code are exactly the same as 7, because batch updates can save a lot of performance.

useDispatch

The final part isuseDispatch

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();

useDispatchVery simple, just passuseStore()Get the redux store and returnstore.dispatch, users can use thisdispatchdistributeactionup.

In addition to the above 4 APIs,/src/index.tsThere are still some APIs in it, but we have analyzed the most difficult part, and I believe you can leave the rest to study by yourself.

During the reading of the source code, I wrote some Chinese comments in the react-redux project of the fork, and put it as a new projectreact-redux-with-commentWarehouse, if you need to compare the source code to read the article, you can take a look, the version is 8.0.0-beta.2

last of the last

rightreact-reduxThe analysis of the source code is here. From the initial doubts about the performance of react-redux and reading the source code for the first time, to the curiosity about the solution to the “stale props” and “zombie children” problems on the official website, we drove us to explore more in-depth details. Through the exploration of principles, and then feed back our business applications, and learn from the design of excellent frameworks to trigger more thinking, this is a virtuous circle. Read with your curiosity and questions, and finally apply it to the project, instead of reading for some purpose (such as coping with interviews), a technology that cannot be applied to engineering has no value. If you see the end, it means that you are very curious about technology, and I hope you will always maintain a curious heart.

welcome to follow megithubshare-technologyThis warehouse will occasionally share high-quality front-end technical articles.

Recommended Today

Wonderful review l Rust chat room: Xline cross-data center consistency management

On October 15, 2022, Datan Technology and the Rust language Chinese community cooperated in the Rust chat room event, hosted byShi Jicheng, co-founder of DatenLordI shared about open source distributed storage technology, focusing on Datan Technology’s new open source project Xline, how this cross-cloud metadata (metadata) KV storage product can achieve high-performance cross-data center data […]