A redux in a mountain village

Keywords: Javascript React Attribute

This article mainly talks about how to achieve a "simple" Redux through our own understanding. The purpose is not to copy a redux, but to implement the core functions of Redux manually to help us understand and use redux.

In fact, redux does not have many core function codes, and a large number of other codes are designed to cope with the "no-show" situation in real use, so for ease of understanding, we only implement core functions that do not handle special cases and exceptions.

Finally, we will refer to the redux source code to understand and learn how redux is implemented.

Understanding redux

First, let's comb redux according to our own understanding.

  1. Reux is essentially a container in which we can store all the shape data we need in our applications.In js, we just use one object to represent the container.
  2. By adding a subscription function to the container, when the container's data changes, we receive a response and process it accordingly.
  3. Notify the container of changes to the data by sending an action object to the container.
  4. The container replaces the old state data by calling the reducer function we wrote to get the latest state data.

Implement createStore

The first version implements redux's most important api: createStore.This function returns what we usually call a store object, and there are three methods on the store object that we want to implement.Namely

  1. getState.Returns the current state of the store.
  2. subscribe.Used to add subscription functions to store s.
  3. dispatch.The directive action object used to send changes to the store.

Here's the code

{
  const createStore = (reducer, prelodedState) => {
    let state = prelodedState;
    // Store all subscription functions
    const listeners = [];
    // Get the current state
    const getState = () => state;

    const dispatch = action => {
      // Pass the current state and action into reducer to calculate the changed state
      state = reducer(state, action);
      // After the state changes, iterate through all subscription functions
      listeners.forEach(listener => listener());
    }

    const subscribe = listener => {
      listeners.push(listener);
      // Return function to remove subscription
      return () => {
        const i = listeners.indexOf(listener);
        listeners.splice(i, 1);
      }
    }

    // After creating the store, initialize the state
    dispatch({});

    return {
      getState,
      dispatch,
      subscribe,
    }
  }

  window.Redux = {
    createStore,
  }
}

Here is an example of counter Counter implemented with redux above
Redux_Counter

Join combineReducers

In the counter, state is only a single number data type, and reducer is also simple. However, in practice, state is often a complex object, and multiple reducers are needed to calculate the corresponding parts under the state.
Take todo for example:

Its state and reducer may look like this

const state = {
    todo: [
      {
        text: 'Having dinner',
        completed: true,
      }, 
      {
        text: 'Sleep',
        completed: false,
      }
    ],
    filter: 'FILTER_ALL',    // Show all, whether completed or not  
}

const reducer = (state = { todo: [], filter: 'FILTER_ALL' }, action) => {
  switch (action.type) {
    case 'TODO_ADD':
      return {
        ...state,
        todo: state.todo.concat({text: action.text, completed: false}),
      }
    // TODO_REMOVE ...
    // TODO_TOGGLE ...
    case 'FILTER_SET':
      return {
        todo: state.todo.slice(),
        filter: action.filter,
      }
    default:
      return state;
  }
}

We can see that state is mainly divided into two parts: todo list and filter. In reducer, the processing logic of the two parts is mixed together, the logic of handling TODO_ADD also returns filters together by deconstructing the state, and the logic of handling FILTER_SET is responsible for copying a new todo back together.
This results in a mix of code that handles different states, which increases complexity and code redundancy, so it is necessary to break down the reducer into separate functions that process the corresponding data in the state.

First try merging multiple reducer s manually

// reducer: processes the array todo under state
const todo = (state = [], action) => {
  switch (action.type) {
    case 'TODO_ADD':
      return [...state, { text: action.text, completed: false }];
      // TODO_REMOVE TODO_TOGGLE
    default:
      return state;
  }
}
// reducer: process filter under state
const filter = (state = 'FILTER_ALL', action) => {
  switch (action.type) {
    case 'FILTER_SET':
      return action.filter;
    default:
      return state;
  }
}
// Manually merge reducer, break apart the data under state, and call the corresponding processing functions.
// Eventually combined into a new state return
const reducer = (state = {}, action) => {
  return {
    todo: todo(state.todo, action),
    filter: filter(state.filter, action),
  }
}

Let's implement a combineReducers function that combines multiple reducer s

const combineReducers = (reducers) => {
    return (state = {}, action) => {
      // Get all the key values of reducers
      const keys = Object.keys(reducers);
      // Incoming {} as initial value (new state)
      return keys.reduce((prevState, key) => {
        // Pass the old state[key] and action corresponding to the key into the value handler corresponding to the key in reducers
        // Calculate the new state
        prevState[key] = reducers[key](state[key], action);
        return prevState;
      }, {});
    }
}

Here is an example of todo implemented by adding the combineReducers function
Redux_Todo

Join Extension Mechanisms

If only the above mentioned functions are available, they will certainly not meet the actual needs.For example, asynchronous tasks need to be handled in a uniform and canonical manner or an api needs to be extended or customized.

redux provides two extensions: middleware and enhancers.

Middleware is an extension of the dispatch method. So far, if dispatch is called and an action object is passed in, the action will reach the store object directly, then reduce is executed and the subscription function is called.Middleware is the mechanism by which actions are parsed before they reach the store after dispatch, processed by a middleware function, and then passed to the store.

Enhancers can extend the entire store, not just the dispatch method.

So middleware is an enhancer, and middleware is implemented through enhancers. Because extensions to dispatch methods are common and practical, the mechanism of inserting middleware is implemented separately as applyMiddleware method.

So let's first look at how the enhancer works, then at how the middleware works.

Add Enhancer

Supermarkets always like to pack bulk plastic plates and plastic wraps for sale. After packaging, the product's appearance, portability and value have been enhanced.
The enhancer in redux is similar to this wrapping mechanism. If you want to enhance the store's getState method, wrap it up as a new function, as long as you are sure that the store's original getState method will be called eventually.

The following is an enhancement to getState, which prints a sentence each time it is called

// Enhancer for getState
const getStateEnhancer = (store) => {
  const originalGetState = store.getState;
  store.getState = () => {
    console.log('----- getState is invoked -----');
    return originalGetState();
  }
}
// Create a store
const store = Redux.createStore(reducer, initialState);
// Enhanced store
getStateEnhancer(store);

This is possible, but that's too much. Later we'll see how the redux source works.

Join Middleware

Middleware is an enhancement to the dispatch method, which wraps the dispatch method to generate a new dispatch method.

Insert the middleware functions in turn into the new dispatch, and each middleware has access to getState, dispatch, action, and the next middleware function.

Therefore, by parsing an action within a middleware, the middleware can choose to call the next middleware to pass the action on, or it can choose to call dispatch again to let the action flow through the middleware again.

The next middleware function called by the last middleware points to the dispatch before wrapping, so that the action eventually reaches the store after the middleware has been processed.

redux specifies that the middleware must follow the specifications shown below.

const middleware = ({dispatch, getState}) => next => action => {
    // do something
    next(action);
}

First, the middleware middleware must be a function that is injected with an object as a parameter and returns a new function.The parameter next of the new function represents the next middleware. The new function returns a function again. The last function returned is the place where the middleware executes its logic. After execution, call next and pass action to the next middleware. If there is no next middleware, the next next function points to the dispatch side of the store.Method.

The applyMiddleware method for adding middleware is simulated below according to the interface specification of the middleware

  /**
   * Combinatorial function, which combines multiple functions into one function
   * Such as a, b, c three functions 
   * The function returned by executing compose(a, b, c) approximates (... args) => a (b (c (... args))
   * */ 
  const compose = (...funcs) => {
    if (funcs.length === 0) return f => f;

    if (funcs.length === 1) return funcs[0];

    return funcs.reduce((prevFunc, curFunc) => (...args) => prevFunc(curFunc(...args)));
  }

  /**
   *  Injection Middleware 
   *  @param {store} store object that needs to be injected into Middleware
   *  @param {...middlewares} Middleware passed in in order
   */
  const applyMiddleware = (store, ...middlewares) => {
    let dispatch;
    // Parameter object injected into the middleware, where dispatch points to the new dispatch function
    const injectApi = {
      dispatch: (...args) => dispatch(...args),
      getState: store.getState,
    }
    // Before map execution, each middleware was approximately this long: ({dispatch, getState}) => next => action => {};
    // After each middleware injection parameter is called, it will be about this long: next => action => {};
    const chain = middlewares.map(middleware => middleware(injectApi));
    // Get a new dispatch method
    store.dispatch = compose(...chain)(store.dispatch);
    dispatch = store.dispatch;
  }
    
    // Test the middleware, print only one sentence
    const middleware_1 = ({ dispatch, getState }) => next => action => {
      console.log('middleware_1');
      next(action);
    }
    const middleware_2 = ({ dispatch, getState }) => next => action => {
      console.log('middleware_2');
      next(action);
    }
    const middleware_3 = ({ dispatch, getState }) => next => action => {
      console.log('middleware_3');
      next(action);
    }
    
    // Usage patterns
    // Create a store
    const store = Redux.createStore(reducer, initialState);
    // Injection Middleware
    applyMiddleware(store, middleware_1, middleware_2, middleware_3);

For applyMiddleware, let's focus on the following code

store.dispatch = compose(...chain)(store.dispatch);

That is, how many middleware functions are combined into a single function and linked to dispatch.

// If you now have the three middleware_1, middleware_2, and middleware_3 mentioned above
// After executing the following code
// const chain = middlewares.map(middleware => middleware(injectApi));
// chain is about this long
chain = [
  next => action => { console.log('middleware_1'); next(action); }, // middleware_1
  next => action => { console.log('middleware_2'); next(action); }, // middleware_2
  next => action => { console.log('middleware_3'); next(action); }, // middleware_3
]

// After compose(...chain) has been executed
// The function returned by compose(...chain) is approximately as long as this
(...args) => {
  return ((...args) => {
    return middleware_1(middleware_2(...args))
  })(middleware_3(...args))
}

// Next (store.dispatch) when the return function is called
// store.dispatch passes in middleware_3, which becomes
action => { console.log('middleware_3'); store.dispatch(action); }

// The transformed middleware_3 is passed into middleware_2 as a parameter, and middleware_2 becomes the following
action => { console.log('middleware_2'); middleware_3(action); }

// The transformed middleware_2 is passed into middleware_1 as a parameter, and middleware_1 becomes the following
action => { console.log('middleware_1'); middleware_2(action); }

// So compose(...chain)(store.dispatch); the final function returned is middleware_1 as shown below
action => { console.log('middleware_1'); middleware_2(action); }
// Then call middleware_2 inside the function and middleware_2 inside to call middleware_3
// This implements sequential execution of the middleware, and the last middleware will call the old dispatch function

Next, we add the log middleware and thunk Middleware in Counter to see the final result
Redux_middleware_Counter

See redux source

combineReducers

One of the things to focus on in this function is whether the state changes or not.

// Only the core code for this method is preserved
function combineReducers(reducers) {
  ...
  return function combination(state = {}, action) {
    // Flag whether the state has changed
    let hasChanged = false
    const nextState = {}
    for (let i = 0; i < finalReducerKeys.length; i++) {
      const key = finalReducerKeys[i]
      const reducer = finalReducers[key]
      const previousStateForKey = state[key]
      const nextStateForKey = reducer(previousStateForKey, action)
      nextState[key] = nextStateForKey
      // Shallow comparisons are used for state values corresponding to the same key twice before and after
      // If it is the same reference, it is considered unchanged
      hasChanged = hasChanged || nextStateForKey !== previousStateForKey
    }
    // One key corresponds to a value that changes and returns to the new state
    // Use the old state without changing the value of all key s
    return hasChanged ? nextState : state
  }
}

combineReducers uses a shallow comparison to determine whether to return the new state or the old state, and returns the old state if each attribute value under the root state has the same reference twice before and after.

This is why Redux does not directly modify the state, because binding libraries such as react-redux also use a shallow comparison to determine if the state has changed. If you modify the state directly, react-redux will assume that the state has not changed and will not trigger rendering.

Enhancer

First look at the format of an enhancer that does nothing

const enhancer = createStore => (reducer, prelodedState, enhancer) => {
  const store = createStore(reducer, prelodedState, enhancer);
  // Enhanced store code
  return store;
}

redux enhancer

// Only enhancer-related code is retained
// createStore can accept 3 parameters
// The first parameter is always reducer, and the second and third are optional parameters
// The second parameter is treated as enhancer if it is a function, otherwise as the initial state of the state
// The third parameter, if any, must be the enhancer function type
function createStore(reducer, preloadedState, enhancer) {
  ...
  // If there is an enhancer
  if (typeof enhancer !== 'undefined') {
    // Pass yourself to enhancer and get a new createStore function
    // Then pass the remaining two parameters to the new createStore
    return enhancer(createStore)(reducer, preloadedState)
  }
  ...
  return {
    dispatch,
    getState,
    ...
  }
}

Last

There are many ways to redux that are not listed here, and you can go further if you are interested.
When you look at the source code, you can comment out the code that is not related to the core functionality, which makes it easier.

Posted by kester on Wed, 04 Sep 2019 16:04:42 -0700