Tens of thousands of words long! 2022 latest react redux8 source code super detailed in-depth analysis: Reread react Redux source code

Posted by andrewburgess on Mon, 28 Feb 2022 09:51:48 +0100

The react redux library is no stranger to anyone who is familiar with react. In one sentence, it serves as a bridge between the "redux framework independent data flow management library" and the "react view library", so that the store of redux can be updated in react, and the change of store can be monitored and the related components of react can be updated, Thus, react can manage the state externally (it is conducive to the centralized management of the model, can use the single data flow architecture of redux, the data flow is easy to predict and maintain, and greatly facilitates the communication between components at any level).

The react Redux version comes from the latest version v8.0 as of February 28, 2022 0.0-beta. 2 (it's a little sad that when I read the source code, it was version 7. I didn't expect it to rise to 8 just after reading git pull, so I read 8 again)

Compared with version 7, react Redux 8 includes but is not limited to these changes:

  • Refactoring all with typescript
  • The original Subscription class is reconstructed by createSubscription. The benefits of using closure function instead of class will be mentioned when talking about that part of the code.
  • Using React18 useSyncExternalStore Instead of the subscription update (useReducer internally), useSyncExternalStore and its predecessor useMutableSource It solves the problem of tearing in concurrent mode and makes the code of the library more concise. Compared with the predecessor useMutableSource, useSyncExternalStore doesn't care about the mental burden of immutable of the selector (here is the selector of useSyncExternalStore, not react Redux).


The following part is not directly related to source code analysis, but you can gain something from reading it and understand why you want to write this article. If you want to see the source code analysis directly, you can jump to React Redux source code analysis

Water blowing stage 1 before the text: since it is "re reading", what about "first reading"?

I wonder if you have ever seen comments like this when you visit the Technology Forum: redux has poor performance, mobx is more fragrant

People who like to get to the bottom of the matter (such as me) can't help asking more questions when they see it:

  1. Is the performance of redux bad or react redux bad?
  2. What's the 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 relate redux and react? There are many articles on source code analysis. I once read a very detailed article, but unfortunately, the old version is still using class component, so I decided to see the source code myself at that time. At that time, it was a rough reading. The simple summary after reading is that there are Subscription instances in the Provider, and there are Subscription instances in the high-level component of connect, and there are hooks responsible for its own update: useReducer. The dispatch of useReducer will be registered into the listeners of Subscription. In the listeners, there is a method notify, which will traverse and call each listener, Notify will be registered with the subscribe r of redux, so that the state of redux will be notified to all connect components after being updated. Of course, each connect has a method to check whether it needs to be updated. checkForUpdates can avoid unnecessary updates. I won't talk about the specific details.

In short, I only skimmed the overall logic at that time, but I can answer the above questions:

  1. React redux does have the possibility to be bad. As for redux, each dispatch will ask the state to go through each reducer, and in order to ensure the data immutable will also have additional creation and replication costs. However, if mutable camp libraries modify objects frequently, the object memory structure of V8 will also change from sequential structure to dictionary structure, the query speed will be reduced, and the inline cache will become highly overloaded. On this point, immutable will pull back a little. However, for a clear and reliable data flow architecture, this level of overhead is worth or even ignored in most scenarios.
  2. What is the bad 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 consume performance.
  3. So will react redux have poor performance? Not necessarily. According to the above analysis, if your selector logic is simple (or complex derivative calculations are put in the reducer of redux, but this may not be conducive to building a reasonable model), and there is not much use of connect, then the performance will not be pulled away too much by fine-grained updates such as mobx. In other words, when the business calculation in the selector is not complex and there are few components using global state management, there will be no perceptible performance problems at all. What if the business calculation in the selector is complex? Can it be completely avoided? 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 my purpose and problems. Now the problem has been solved. It is reasonable to say that everything is over. So why does "second reading" start?

Water blowing stage 2 before the text: why "reread"?

Some time ago, I focused on zustand, a React state management library on github.

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

its github This is the beginning of the introduction

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 humanized.
Don't ignore it because it's cute (it seems that the author compares it to a bear, and the cover picture is also a lovely bear). It has many claws and spends a lot of time dealing with common traps, such as the terrible zombie child problem, the React concurrency mode, and the context loss between multiple render ers when using portals. It may be the only state manager in the field of React that can handle all these problems correctly.

There is one thing in it: zombie child problem. When I click in zombie child problem It is the official document of react redux. Let's see what this problem is and how react Redux solves it. If you want to see the original text, you can click the link directly.

"Stale Props" and "Zombie Children"

From v7 After the release of version 1.0, react Redux can use the hooks api. The official also recommends using hooks as the default method in the component. But there are some marginal situations that may happen, and this document makes us aware of these things.

One of the most difficult parts in the implementation of react Redux is that if your mapStateToProps is used in this way, it will be passed into the "latest" props every time. Up to version 4, repeated bug s in the edge scenario have been reported. For example, the data of a list item has been deleted, and an error has been reported in mapStateToProps.

Since version 5, react Redux has tried to ensure the consistency of ownProps. In version 7, there is a custom subscription class inside each connect(), so that when there is another connect in connect, it can form a nested structure. This ensures that the connect component at the lower level in the tree will only receive updates from the store after the connect component of its nearest ancestor is updated. However, this implementation depends on each connect() instance, which overwrites a part of the internal React Context (the subscription part), and uses its own subscription instance for nesting. Then render the child nodes with this new React Context (\ < reactreduxcontext. Provider \ >).

If you use hooks, there is no way to render a context Provider, which means that it cannot have a nested structure for subscriptions. Because of this, "stale props" and "zombie child" problems may recur in the application of "using hooks instead of connect".

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 render again and pass it to the new props of this component
  • However, this component will execute the selector before props update

The results calculated by the old props and the latest store state are likely to be wrong, and even cause errors.

"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 component
  • An action dispatch deletes data in the store, such as an item in a todo list
  • When the parent component is rendered, there will be one item sub component missing
  • 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, part of the data may no longer exist. If the calculation logic does not pay attention, an error will be reported.

useSelector() attempts to solve this problem by capturing all errors in the selector calculation caused by store update. When an error occurs, the component will force the update, and the selector will execute again. This requires that the selector is a pure function and you have no logical dependency. The selector throws an error.

If you prefer to handle it yourself, here's a potentially useful thing to help you avoid these problems when using useSelector()

  • Do not rely on props in the calculation of the selector
  • If you have to rely on props calculation, props may change in the future, and the dependent store data may be deleted, you should write the selector as a precaution. Don't look like state directly todos[props.id]. Name reads the value this way, but first reads state Todos [props. ID], verify whether it exists, and then read todo name
    Because connect adds necessary subscriptions to the context provider, it will delay the execution of sub subscriptions until the connected component is re rendered. This problem can also be avoided if there are connected components on the upper layer of components using useSelector in the component tree, Because the parent connect component has the same store update as the hooks component, the child hooks component will be updated only after the parent connect component is updated, and the update of the connect component will drive the child nodes to update. The deleted nodes have been uninstalled in the update of the parent component: because state.todos[props.id].name, indicating that the hooks component is traversed by the upper layer through ids. So the subsequent sub hooks component updates from the store will not be deleted)

The above explanation may make you understand how the "Stale Props" and "Zombie Children" problems arise and how react redux is probably solved. That is, the updates of the child's connections are nested and collected to the parent's connections. Each redux update does not traverse and update all connections, but the parent updates first, and then the child updates by the parent before triggering the update. But it seems that the appearance of hooks can not solve the problem perfectly, and the specific design details are not mentioned. The confusion and lack of this part is the reason why I am going to read the react redux source code again.

Source code analysis of react Redux

The react Redux version comes from the latest version v8.0 as of February 28, 2022 0.0-beta. two

While reading the source code, I wrote down some Chinese Notes in fork's react Redux project, which was placed as a new project react-redux-with-comment Warehouse. If you need to compare the source code to read the article, you can have a look. The version is 8.0.0-beta two

Before talking about the specific details, I want to talk about the overall abstract design first, so that everyone can read the details with the design blueprint in mind. Otherwise, it is difficult to connect them only by looking at the details and understand how they work together to complete the whole function.

Both the Provider and connect of react Redux provide their own context running through the subtree. All their child nodes can get them and give them their own update methods. Finally, the collection order of root < -- parent < -- child is formed. 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 update, so as to ensure the order in which the child nodes are updated only after the parent node is updated by redux.

If you look at the macro source code for a few times, it has nothing to do with the new source code.

Start with the project construction portal

It can be seen that its umd package is built through rollup (build:umd, build:umd:min), and the esm and commonjs packages are compiled and output through babel (build:commonjs, build:es). Let's just look at build:es: "babel src -- extensions \" js,.ts,.tsx\" --out-dir es". It means to use babel to convert the data in the src directory js,.ts,.tsx files and output them to the ES directory (this is somewhat different from business projects, because npm packages do not need to be packaged into one file, otherwise repeated dependencies may be packaged between different installed npm packages, and each file still remains import ed, just content compilation, and they will be built together in the developer's project).

Let's have a look babelrc. What did JS do

You can see that @ babel / preset typescript in babel's presets is responsible for compiling ts into js, and @ babel / preset env is responsible for compiling the latest ECMA syntax into es5 (only syntax can be compiled, and the api requires additional plug-ins). As for babel plugins, @ babel / transform modules commonjs solves the problem of repeated helpers in babel. You can introduce api polyfill in the unified corejs library as needed. Here, you can decide whether to use esm or commonjs helpers through the configuration of useESModules, but Official documents This configuration has been abandoned since 7.13.0. You can directly use package JSON exports. Other plugins are also related to syntax compilation, such as the compilation of syntax such as private methods, private attributes, static attributes, jsx and decorators, and @ Babel / plugin transform modules commonjs, a library that introduces esm into syntax and compiles it into commonjs, which is controlled by the environment variable NODE_ENV determines whether to use it. It determines whether the final output is the esm library or the commonjs library.

According to package module field of JSON( About the priority of main, module and browser fields ), the final entry is ES / index. In the root directory JS, because it is output by babel according to the source directory, the source code entry is Src / index ts.

Cut into common APIs

As can be seen from the above figure, the output of the entry file is only batch and exports All exports of TS files, so let's look at exports ts

Among them, Provider, connect, useSelector and useDispatch occupy most of the scenarios we usually use, so we start from these four APIs.

Provider

The Provider is from Src / components / Provider tsx.

It is a React component, which does not have any view content. It finally shows children, but adds a layer to the outside of children Context Provider , which is why this api is called Provider. What exactly does this component want to pass down.

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

You can see that the transparent transmission is an object composed of store, subscription and getServerState. Let's talk about the functions of the three attributes of the object.

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

Subscription is created by the object factory createSubscription, which generates the subscription object, which is the key to the subsequent nested collection subscription. The code details of createSubscription will be described later.

getServerState is newly added in version 8.0.0. It is used to obtain a snapshot of the server-side state during the initial "water injection" hydrate in SSR, so as to ensure the consistency of the states at both ends. Its control is entirely in the developer's hands. Just give the state snapshot to the Provider component through the prop serverState. Those who do not understand the concepts related to SSR and hydrate can read an article by Dan Abramov discussions , although its topic is not specifically about SSR, it introduces its related concepts at the beginning, and Dan's article has always been 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>;

The latest state is obtained once and named previousState. As long as the store singleton does not change, it will not be updated. redux singletons are unlikely to change in general projects.

Useisomorphiclayouteeffect is just a facade , 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 uselayouteeffect on the server will throw a warning. In order to bypass it, use useEffect on the server instead. Secondly, why do you have to do it in useLayoutEffect/useEffect? Because a store update may occur between the render stage and the side effect stage, if you do it during render, you may miss the update. You must ensure that the callback of store subscription has a selector from the latest update. At the same time, ensure that the creation of store subscription must be synchronous. Otherwise, a store update may occur before the subscription (if the subscription is asynchronous). At this time, the subscription has not been created, resulting in inconsistent status.

If you don't understand the reason, you can understand it in combination with the following examples.

The Provider does this in useisomorphiclayouteeffect:

subscription.trySubscribe();

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

First collect the subscriptions of the subscription, and then check whether the latest status is consistent with the previous status in render. If not, notify the update. If this paragraph is not placed in uselayouteeffect / useeffect, but in render, it only subscribes to itself, and its sub components do not subscribe. If the sub components update the redux store during rendering, the sub components will miss the update notification. At the same time, react's useLayoutEffect/useEffect is called from bottom to top, the subcomponent is called first, and the parent component is called back. Since it is the root node of react Redux, its uselayouteffect / useeffect will be called at last. At this time, it can ensure that all the subcomponents that should be registered and subscribed are registered, and also ensure that the updates that may occur in the rendering process of subcomponents have occurred. So read the state for the last time and compare whether you want to notify them of updates. This is why uselayouteffect / useeffect is selected.

Next, let's take a complete look at what the Provider does in useisomorphiclayouteeffect

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 onStateChange of subscription (it is initially an empty method and needs to be injected into the implementation). It will be called when the update is triggered. It hopes to call subscription in the future notifyNestedSubs,subscription. Notifynestedsubscriptions will trigger all sub subscriptions collected by this subscription. In other words, the update callback here is not directly related to the "update", but the update method of the child nodes.

And then called subscription.. Trysubscribe(), which will give its 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. If not, it will call subscription Notifynestedsubscriptions(), which will trigger all sub subscriptions collected by this subscription to update them.

The function related to logoff is returned. It will logoff the subscription at the parent level and replace the subscription Onstatechange resets the null method. This function will be called when the component is unloaded or re rendered (only when the store changes) (the feature of react useEffect).

The Provider involves subscription in many places. The methods of subscription only talk about the general functions. The details of subscription will be discussed in the later part of subscription.

The complete Provider source code and comments are as follows:

function Provider<A extends Action = AnyAction>({
  store,
  context,
  children,
  serverState,
}: ProviderProps<A>) {
  // An object for context transparent transmission is generated, including functions that may be used in redux store, subscription instance and 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, it is called previousState
  const previousState = useMemo(() => store.getState(), [store]);

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

    // Judge whether the state changes after rendering. If it changes, all sub subscription updates will be triggered
    if (previousState !== store.getState()) {
      subscription.notifyNestedSubs();
    }
    // Logout operation during component uninstallation
    return () => {
      subscription.tryUnsubscribe();
      subscription.onStateChange = undefined;
    };
  }, [contextValue, previousState]);

  const Context = context || ReactReduxContext;

  // Finally, the Provider component is only used to transparently transmit the contextValue, and the component UI completely uses children
  return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}

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

Subscription/createSubscription subscription subscription factory function

Here we will talk about the subscription part of the Provider, which has a high exposure rate. It is the key to the nested collection of subscriptions by react redux. In fact, the title of this part is called subscription. Before version 8.0.0, react Redux did implement it through Subscription class. You can use new Subscription() to create subscription instances. However, after 8.0.0, it has become the createSubscription function to create the subscription object and replace the original attributes with closures internally.

One advantage of replacing class with function is that you don't need to care about the direction of this. The method returned by the function will always modify the internal closure. There will be no problem that the direction of this will change after the class method is assigned to other variables, which reduces the mental burden during development. Closures are also more private, increasing variable security. At the same time, in a library that supports hooks, the function implementation is more in line with the development paradigm.

Let's take a look at the abstract code of createSubscription. The responsibilities of each are written in the comments

Note: the "subscription callback" below specifically refers to the update method of the component triggered after the redux status update. The component update method is collected by the parent subscription, which is a subscription publishing mode.

function createSubscription(store: any, parentSub?: Subscription) {
  // Whether you are subscribed or not
  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 whether you are subscribed
  function isSubscribed() {}

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

  // Unregister your subscription from the parent
  function tryUnsubscribe() {}

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

  return subscription;
}

The createSubscription function is an object factory that defines some variables and methods, and then returns an object subscription that owns these methods

First, take a look at the handleChangeWrapper. From its name, you can see that it is just a shell

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

The onstatechange method is actually called inside. The reason is that when the subscription callback is collected by the parent, its own callback may not be determined, so a shell is defined for collection. 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 in provider In the source code of TS, make a subscription before collecting subscriptions onStateChange = subscription. Reason for notifynestedsubs.

Then look at trySubscribe

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

    listeners = createListenerCollection();
  }
}

Its function is to let the parent's subscription collect its own subscription callback. First, it will judge that if unsubscribe marks that it has been subscribed, it will not do anything. Secondly, it will judge whether the second parameter parentSub when creating a subscription is empty. If there is a parentSub, it means that there is a parent subscription on its upper layer. Then it will call the addNestedSub method of the parent to register its subscription callback to it; Otherwise, you think you are on the top level, so you register with redux store.

From this, we need to see what the addNestedSub method is

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

addNestedSub cleverly uses recursion, which calls trySubscribe. Therefore, they will achieve such a goal. When the lowest subscription initiates trySubscribe to be collected by the parent, it will first trigger the parent's trySubscribe and continue to recurse until the root subscription. If we think of such a hierarchical structure as a tree (in fact, subscription.trySubscribe does occur in the component tree), Then it is equivalent to that the parent will collect subscriptions from the root node to the leaf node in turn. 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 the callback shell handleChangeWrapper is designed. Only this callback shell is registered. After the callback is set by the non leaf node in the future, it can be triggered by the shell.

After the "delivery" process, the subscription callback handleChangeWrapper from the root node to the leaf node is being collected by the parent. The "return" process is traced back to its own work return listeners Subscribe (listener) collects the subscription callback of the sub subscription into the collector listeners (the relevant handleChangeWrapper will be triggered when the update occurs in the future, and it will indirectly call to collect all listeners).

So the addNestedSub of each subscription does two things: 1 Let your subscription callback be collected by the parent first; 2. Collect subscription callback of sub subscription.

Combined with the explanation of addNestedSub and looking back, trySubscribe wants its subscription callback to be collected by the parent, so when it is passed into the parent subscription, it will call its addNestedSub, which will cause each layer of subscription from the root subscription to be collected by the parent callback, so each subscription nested and collected their child subscriptions, Thus, it is possible for the child to update only after the parent is updated. At the same time, because of the lock of unsubscribe, if the trySubscribe of a parent subscription is called, the "nested registration" will not be triggered repeatedly.

Above, we have analyzed what happens during "nested registration". Next, let's take a look at the substantive operation of registration, listeners What does subscribe do and how is the registered data structure designed.

function createListenerCollection() {
  const batch = getBatch();
  // For the collection of listener, listener is a two-way 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;
        }
      });
    },

    // Returns 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;
        }
      };
    },
  };
}

The listeners object is created by createListenerCollection. The listeners method is not many and the logic is easy to understand. It is composed of clear, notify, get and subscribe.

Listeners are responsible for collecting listeners (that is, subscription callbacks). Listeners internally maintain listeners as a two-way linked list, with the first node and the last node as the head node.

The clear method is as follows:

clear() {
  first = null
  last = null
}

Used to empty the linked list of the collection

The notify method is as follows:

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

It is used to traverse and call the linked list node. Batch can be simply understood here as the function calling the input parameter. Many React principles (such as batch update, fiber, etc.) can be derived from the details, which will be mentioned at the end of the article.

The get method is as follows:

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

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

The subscribe method is as follows:

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 nodes
  if (listener.prev) {
    listener.prev.next = listener
  } else {
    // If the linked list does not have a node, it is the first node
    first = listener
  }

  // unsubscribe is the operation of deleting a specified node in a two-way linked list
  return function unsubscribe() {
    // Prevent meaningless execution
    if (!isSubscribed || first === null) return
    isSubscribed = false

    // If the added node already has subsequent nodes
    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 this node is the last one, and the prev node is used as the last node
      last = listener.prev
    }
    // If there is a front 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, and 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 cancel the subscription. It involves the addition and deletion of the linked list. See the notes for details.

Therefore, each subscription collection subscription actually maintains a two-way linked list.

The last part of the subscription is notifynestedsubscriptions and tryUnsubscribe

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

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

notifyNestedSubs called listeners Notify. According to the above analysis of listeners, all subscriptions will be traversed and called here

Try unsubscribe is the operation related to logoff, this Unsubscribe is injected during the execution of the trySubscribe method. It is the return value of the addNestedSub or redux subscribe function and the undo operation of unsubscribing. In this The operations under unsubscribe () are clear unsubscribe and clear listeners respectively.

So far, the analysis of subscription is finished. It is mainly used to collect subscriptions nested during nested calls, so that the subscription callback of child nodes can be executed only after the parent is updated, so as to update after the parent is updated. People who don't know about react Redux may wonder, isn't it only the Provider component that uses subscription? Where are the nested calls? Where did you get the collection sub subscription? Don't worry. Later, we will talk about the connect higher-order function, which also uses subscription, which is nested here.

connect advanced components

8.0.0 starts with connect Tsx replaces connectadvanced JS is essentially a multi-layer high-order function, but the reconstructed connect Tsx structure is more clear and intuitive.

We all know that when using connect, it is: connect(mapStateToProps, mapDispatchToProps, mergeProps, connectOptions)(Component), so its entry should receive mapStateToProps, mapDispatchToProps and other parameters, return a high-order function that receives Component parameters, and this function finally returns JSX Element.

If you simply look at the structure of connect, it is as follows:

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

If you break down what connect does, I think there are some other details: subscribing to your own updates from the parent, selecting data from the Redux store, and judging whether updates are needed

selector of connect

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

The match function is the first one 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;
}

As a factory array, factories will be traversed and called by the arg parameter passed in. Each factory will detect and process arg, and arg here is mapStateToProps, mapDispatchToProps and mergeProps written in our development. It will not return until factories[i](arg) has a value. If it is not true all the time, an error will be reported. Factories are like the chain of responsibility model. Their own factory responsibilities will be processed and returned.

factories are different in initMapStateToProps, initMapDispatchToProps and initMergeProps. They are defaultMapStateToPropsFactories, defaultMapDispatchToPropsFactories and defaultMergePropsFactories respectively. 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,
];

Traversing the defaultMapStateToPropsFactories is to call the whenMapStateToPropsIsFunction and whenMapStateToPropsIsMissing factories. From the name, the first one is processed when mapStateToProps is a function, and the second one is processed when mapStateToProps is omitted.

The wrapmaptopropsfunction (i.e. whenMapStateToPropsIsFunction) wraps mapToProps in a proxy function, which does several things:

  1. Detects whether the called mapToProps function depends on props, which is used by selectorFactory to determine whether it should be called again when props changes.
  2. On the first call, if mapToProps returns another function, mapToProps is processed and the new function is processed as the real mapToProps for subsequent calls.
  3. On the first call, verify whether the result is a flat object to warn the developer that the mapToProps function does not return a valid result.

Wrapmaptopropsiconstant function (i.e. whenMapStateToPropsIsMissing) will return an empty object in the future by default (not immediately, but a higher-order function). When there is a value, it is expected that the value is a function, pass dispatch into the function, and finally return the return value of this function (also not immediately)

The other two factory groups, defaultMapDispatchToPropsFactories and defaultMergePropsFactories, have the same responsibilities as defaultMapStateToPropsFactories and are essentially responsible for handling arg s in different case s

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

const defaultMergePropsFactories = [
  whenMergePropsIsFunction,
  whenMergePropsIsOmitted,
];

I believe you can guess what they are responsible for through their names. I won't elaborate on them one by one.

After match processing, three high-order functions initMapStateToProps, initMapDispatchToProps and initMergeProps are returned
Finally, the 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,
};

They and other properties make up an object called selectorFactoryOptions

Finally, it is handed over to defaultSelectorFactory for use

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

The childPropsSelector is the function that finally returns the value it really needs (it is really the end of the higher-order function ~)

So finally, we only need to see what the defaultSelectorFactory function does. It is actually called 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);
}

mapStateToProps, mapDispatchToProps and mergeProps are functions that return their final values. More attention should be paid to the pureFinalPropsSelectorFactory function

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 closure hasRunAtLeastOnce is used to distinguish whether it is called for the first time. The first and subsequent functions are different. If it is called for the first time, the handleSubsequentCalls function is used. It generates stateProps and dispatchProps, then puts them into mergeProps to calculate the final props, and sets hasRunAtLeastOnce to true, This is not the first time.

handleSubsequentCalls is used for subsequent calls. Its main purpose is to use cached data if the state and props have not changed (the judgment method of whether the state and props have changed is passed in externally, and the component can certainly know whether it has changed). If the state and props have changed or only one of them has changed, Then call their respective functions respectively (mainly to judge whether to re execute according to the static attribute dependsOnOwnProps) to get the new value.

Therefore, the childPropsSelector function is the returned pureFinalPropsSelector function. The closure is accessed internally, and the closure saves the persistent value. Therefore, when the component is executed multiple times, you can decide whether to use cache to optimize performance.

The analysis related to selector is finished.

In general, if you want to implement the simplest selector, you 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 is React Redux so complicated. In order that the connect component can use the mergedProps value of fine-grained cache to improve performance when executing multiple times, React can only use memo when wrapperProps remain unchanged, but it is difficult to make a more fine-grained distinction. For example, it is difficult to know whether the selector depends on props, so that even if props changes, it does not need to be updated. To achieve this, a large number of nested high-order functions are required to store the persistent closure intermediate value, so that the state will not be lost when the component is executed many times, so as to judge the update.

Now we are going to talk about something else. If you are a little dizzy about a series of call stacks, you just need to remember that seeing childPropsSelector is the value after returning the selector.

connect updated registered subscriptions

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

  // ............
  // ............
}

Firstly, the props related to actual business props and behavior control are divided from props. The so-called business props refers to the props actually transmitted from the parent component of the project to the connect component, and the behavior control props is forward ref props unrelated to business such as, context and related to internal registration subscription. And use useMemo cache to understand the value after construction.

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. However, a judgment is made here. If the user passes in a custom context through props, the user-defined context is preferred. Otherwise, use the React.createContext that can be regarded as global (also used by the 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 get the store (it may come from props or context) and the rendering state of the server (if any). Then a selector function that can return the selected value is created. The details of selector are described above.

Here are the key points of subscription!

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 are described above. There is a logic of nested subscription, which will be used here. The third parameter of createSubscription is passed into the subscription instance in the context. According to the nested subscription logic (if you forget, you can look back to the function to create a subscription instance, and what role the third parameter of createSubscription plays), the subscription callback in this connect is actually the contextvalue registered with the parent For subscription, if the parent is a top-level Provider, its subscription callback will be truly registered with redux. If the parent is not a top-level Provider, it will still be nested and registered like this. Through this, the "parent updates first - child updates later" is realized, so as to avoid the problems of expired props and zombie nodes.

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

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 a batch of "persistent data" (which will not be initialized with the repeated execution of the component) is defined. These data are mainly used for future "update judgment" and "updates driven by the parent component and updates from the store do not occur repeatedly", which will be used later.

We only saw the creation of subscription, but didn't update the relevant information. The next code will go to.

const subscribeForReact = useMemo(() => {
  // Here you subscribe to updates and return a function to unsubscribe
}, [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 will be seen later. It is mainly used to judge whether to update. It is the main entrance to initiate the update.

useIsomorphicLayoutEffectWithArgs is a tool function. The internal function is useisomorphiclayouteeffect. 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 is the cache dependency of useisomorphiclayouteeffect.

The first parameter to be executed is captureWrapperProps. Its main function is to judge that if it is an update from the store, the subscription will be triggered after the update is completed (such as useEffect) Notifynestedsubs to notify sub subscriptions of updates.

Then it wants to generate actualChildProps, which is the props required by the selected business component. useSyncExternalStore is mainly used. If you look in the code of useSyncExternalStore, you will find that it is an empty method, and the direct call will throw an error, so it is injected externally. At the entrance In TS, initialize connect (useSyncExternalStore) initializes it. useSyncExternalStore comes from React. So the actual childprops is actually React useSyncExternalStore (subscribeforreact, actualchildpropsselector, getserverstate? () = > the result of childpropsselector (getserverstate(), wrapperprops): actualchildpropsselector).

useSyncExternalStore It is a new API of react18, formerly known as useMutableSource , in order to prevent the third-party store from being modified after the task is interrupted in the concurrent mode, tearing occurs when the task is restored, resulting in inconsistent data. The update of external store can cause the update of components through it. Before react-redux8, it was manually implemented by useReducer. This is the first time that react-redux8 uses the new API. This also means that you have to use React18 +. But I think react redux8 can actually use ship: import {usesyncexternal store} from 'use syncexternal store / ship'; To achieve downward compatibility.

The first parameter of useSyncExternalStore is a subscription function, which will cause the update of the component when the subscription is triggered. The second function returns an immutable snapshot, which is used to mark whether to update and get the returned results.

Let's take a look at what the subscription function subscribeForReact does.

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 of all, use useMemo to cache the function, and use useCallback can also be used. Personally, I think useCallback is more semantic. This function actually calls subscribeUpdates. Let's take a look at 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;
}

The key point is checkForUpdates, which obtains the latest Store status: laststorestate (note that it is still obtained manually here, and react Redux will give it to uSES in the future), and the latest props to be handed over to the business component: newchildprops. If childProps is the same as the last time, it will not be updated, and the child connect will be notified to try to update. If childProps changes, react. Is called The update method passed in by usesyncexternalstore, here called additionalSubscribeListener, will cause component updates. react-redux8 used to use useReducer's dispatch here. checkForUpdates will be handed over to subscription Onstatechange, which we analyzed earlier, subscription Onstatechange will eventually be nested and called when the redux store is updated.

The subscribeUpdates function also calls subscription Trysubscribe() collects onStateChange into the parent subscription. checkForUpdates is then called in case the data changes during the first rendering. Finally, a function to unsubscribe is returned.

According to the above analysis, the actual update of the component is completed by checkForUpdates. It is called in two ways:

  1. After the redux store is updated, it is called by the parent cascade
  2. The component itself renders (driven by the parent render and the component itself state), and the snapshot of useSyncExternalStore changes, resulting in the call

We will find that in a total update, the checkForUpdates of a single connect will be called multiple times. For example, an update from redux causes the parent to render, and its child element has a connect component. Generally, we will not do memo to the connect component, so it will also be rendered. Just as its selectorProps has also changed, so checkForUpdates is called during render. When the parent updates, it triggers its own listeners, causing the checkForUpdates of the child connect to be called again. Won't this make the component re render multiple times? When I first looked at the code, I had such a question. After the brain simulates the code scheduling of various scenarios, it is found that it avoids repeated render in this way. It can be divided into these scenarios:

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

There are no changes in stateFromStore and props in 6 of them. actualChildProps directly uses the cache results, does not call checkForUpdates, and does not worry about multiple render ings

The updates of 1 and 2 come from the redux store, so the parent component must be updated first (unless the connection is the top layer of the Provider). The connection is updated after the connection. During the connect render, the props from the parent component may change, and its own stateFromStore may also change. Therefore, checkForUpdates is called, and useRef childPropsFromStoreUpdate is set to a new childProps, Interrupt the current render and re render. The component obtains the new childProps value in render. Next, the useEffect of the parent connect component brings the second wave of checkForUpdates. At this time, childProps is no different from the last time, so it will not be updated. It just triggers the checkForUpdates of the lower level sub connect. The lower level connect logic is the same.

Type 3 and 4 updates are actually part of 1 and 2, so I won't go into detail.

5 type of update 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 process, childPropsSelector(store.getState(), wrapperProps) obtains the latest childProps, which is obviously changed. Therefore, checkForUpdates and the subsequent redux dispatch update childProps are the same as the last time, so we only go to notifyNestedSubs.

So far, the update of all links in all scenarios has a closed loop.

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;

WrappedComponent is the business component passed in by the user, contexttouse The provider will pass the subscription of the connection to the lower layer. If there is a connection in the business component, the subscription can be nested. Whether context transparent transmission is required is determined by the shouldHandleStateChanges variable. If mapStateToProps is not available, it is false. In other words, if you don't even have mapStateToProps, this component and its subcomponents don't need to subscribe to redux.

useSelector

Then let's take a look at 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 is created by createSelectorHook()

Like connect, you can get the Provider's store and other data through ReactReduxContext.

useSyncExternalStoreWithSelector is also an empty method and is used by / SRC / index TS is set to import {useSyncExternalStoreWithSelector} useSyncExternalStoreWithSelector from 'use sync external store / with selector', which is similar to useSyncExternalStore. It subscribes directly to redux store. subscribe. When the Redux store is updated, it will trigger the update of the components using it, so as to get a new selectedState.

Hooks is only state logic. It cannot provide Context to sub components like connect components, so it can only subscribe directly in redux at the same level. This is what was mentioned in the "zombie node" problem at the beginning of the article: hooks does not have nested subscriptions. The code of useSelector is much more concise than that of version 7. It can be found that there is not much after removing the non production environment code. Compared with version 7, it is much lengthy (165 lines). You can go and have a look if you are interested.

Derived from React principle

There is another important difference between useSelector and version 7! Understanding it can help you know more about the internal details of React!

In version 7, registered subscriptions are executed in useEffect/useLayoutEffect. According to the fiber architecture logic of React, it will traverse the fiber tree in the order of previous traversal. First, begin work is used to process the fiber. When it reaches the leaf node, completeWork is called, in which completeWork will put such as useEffect and uselayouteeffect into the effectList, which will be executed sequentially in the commit phase in the future. According to the previous traversal order, completeWork is from bottom to top, that is, the useEffect of the child node will be executed before the parent node. Therefore, in version 7, the child component hooks is registered earlier than the parent component and executed earlier in the future. This typically falls into the problems of "stay props" and "zombie children" mentioned at the beginning.

Because I know the internal mechanism of React, at first I thought there would be a bug in hooks of React redux7, so I ran the code locally with several test cases through npm link. The result was unexpected. listener was indeed called many times, which means that multiple connect components will be updated. When I thought that the child components will be updated before the parent components, But the final render is only once. It is generated by the top-level parent connect render, which will drive the following child connect updates.

This leads to the batch update strategy of React. For example, in React16, all React events and life cycles are decorated with a logic, and a lock will be set at the beginning. Therefore, all update operations such as setState will not really initiate updates. After the code is unlocked at the end, they will be updated together in batches. Therefore, React Redux just borrows this strategy to make the components that need to be updated in batches from top to bottom, which stems from its humble place: setBatch(batch), and I misjudged it because I didn't pay attention to its use. What setBatch(batch) actually did will be discussed later.

As for batch update, take another example. For example, a has sub component B and B has sub component C, which call the setState of C, B and a in sequence respectively. Normally, C, B and a will be updated once in sequence, while batch update will combine the three updates into one. It will be updated once directly from component A, and B and C will be updated accordingly.

However, the "lock" of this batch update strategy is in the same "macro task". If there is an asynchronous task in the code, the setState in the asynchronous task will "escape" batch update, that is, in this case, the component will be updated every time the setState is updated. For example, react Redux cannot guarantee that the user will not call dispatch in a request callback (actually, this is too common), so react Redux is in / SRC / index The operation of setBatch(batch) is done in TS, and the batch is from import {unstable_batchupdates as batch} from '/ utils/reactBatchedUpdates',unstable_batchedUpdates is a manual batch update method provided by react DOM, which can help the uncontrolled setState to update in batches again. In subscription Batch is used in createListenerCollection in TS:

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

Therefore, the notify method of listeners in the subscription will manually update all update subscriptions in batches. Thus, in react-redux7, even if the subscription registered by hooks is bottom-up, it will not cause problems.

While react-redux8 directly uses the new API useSyncExternalStoreWithSelector to subscribe, which occurs during render, so the order of subscription is top-down, avoiding the problem of sub subscription executing first. However, as like as two peas, the 8 version still has the logic of the batch, which is exactly the same as the 7, because mass update can save a lot of performance.

useDispatch

The last part of 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 is very simple, that is, get the redux store through useStore() and return to store Dispatch, users can use this dispatch to dispatch action s.

In addition to the above four APIs, / SRC / index There are still some APIs in TS, but we have analyzed the most difficult part. I believe you can study the rest by yourself.

While reading the source code, I wrote down some Chinese Notes in fork's react Redux project, which was placed as a new project react-redux-with-comment Warehouse. If you need to compare the source code to read the article, you can have a look. The version is 8.0.0-beta two

Last last

That's all for the analysis of react Redux source code. From the initial doubt about the performance of react Redux to reading the source code for the first time, to the later curiosity about the solutions to the problems of "stay props" and "zombie children" on the official website, we are driven to explore more in-depth details. Through the exploration of principles, and then feed back our application in business, and draw lessons from the design of excellent framework to trigger more thinking, which is a virtuous circle. Read with your curiosity and questions, and finally apply them to the project, rather than reading for some purpose (such as dealing with an interview). A technology that cannot be applied to engineering has no value. If you see the end, it shows that you are very curious about technology. I hope you will always keep a curious heart.

Welcome to my githubshare-technology This warehouse will share high-quality front-end technical articles from time to time.

Topics: Front-end React redux hooks