1. Introduction

There’s a saying in the front-end community that Vue is easiest to get started with, React has a steep learning curve, Angular… I’ll stick with the dog leash. When React was born, Facebook advertised it as a library for front-end development, just a View layer. React component communication and state management are very important in large applications. To solve this problem, Facebook pioneered the Flux architecture for one-way data flow, which compensated for the use of React to develop large sites.

Flux:

Subsequently, Dan Abramov developed Redux, a state-management library inspired by Flux and the functional programming language Elm. The Redux source code is minimal and the implementation is clever. This article will take you through writing a Redux and React-Redux library from scratch and show you how to design a Store in Redux. Before we start, I’ve put the entire code for this article on GitHub for your reference. Redux: simple-redux Redux: simple-react-redux: simple-react-redux

2. Status management

2.1 Understand the data drive

Before we dive into state management, let’s take a look at what modern front-end frameworks do. Take Vue for example. In the beginning, the main selling points of Vue’s official website homepage were data-driven, componentization, MVVM, etc. (now the homepage has been revised). So what does it mean to be data-driven? Whether it’s native JS or jQuery, page refreshes are implemented by modifying the DOM directly. Frameworks such as Vue/React, instead of roughing up the DOM directly, rerender components by modifying data in data/state. That is, they encapsulate the process from data change to component rendering.

In addition to implementing the business logic, we also need to manipulate the DOM to manually update the page. Especially when it comes to rendering lists, updating can be cumbersome.

var ul = document.getElementById("todo-list");
$.each(todos, function(index, todo) {
    var li = document.createElement('li');
    li.innerHTML = todo.content;
    li.dataset.id = todo.id;
    li.className = "todo-item";
    ul.appendChild(li);
})
Copy the code

So over time, templates like jquery.tpl and Underscore. Template have made it easier to manipulate the DOM, with data-driven and componentized prototypes that we still have to render manually.

<script type="text/template" id="tpl">
    <ul id="todo-list">
        <% _.each(todos, function(todo){ %>
            <li data-id="<%=todo.id%>" class="todo-item">
                <%= todo.content %>
            </li>
        <% }); %>
    </ul>
</script>
Copy the code

If developing pages with pure native JS or jQuery is the age of primitive farming, modern frameworks like React/Vue are the age of automation. With a front-end framework, you don’t have to worry about generating and modifying the DOM, just the data on the page and the flow. So how to manage the flow of data becomes a top priority, which is often referred to as “state management”.

2.2 What state needs to be managed?

I’ve seen a lot of examples, but what does state management manage? In my opinion, state management in general is about these two kinds of data.

  1. Domain State

Domain State refers to the State of the server. This refers to the data obtained from the server through network requests, such as list data, which is usually consistent with the server data.

{" data ": {" hotels" : [{" id ":" 31231231 ", "name" : "Hilton", "price" : "1300"}]}}Copy the code
  1. UI State

UI states are often related to interactions. For example, the switch state of the modal box, the loading state of the page, the selected state of single (multiple) options, etc., these states are often scattered in different components.

{
    "isLoading": true,
    "isShowModal": false,
    "isSelected": false
}
Copy the code

2.3 Global Status Management

When we write components with React, we often need to promote the state to the parent component if sibling communication is involved. Once this component communication becomes more common, data management becomes a problem. Given the above example, if you want to manage your application’s data flow, can you put all the state in the top-level component? Partitioning data by function or component, placing data shared by multiple components separately, thus forming a large tree store. It is more recommended to divide by function.

This large store can be maintained inside or outside of the top component, which is commonly referred to as a container component. Container components can pass layers of data on which the component depends and methods for modifying that data to child components. We can divide the state of the container component by component, and this state is now the entire application store. Put the methods to change the state in actions, organize them in the same structure as the state, and pass them into their respective child components.

class App extends Component {
    constructor(props) {
        this.state = {
            common: {},
            headerProps: {},
            bodyProps: {
                sidebarProps: {},
                cardProps: {},
                tableProps: {},
                modalProps: {}},footerProps: {}}this.actions = {
            header: {
                changeHeaderProps: this.changeHeaderProps
            },
            footer: {
                changeFooterProps: this.changeFooterProps
            },
            body: {
                sidebar: {
                    changeSiderbarProps: this.changeSiderbarProps
                }
            }
        }
    }
    
    changeHeaderProps(props) {
        this.setState({
            headerProps: props
        })
    }
    changeFooterProps() {}
    changeSiderbarProps(){}...render() {
        const {
            headerProps,
            bodyProps,
            footerProps
        } = this.state;
        const {
            header,
            body,
            footer
        } = this.actions;
        return (
            <div className="main">
                <Header {. headerProps} {. header} / >
                <Body {. bodyProps} {. body} / >
                <Footer {. footerProps} {. footer} / >
            </div>)}}Copy the code

As you can see, this approach perfectly solves the problem of communication between child components. All you need to do is change the corresponding state, and the App component will be rendered when the state changes, and the child component will be rendered when it receives the new props.

Further optimizations can be made to this pattern, such as using Context to pass data to deep sub-components.

const Context = createContext(null);
class App extends Component {...render() {
        return (
            <div className="main">
                <Context.Provider value={... this.state, . this.events} >
                    <Header />
                    <Body />
                    <Footer />
                </Context.Provider>
            </div>)}}const Header = () = > {
    // Get the Context data
    const context = useContext(Context);
}
Copy the code

If you’ve been exposed to the Redux state management library, you might be surprised to learn that if we move state outside of the App component, it’s Redux. Yes, the core principle of Redux is to maintain a store outside the component, and when the Store changes, all connected components are notified to update. This example can be seen as a prototype of Redux.

3. Implement a Redux

Redux is a state management library that isn’t tied to React. You can use it with other frameworks and even native JS, as in this article: How to Use Redux in non-React Projects

How Redux works:

You need to understand how Redux works before you can learn it. Generally speaking, the process goes like this:

  1. The user triggers an action on the page, sending an action through Dispatch.
  2. Redux receives this action and retrieves the next state using the Reducer function.
  3. Update the new status into store, store update notification page re-render.

As you can see from this process, the core of Redux is a publish-subscribe model. All subscribers are notified when the Store changes, and the View rerenders when it receives the notification.

Redux has three principles:

  • Single data source

    The previous example ends up putting all the states into the top-level component’s state, which forms a state tree. In Redux, the state is store, and there is usually only one store in an application.

  • State is read-only

    In Redux, the only way to change state is to trigger an action that describes information about the behavior of the change. Allowing only action changes makes every state change in the application clear for later debugging and playback.

  • It’s modified by pure functions

    To describe how the action makes the state change, you need to write the Reducer function to change the state. The Reducer function receives the previous state and action and returns the new state. As long as the same state and action are passed in, the same result must be returned no matter how many times it is called.

The usage of Redux is not explained in detail here. Please refer to ruan Yifeng’s Introduction to Redux series: Introduction to Redux

3.1 implementation store

In Redux, stores are typically created through createStore.

import { createStore } from 'redux'; 
const store = createStore(rootReducer, initalStore, middleware);
Copy the code

Let’s take a look at a few of the approaches exposed in Redux.

The methods returned by createStore mainly include SUBSCRIBE, Dispatch, replaceReducer, and getState.

CreateStore receives three parameters, which are the reducers function, the initial value initalStore, and middleware.

Three methods, getState, Dispatch and subscribe, are mounted on store.

GetState is the method to get the store, which can be obtained through store.getState().

Dispatch is a method of sending actions that receives an action object and tells the Store to execute the Reducer function.

Subscribe is a listening method that listens for store changes, so you can use subscribe to combine Redux with other frameworks.

ReplaceReducer is used to inject the reducer asynchronously. A new reducer can be passed to replace the current reducer.

3.2 implementation getState

The implementation principle of store is relatively simple, which is to create an object based on the initial value passed in. Use closure features to preserve the store, allowing the store to be retrieved via getState. The getState command is used to obtain the snapshot of the current store. In this way, logs can be printed to compare the changes of the two stores and facilitate debugging.

const createStore = (reducers, initialState, enhancer) => {
    let store = initialState;
    const getState = () => store;
    return {
        getState
    }
}
Copy the code

Of course, the store implementation is relatively simple now, because createStore still has two parameters that are not used. Don’t worry, these two parameters will be used later.

3.3 Subscribe && Unsubscribe

Since Redux is essentially a public-subscribe model, there must be a listening method, similar to $.on in jQuery, which provides both listening and unlistening methods in Redux. The implementation is also relatively simple, using an array to hold all the listening methods.

const createStore = (...) => {
    ...
    let listeners = [];
    const subscribe = (listener) => {
        listeners.push(listener);
    }
    const unsubscribe = (listener) => {
        const index = listeners.indexOf(listener)
        listeners.splice(index, 1)
    }
}
Copy the code

3.4 implementation dispatch

Dispatches and actions are closely related, and actions can only be sent through Dispatches. The methods that subscribe listens to are executed only after the action is sent. So what dispatch does is pass the action to the Reducer function, set the result to a new store, and then execute the methods on the Listeners.

const createStore = (reducers, initialState) => {
    ...
    let store = initialState;
    const dispatch = (action) => {
        store = reducers(store, action);
        listeners.forEach(listener => listener())
    }
}
Copy the code

Is that all? Not enough, of course. If multiple actions are sent at the same time, it is difficult to know what the final store will look like, so locks are needed. In Redux, the return value after dispatch execution is also the current action.

const createStore = (reducers, initialState) => { ... let store = initialState; let isDispatch = false; Const dispatch = (action) => {if (isDispatch) return Action // Dispatch must be reducers(store,  action); isDispatch = false listeners.forEach(listener => listener()) return action; }}Copy the code

At this point, the principles of the Redux workflow have been implemented. But you might have a lot of questions. If initialState is not passed, what is the default value for store? If middleware is passed in, how does it work?

3.5 implementation combineReducers

When we first came to Redux’s Store, we all had a kind of question: how is the structure of the store determined? CombineReducers will solve the mystery. Now let’s analyze the first parameter received by createStore. This parameter has two forms, one is a direct reducer function and the other is a merge of multiple Reducer functions using combineReducers.

CombineReducers is a higher-order function that takes an object as an argument and returns a new function. This new function should be the same as the normal Reducer function.

const combineReducers = (reducers) => {
    return function combination(state = {}, action) {
    }
}
Copy the code

So what does combiner Trainers do? Mainly the following steps:

  1. Collect all incoming Reducer functions
  2. When the Combination function is executed in Dispatch, all the Reducer functions are iterated. If a Reducer function returns a new state, the Combination returns that state, otherwise the incoming state is returned.
const combineReducers = reducers => { const finalReducers = {}, // Collect all reducer functions nativeKeys(reducers). ForEach (reducerKey => {if(Typeof Reducers [reducerKey]  === "function") { finalReducers[reducerKey] = reducers[reducerKey] } }) return function combination(state, action) { let hasChanged = false; const store = {}; NativeKeys (finalReducers). ForEach (key => {const reducer = finalReducers[key]; // Obviously, Store keys come from reducers const nextState = reducer(state[key], action) store[key] = nextState hasChanged = hasChanged || nextState ! == state[key]; }) return hasChanged ? nextState : state; }}Copy the code

If this combination is executed every time I call dispatch, don’t all reducer functions be executed no matter what type of action I send? If there are many reducer functions, isn’t this inefficient? But if you don’t execute it, it looks like you can’t exactly match the switch… Action. Type in case. Would it be more efficient to match action. Type and reducer in the form of key-value pairs? Something like this:

// redux const count = (state = 0, action) => { switch(action.type) { case 'increment': return state + action.payload; case 'decrement': return state - action.payload; default: return state; }} const count = {state: 0, // Initialize state reducers: {increment: (state, payload) => state + payload, decrement: (state, payload) => state - payload } }Copy the code

In this way, every time you send a new action, you can directly use the key value below reducers to match it without resorting to violent traversal. Oh, my God, you are so smart. Quietly, there are some Redux-like solutions in the community that do just that. Take rematch and Relite as examples: rematch:

import { init, dispatch } from "@rematch/core"; import delay from "./makeMeWait"; const count = { state: 0, reducers: { increment: (state, payload) => state + payload, decrement: (state, payload) => state - payload }, effects: { async incrementAsync(payload) { await delay(); this.increment(payload); }}}; const store = init({ models: { count } }); dispatch.count.incrementAsync(1);Copy the code

Relite:

const increment = (state, payload) => {
    state.count = state.count + payload;
    return state;
}
const decrement = (state, payload) => {
    state.count = state.count - payload;
    return state;
}
Copy the code

3.6 Middleware and Store Enhancer

Considering this situation, IF I want to print the relevant information of each action and the changes before and after the store, I can only print the information manually at each dispatch, which is tedious and repetitive. The third parameter provided in createStore is an enhancement to the Dispatch function, which we call Store Enhancer. The Store Enhancer function is a higher-order function that has a general structure like this:

const enhancer = () => { return (createStore) => (reducer, initState, enhancer) => { ... }}Copy the code

Enhancer takes createStore as an argument and returns an enhanced store, essentially an extension of the Dispatch function. The logger:

const logger = () => { return (createStore) => (reducer, initState, enhancer) => { const store = createStore(reducer, initState, enhancer); const dispatch = (action) => { console.log(`action=${JSON.stringify(action)}`); const result = store.dispatch(action); const state = store.getState(); console.log(`state=${JSON.stringify(state)}`); return result; } return { ... state, dispatch } } }Copy the code

How is this used in createStore? Generally, it will return directly in the case of arguments.

const createStore = (reducer, initialState, enhancer) => {
    if (enhancer && typeof enhancer === "function") {
        return enhancer(createStore)(reducer, initialState)
    }
}
Copy the code

If you’ve looked at the source code for applyMiddleware, you’ll see that the two implementations are similar. ApplyMiddleware is essentially a Store Enhancer.

3.7 implementation applyMiddleware

A lot of middleware is often used when creating a store, with applyMiddleware injecting multiple middleware into the store.

const store = createStore(reducers, initialStore, applyMiddleware(thunk, logger, reselect));
Copy the code

The implementation of applyMiddleware is similar to Store Enhancer above. Because multiple middleware can be used sequentially, you end up like the Onion model where action delivery is handled by one middleware, so what the middleware does is enhance dispatch’s ability to pass the action to the next middleware. The key, then, is to pass the new Store and Dispatch functions to the next middleware.

Take a look at the source code implementation of applyMiddleware:

const applyMiddleware = (... middlewares) => { return (createStore) => (reducer, initState, enhancer) => { const store = createStore(reducer, initState, enhancer) const middlewareAPI = { getState: store.getState, dispatch: (action) => dispatch(action) } let chain = middlewares.map(middleware => middleware(middlewareAPI)) store.dispatch = compose(... chain)(store.dispatch) return { ... store, dispatch } } }Copy the code

The compose function is a kind of pipeline that allows you to compose multiple functions. Compose (m1, m2)(dispatch) is equivalent to m1(m2(dispatch)). Function composition can be achieved using the Reduce function.

const compose = (... funcs) => { if (! funcs) { return args => args } if (funcs.length === 1) { return funcs[0] } return funcs.reduce((f1, f2) => (... args) => f1(f2(... args))) }Copy the code

If you look at the streamlined implementation of the Redux-Logger middleware, you’ll see that the two match up nicely.

Function Logger (middlewareAPI) {return function (next) {// Next Console. log(' before dispatch: ', middlewareapi.getState ()); var returnValue = next(action); Console. log(' after dispatch: ', middlewareapi.getState (), '\n'); return returnValue; }; }; }Copy the code

So far, the basic principle of Redux is very clear, and finally organize a simplified version of Redux source code implementation.

Const compose = (const compose = (... funcs) => { if (! funcs) { return args => args } if (funcs.length === 1) { return funcs[0] } return funcs.reduce((f1, f2) => (... args) => f1(f2(... args))) } const bindActionCreator = (action, dispatch) => { return (... args) => dispatch(action(... args)) } const createStore = (reducer, initState, enhancer) => { if (! enhancer && typeof initState === "function") { enhancer = initState initState = null } if (enhancer && typeof enhancer === "function") { return enhancer(createStore)(reducer, initState) } let store = initState, listeners = [], isDispatch = false; Const getState = () => Store const dispatch = (action) => {if (isDispatch) return action // Dispatch must be isDispatch = one by one  true store = reducer(store, action) isDispatch = false listeners.forEach(listener => listener()) return action } const subscribe = (listener) => { if (typeof listener === "function") { listeners.push(listener) } return () => unsubscribe(listener) } const unsubscribe = (listener) => { const index = listeners.indexOf(listener) listeners.splice(index, 1) } return { getState, dispatch, subscribe, unsubscribe } } const applyMiddleware = (... middlewares) => { return (createStore) => (reducer, initState, enhancer) => { const store = createStore(reducer, initState, enhancer); const middlewareAPI = { getState: store.getState, dispatch: (action) => dispatch(action) } let chain = middlewares.map(middleware => middleware(middlewareAPI)) store.dispatch = compose(... chain)(store.dispatch) return { ... store } } } const combineReducers = reducers => { const finalReducers = {}, nativeKeys = Object.keys nativeKeys(reducers).forEach(reducerKey => { if(typeof reducers[reducerKey] === "function") { finalReducers[reducerKey] = reducers[reducerKey] } }) return (state, action) => { const store = {} nativeKeys(finalReducers).forEach(key => { const reducer = finalReducers[key] const nextState = reducer(state[key], action) store[key] = nextState }) return store } }Copy the code

4. Implement a react-redux

If you want to use Redux with React, you can usually use the React-Redux library. Now that you’ve seen how Redux works, you know how react-Redux works. React-redux provides two apis, connect and Provider. The former is a high-order component of React, and the latter is a common component of React. React-redux implements a simple publish-subscribe library that listens for changes in the current store. The functions of both are as follows:

  1. Provider: passes a store to a descendant component through the Context to register listening to the store.
  2. Connect: When the store changes, mapStateToProps and mapDispatchToProps are executed to get the latest props and pass it to the child components.

Usage:

// Provider
ReactDOM.render({
    <Provider store={store}></Provider>,
    document.getElementById('app')
})
// connect
@connect(mapStateToProps, mapDispatchToProps)
class App extends Component {}
Copy the code

4.1 implement the Provider

We will implement a simple Provider first. We know that providers use Context to pass stores, so providers directly pass stores to their children through context. Provider.

// Context.js
const ReactReduxContext = createContext(null);

// Provider.js
const Provider = ({ store, children }) => {
    return (
        <ReactReduxContext.Provider value={store}>
            {children}
        </ReactReduxContext.Provider>
    )
}
Copy the code

The Provider also needs a publish-subscriber.

class Subscription { constructor(store) { this.store = store; this.listeners = [this.handleChangeWrapper]; } notify = () => { this.listeners.forEach(listener => { listener() }); } addListener(listener) { this.listeners.push(listener); } // Listen store trySubscribe() {this.unsubscribe = this.store.subscribe(this.notify); } // onStateChange needs to set handleChangeWrapper = () => {if (this.onStatechange) {this.onStatechange ()}} unsubscribe() { this.listeners = null; this.unsubscribe(); }}Copy the code

Combine the Provider and Subscription and register the listener in useEffect.

// Provider.js const Provider = ({ store, children }) => { const contextValue = useMemo(() => { const subscription = new Subscription(store); return { store, subscription } }, [store]); UseEffect (() => {const {subscription} = contextValue; subscription.trySubscribe(); return () => { subscription.unsubscribe(); } }, [contextValue]); return ( <ReactReduxContext.Provider value={contextValue}> {children} </ReactReduxContext.Provider> ) }Copy the code

4.2 implement the connect

Looking at the implementation of CONNECT, there are three main steps:

  1. Use useContext to get the incoming store and subscription.
  2. Add a listener to subscription, which rerenders the component whenever the store changes.
  3. After the store changes, execute the mapStateToProps and mapDispatchToProps functions, merge them with the props passed in, and pass them to the WrappedComponent.

Let’s just do a simple way of getting the Context.

const connect = (mapStateToProps, mapDispatchToProps) => (WrappedComponent) => { return function Connect(props) { const { store, subscription } = useContext(ReactReduxContext); return <WrappedComponent {... props} /> } }Copy the code

The next step is to implement how to update this component when the Store changes. In React, the only way to update components is to manually set state and call forceUpdate. UseState is used to set count one at a time to trigger updates.

const connect = (mapStateToProps, mapDispatchToProps) => { return (WrappedComponent) => { return (props) => { const { store, subscription } = useContext(ReactReduxContext); const [count, setCount] = useState(0) useEffect(() => { subscription.onStateChange = () => setCount(count + 1) }, [count]) const newProps = useMemo(() => { const stateProps = mapStateToProps(store.getState()), dispatchProps = mapDispatchToProps(store.dispatch); return { ... stateProps, ... dispatchProps, ... props } }, [props, store, count]) return <WrappedComponent {... newProps} /> } } }Copy the code

The principle of React-Redux is similar to the above. It is only used as an example of the learning principle and is not recommended for production.

5. How to design store

If you want to see the store structure of the current page, you can use the Redux-devTools or React Developer Tools chrome plugins to view it. The former is typically used in a development environment to visualize the Store and its changes. The latter is mainly used for React, but can also be viewed as store. The question of how to design a store in Redux has always been a challenge for beginners, and in my opinion this is not just a Redux problem, it should be the same in any front-end store design.

5.1 Misunderstanding of Store design

Here we take zhihu’s problem page Store design as an example. To start, install React Developer Tools and select the root node in the RDT Tab.

Then type $r.state.store.getState() in Console to print out store.

It can be seen that there is an entity attribute in store, which includes users, questions, answer, and so on.

This is a question page, which naturally includes questions, answers, answers to the comments below, and so on.

In general, this should be done by getting all the answers from the back end in batches based on question_id when you enter the page. When you click on comments, all comments are batched from the back end according to answer_id. So you might think that the store structure should be designed like this, like a Russian doll, one on top of the other.

Question_id: '1', Answers: [{answer_id: '1-1', content: 'Comments: [{comment_id: '1-1-1', Content:' Every comment is a shot '}]}]}]}Copy the code

To get a better sense of the data structure:

This is a common mistake that beginners fall into. It is wrong to design the store structure according to the API. Take the comments section for example. How do you relate the comments to the replies? You might be thinking, why don’t you just treat the comment as a sub-comment of the comment?

{comments: [{comment_id: '1-1-1', content: 'Concise, every sentence is a picture, a set of shots ', children: [{comment_id: '1-1-2', content: 'I feel like a lot of pictures, a movie...'}]}, {comment_id: '1-1-2', content: 'I feel like a lot of pictures, a movie...'}]}Copy the code

That’s fine, it satisfies our needs, but the comments in children and comments are overloaded.

5.2 Flattening store

Wouldn’t it be nice to just save comment_id in children? Just look up comment_id from comments for this display. This is the essence of the Design Store. We can think of a Store as a database, with states in the store divided into tables of data by domain. Different tables are associated with each other by primary keys. So the store above can be designed as three tables, questions, Answers, comments, with their ids as keys and a new field to associate the children with.

{questions: {'1': {id: '1', content: 'LOL which hero best expresses your imagination of the assassin? 'answers: [' 1-1]}}, answers: {' 1-1: {id:' 1-1, content: 'I am to nominate a hero has faded. That's right, captain Timo... 'comments: [' 1-1-1', '1-1-2']}}, comments: {' 1-1-1 ': {id:' 1-1-1 ', the content: 'words are refined, every word is a picture, a group of lens', the children: } '1-1-2', '1-1-2' : {id: '1-1-2', the content: 'I feel is a lot of pictures, movie... '}}}Copy the code

You will notice that the data structure becomes very flat, avoiding the problem of redundant data and deep nesting. When searching, you can also search by ID directly, instead of searching for a specific item by index.

6. Recommended reading

  1. Analyze the Twitter front-end architecture to learn about complex scenario data design
  2. JSON data normalization
  3. React+Redux builds NEWS EARLY single-page app

PS: Welcome to pay attention to my public account [front-end small pavilion], share original articles from time to time, no advertising.