Redux design Philosophy and Working Principle revealed (PART 2)

In the last lecture, I tried to disassemble the Redux source code, understood the basic structure and main modules of the Redux source code, and deeply understood the working logic of the core module of createStore. This lecture will further analyze the two specific methods of dispatch and subscribe, and recognize the most core dispatch action in Redux workflow and the unique “publish-subscribe” mode of Redux.

1. The core of the Redux workflow: the Dispatch action

Dispatch is probably the API we are most familiar with when working with Redux. Based on the previous interpretation of the design philosophy, there are three key elements in Redux:

  • action

  • reducer

  • store

Dispatch is the core of the Redux workflow because it connects the actions, reducer, and Store. The internal logic of Dispatch is sufficient to reflect the process of “matching” between the three.

The dispatch logic is extracted from createStore.

Function dispatch(action) {// Check whether the action is valid if (! isPlainObject(action)) { throw new Error( 'Actions must be plain objects. ' + 'Use custom middleware for async actions.' If (typeof action.type === 'undefined') {throw new Error('Actions may not have an undefined "type" property. ' + 'Have you misspelled a constant? ')} // If you are already in the process of dispatch, If (isDispatching) {throw new Error('Reducers may not dispatch actions.')} try {// Execute the reducer Before, "lock" is used to mark that the dispatch execution process already exists. IsDispatching = true // Call reducer, CurrentState = currentReducer(currentState, action)} finally { // Constant listeners = (currentListeners = nextListeners); for (let i = 0; i < listeners.length; i++) { const listener = listeners[i]; listener(); } return action; }Copy the code

Here, combined with the source code, the workflow of Dispatch is extracted as follows:

There are two points to ponder in this workflow.

①. Avoid “nesting” dispatches by “locking”

The most critical step in the Dispatch workflow is the reducer step, which corresponds to the following code:

Try {// Before the reducer is executed, lock the reducer to mark that the dispatch execution process already exists. IsDispatching = true // Call the reducer, CurrentState = currentReducer(currentState, action)} finally { Dispatch is allowed again isDispatching = false}Copy the code

Reducer is essentially a store update rule that specifies how changes in application state should be sent to the Store in response to actions. In this code, reducer was called and currentState and action were passed in, which corresponds to the process of Action → Reducer → store in the “Redux Workflow from the perspective of coding” diagram in Lesson 06, as shown in red below:

Before calling reducer, Redux first sets the isDispatching variable to true, and after the reducer execution is complete, sets the isDispatching variable to false*. You should be familiar with this operation, since setState batch processing was done in lecture 12 using a similar “lock” approach.

In this case, isDispatching is used to lock the dispatch process in order to avoid the “nesting mode” dispatch. To be more precise, this is to avoid a developer manually calling dispatches from the Reducer.

What is the point of banning nesting dolls? First of all, from the point of view of design, as a “special function for calculating state”, Redux emphasized when designing reducer that it must be “pure” and should not perform any “dirty operation” except calculation. Dispatch call is obviously a “dirty operation”. Secondly, from the perspective of execution, if dispatch is really called from the reducer, then dispatch will call the reducer in turn, and the reducer will call dispatch…… again In this way, repeated calls to each other will enter an endless loop, which is a very serious misoperation.

Therefore, in the pre-check logic of Dispatch, once isDispatching is detected to be true, it directly throws Error (see the code below) to nip the dead loop in the cradle:

if (isDispatching) {
  throw new Error('Reducers may not dispatch actions.')
}
Copy the code

②. Trigger the subscription process

After the reducer execution is complete, a subscription triggering process will start, which corresponds to the following code:

// Listeners = (currentListeners = nextListeners); for (let i = 0; i < listeners.length; i++) { const listener = listeners[i]; listener(); }Copy the code

While reading the source code, you may have two questions:

  • In Lecture 06 we didn’t introduce the SUBSCRIBE API, nor did we talk about listeners. How do they fit into the main Redux process?

  • Why are there currentListeners and nextListeners? This doesn’t look like the publish-subscribe model you’re used to.

To understand these two issues, you need to understand the SUBSCRIBE API.

2. Publish-subscribe in Redux: Recognize subscribe

The listeners array executed in Dispatch comes from a subscription, which requires a call to SUBSCRIBE. Subscribe is not a strictly necessary method in real development, and is called only when it is necessary to listen for changes in state.

Subscribe receives as an input a listener of type Function, which returns the unbinding Function of that listener. We can get a simple idea of how to use subscribe with this example code:

Const unsubscribe = store. Subscribe (handleChange) unsubscribe()Copy the code

Subscribe only needs to be passed in a listener function, not an event type. This is because the default object to subscribe to in Redux is the “change in state (call to the Dispatch function, to be exact)” event.

This is the answer to the first question about SUBSCRIBE: How does SUBSCRIBE fit into the main Redux process? Listeners can be registered by calling Store. Subscribe after the store object is created, or untaped by calling the subscribe return function. Listeners are maintained using an array of Listeners. When dispatch action occurs, Redux executes listener functions in the Listeners array one by one after reducer execution is complete. This is the relationship between SUBSCRIBE and the Redux main flow.

Next, combine the source code to analyze the internal logic of SUBSCRIBE, SUBSCRIBE source extraction is as follows:

Function subscribe(listener) {// check the typeof listener if (typeof listener! == 'function') {throw new Error('Expected the listener to be a function.') (isDispatching) { throw new Error( 'You may not call store.subscribe() while the reducer is executing. ' + 'If you would  like to be notified after the store has been updated, subscribe from a ' + 'component and invoke store.getState() in the callback to access the latest state. ' + 'See https://redux.js.org/api-reference/store#subscribe(listener) for more details.')} // This variable is used to prevent multiple calls to the unsubscribe function let isSubscribed = true; / / make sure nextListeners does not point to the same reference with currentListeners ensureCanMutateNextListeners (); // Register a listener function nextListeners. Push (listener); Return function unsubscribe() {if (! isSubscribed) { return; } isSubscribed = false; ensureCanMutateNextListeners(); const index = nextListeners.indexOf(listener); // Remove the current listeners from the nextListeners array. }; }Copy the code

With this source code, the work flow of SUBSCRIBE can be extracted as follows:

There is a step in the workflow lets a person hard to not care, that is the call of ensureCanMutateNextListeners. Combined with the overall analysis of the source, in front of the can know ensureCanMutateNextListeners role is to ensure that won’t currentListener nextListeners point to the same reference. So why do it? CurrentListeners and nextListeners are two arrays of listeners.

To understand this, it’s important to understand how the subscription and publishing processes in Redux each process the listeners’ array.

① An array of Listeners during the subscription process

The first encounter between the two listeners occurs during the variable initialization phase of createStore, where the nextListeners are assigned currentListeners (see code below), after which they do point to the same reference.

let nextListeners = currentListeners
Copy the code

But is called, for the first time in the subscribe ensureCanMutateNextListeners will find it, then nextListeners correcting for a content consistent with currentListeners, but different new object references. The corresponding logic is shown in the following code:

Function ensureCanMutateNextListeners () {/ / if two point to the same array references if (nextListeners = = = currentListeners) {/ / would nextListeners NextListeners = currentListeners. Slice ()}}Copy the code

In the logic of the subscribe, ensureCanMutateNextListeners before each will be registered with the listener is called unconditional, to ensure that two different array reference. Follow after ensureCanMutateNextListeners enforce a listener registration logic, can be seen in the corresponding source listener will eventually be registered to nextListeners array:

nextListeners.push(listener);
Copy the code

Let’s take a look at the event publishing process.

② an array of Listeners during the release process

The subscription action is triggered by dispatch, and the source code is as follows:

// Listeners = (currentListeners = nextListeners); for (let i = 0; i < listeners.length; i++) { const listener = listeners[i]; listener(); }Copy the code

CurrentListeners are assigned to nextListeners when the subscription is triggered, and currentListeners are assigned to arrays of listeners that are actually executed. Therefore, the array of listeners that are ultimately executed actually points to the same reference as the current nextListeners.

This is a bit of a curious process: Registering a listener is also a nextListeners process, and triggering a subscription is also a nextListeners process (in fact, you may notice that canceling a listener is also an array of nextListeners). So what’s the use of currentListeners?

CurrentListeners array is used to ensure the stability of listener function execution

Because any changes are made on nextListeners, a stable currentListeners are needed to ensure that they are running flawlessly.

For example, the following operation is perfectly legal in Redux:

A function listenerA() {} // define A listener function B function listenerB() {// unSubscribeA from B C function listenerC() {} // subscribe B store.subscribe(listenerB) // subscribe C store.subscribe(listenerC)Copy the code

The Listeners are listeners A, B, and C.

[listenerA,  listenerB, listenerC]
Copy the code

The next call to Dispatch will execute the following logic that triggers the subscription:

// Listeners = (currentListeners = nextListeners); for (let i = 0; i < listeners.length; i++) { const listener = listeners[i]; listener(); }Copy the code

The problem occurs when the for loop reaches index I = 1 and the listener is listenerB: the unSubscribeA action is executed in listenerB. However, based on our analysis above, listeners register, unbind, and trigger function actions that actually affect the process. To reinforce this point, review the unsubscribe source:

Return function unsubscribe() {// Avoid unsubscribe(! isSubscribed) { return; } isSubscribed = false; / / familiar with operation, call ensureCanMutateNextListeners method ensureCanMutateNextListeners (); Const index = nextListeners. IndexOf (Listener); // Remove the current listeners from the nextListeners array. };Copy the code

If say there is no currentListeners, that means don’t need ensureCanMutateNextListeners this action. If no ensureCanMutateNextListeners unsubscribeA (), after listenerA will disappear from listeners array and nextListeners array at the same time (because both point to the same reference). The listeners are now left with only two elements, listenerB and listenerC, which look like this:

[listenerB, listenerC]
Copy the code

The length of the listeners array changes, but the for loop doesn’t notice this and continues relentlessly. Listeners = listeners = listeners[1]; listeners = listeners = listeners[2] The listeners[2] are now missing the notice, but the listeners[2] are now pre-positioned at I = 1 because of the change in the length of the array. In this case, undefined will be executed instead of listenerC, raising function exceptions.

What can I do about it? The answer, of course, is to separate the nextListeners from the ongoing process and point them to different references. This is why ensureCanMutateNextListeners did.

In the example of this scenario, ensureCanMutateNextListeners before execution, listeners, the relationship between the currentListeners and nextListeners goes like this:

listeners === currentListeners === nextListeners
Copy the code

And after ensureCanMutateNextListeners execution, nextListeners will be spun off:

nextListeners = currentListeners.slice() listeners === currentListeners ! == nextListenersCopy the code

That way, the nextListeners can no longer be influenced by any changes they make to the process. The purpose of the currentListeners here is to record references to the currentListeners’ array of listeners that are currently working, separate them from other listeners that may be changing, and ensure the stability of listeners during their execution.

3, summarize

These two lectures provide an in-depth study of Redux’s design philosophy and implementation principles. At this point, WE believe that you have a solid grasp of the architectural motivation of Redux, the working principle, including the design basis of the source code.

In addition to the main Redux process, there is also a formidable role, that is the Redux middleware. With the support of middleware, Redux becomes a flexible chameleon, moving freely between different requirements scenarios. Redux middleware will be demystified in the next lecture.

4, the appendix

To study the source