Summary
In the world of React, there are hundreds of state management solutions, but the most classic of them is Redux. If you want to learn functional programming then Redux
Source code is the best learning material. Considering that many friends use Vue, I strive to make this article easy to understand, so that students who have not been exposed to the React technology stack can also master it
Redux.
Redux belongs to the typical "hundred lines of code, thousands of lines of documents", in which the core code is very small, but the idea is not simple, which can be summarized as the following two points:
The global state is unique and immutable (Immutable). Immutable means that when the state needs to be modified, it is replaced with a new one, rather than directly changing the original data:
let store = { foo: 1, bar: 2 }; // 当需要更新某个状态的时候 // 创建一个新的对象,然后把原来的替换掉 store = { ...store, foo: 111 };
This is just the opposite of Vue. In Vue, the original object must be modified directly in order to be monitored by the reactive mechanism, thereby triggering the setter to notify the dependency update.
The state update is done through a pure function (Reducer). Pure functions are characterized by:
- The output is only related to the input;
- Reference is transparent and does not depend on external variables;
- No side effects;
Therefore, for a pure function, the same input must produce the same output, which is very stable. Use pure functions to modify the global state so that the global state can be predicted.
1. Several concepts to understand
Before using Redux and reading the source code, you need to understand the following concepts:
Action
action is a plain JavaScript object describing how to modify the state, which needs to contain a type attribute. A typical action looks like this:
const addTodoAction = {
type: 'todos/todoAdded',
payload: 'Buy milk'
}
Reducers
A reducer is a pure function with the following function signature:
/**
* @param {State} state 当前状态
* @param {Action} action 描述如何更新状态
* @returns 更新后的状态
*/
function reducer(state: State, action: Action): State
The name of reducer functions comes from the reduce method of the array, because they are similar to the callback function passed by the reduce method of the array, that is, the value returned by the previous call will be passed as the parameter of the next call.
The writing of reducer functions needs to strictly follow the following rules:
Check if the reducer cares about the current action
- If so, create a copy of the state, update the state in the copy with the new value, and return the copy
- Otherwise, return to the current state
A typical reducer function is as follows:
const initialState = { value: 0 }
function counterReducer(state = initialState, action) {
if (action.type === 'counter/incremented') {
return {
...state,
value: state.value + 1
}
}
return state
}
Store
The Redux application instance created by calling createStore can get the current state through the getState() method.
Dispatch
Methods exposed by the store instance. The only way to update the state is to submit an action via dispatch. The store will call the reducer to perform the state update, and then the updated state can be obtained through the getState() method:
store.dispatch({ type: 'counter/incremented' })
console.log(store.getState())
// {value: 1}
storeEnhancer
The higher-order function encapsulation of createStore is used to enhance the capabilities of the store. Redux's applyMiddleware is an official enhancer.
middleware
The higher-order function encapsulation of dispatch, the original dispatch is replaced by applyMiddleware with the implementation of chained invocation of middleware. Redux-thunk is the official middleware to support asynchronous actions.
2. Basic use
Before learning the source code, let's take a look at the basic use of Redux to better understand the source code.
First we write a Reducer function as follows:
// reducer.js
const initState = {
userInfo: null,
isLoading: false
};
export default function reducer(state = initState, action) {
switch (action.type) {
case 'FETCH_USER_SUCCEEDED':
return {
...state,
userInfo: action.payload,
isLoading: false
};
case 'FETCH_USER_INFO':
return { ...state, isLoading: true };
default:
return state;
}
}
In the above code:
- When the reducer is called for the first time, it will pass in initState as the initial state, and then the last default of switch...case is used to obtain the initial state
- Two action.types are also defined in switch...case to specify how to update the state
Next we create the store:
// index.js
import { createStore } from "redux";
import reducer from "./reducer";
const store = createStore(reducer);
The store instance exposes two methods getState and dispatch, where getState is used to get the state, dispatch is used to submit actions to modify the state, and there is also a subscribe to subscribe to the changes of the store:
// index.js
// 每次更新状态后订阅 store 变化
store.subscribe(() => console.log(store.getState()));
// 获取初始状态
store.getState();
// 提交 action 更新状态
store.dispatch({ type: "FETCH_USER_INFO" });
store.dispatch({ type: "FETCH_USER_SUCCEEDED", payload: "测试内容" });
Let's run the above code, the console will print successively:
{ userInfo: null, isLoading: false } // 初始状态
{ userInfo: null, isLoading: true } // 第一次更新
{ userInfo: "测试内容", isLoading: false } // 第二次更新
3. Redux Core source code analysis
The above example is simple, but it already includes the core functionality of Redux. Next, let's take a look at how the source code is implemented.
createStore
It can be said that all the core ideas of Redux design are in createStore. The implementation of createStore is actually very simple. The whole is a closure environment, which caches currentReducer and currentState, and defines methods such as getState, subscribe, and dispatch.
The core source code of createStore is as follows. Since storeEnhancer is not used here, some of the logic of if...else is omitted at the beginning. By the way, the type annotations in the source code are also removed for easy reading:
// src/createStore.ts
function createStore(reducer, preloadState = undefined) {
let currentReducer = reducer;
let currentState = preloadState;
let listeners = [];
const getState = () => {
return currentState;
}
const subscribe = (listener) => {
listeners.push(listener);
}
const dispatch = (action) => {
currentState = currentReducer(currentState, action);
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i];
listener();
}
return action;
}
dispatch({ type: "INIT" });
return {
getState,
subscribe,
dispatch
}
}
The call link of createStore is as follows:
- First call the createStore method, passing in the reducer and preloadState. preloadState represents the initial state. If it is not passed, the reducer must specify the initial value;
- Assign reducer and preloadState to currentReducer and currentState respectively to create closures;
- Create an array of listeners, which is actually based on the publish-subscribe model. The listeners are the event center of the publish-subscribe model, and they are also cached through closures;
- Create getState , subscribe , dispatch and other functions;
- Call the dispatch function and submit an INIT action to generate the initial state. In the Redux source code, the type here is a random number;
- Finally, an object containing getState , subscribe , and dispatch functions is returned, that is, the store instance;
Obviously, the value of the closure cannot be accessed by the outside world, only through the getState function.
In order to subscribe to state updates, you can use the subscribe function to push the listener function to the event center (note that the listener allows side effects to exist).
When the state needs to be updated, dispatch is called to submit the action. Call currentReducer (that is, the reducer function) in the dispatch function, and pass in currentState and action, and then generate a new state and pass it to currentState. After the state update is completed, the subscribed listener function is executed once (in fact, as long as dispatch is called, the listener function will be triggered even if no changes are made to the state).
If you are familiar with object-oriented programming, you may say that what you do in createStore can be encapsulated into a class. Indeed, I use TypeScript to achieve the following (publish and subscribe functions are not written):
type State = Object;
type Action = {
type: string;
payload?: Object;
}
type Reducer = (state: State, action: Action) => State;
// 定义 IRedux 接口
interface IRedux {
getState(): State;
dispatch(action: Action): Action;
}
// 实现 IRedux 接口
class Redux implements IRedux {
// 成员变量设为私有
// 相当于闭包作用
private currentReducer: Reducer;
private currentState?: State;
constructor(reducer: Reducer, preloadState?: State) {
this.currentReducer = reducer;
this.currentState = preloadState;
this.dispatch({ type: "INIT" });
}
public getState(): State {
return this.currentState;
}
public dispatch(action: Action): Action {
this.currentState = this.currentReducer(
this.currentState,
action
);
return action;
}
}
// 通过工厂模式创建实例
function createStore(reducer: Reducer, preloadState?: State) {
return new Redux(reducer, preloadState);
}
You see, how interesting it is that functional programming and object-oriented programming have the same goal.
applyMiddleware
applyMiddleware is a difficult point in Redux. Although there is not much code, a lot of functional programming skills are used in it. I also thoroughly understand it after a lot of source code debugging.
First of all, you need to be able to understand this way of writing:
const middleware =
(store) =>
(next) =>
(action) => {
// ...
}
The above is equivalent to:
const middleware = function(store) {
return function(next) {
return function(action) {
// ...
}
}
}
Second, you need to know that this is actually function currying, that is, you can accept parameters step by step. If there is a variable reference to the inner function, then each call will generate a closure.
When it comes to closures, some students immediately think of memory leaks. But in fact, closures are very common in daily project development. Many times we create closures inadvertently, but they are often ignored by us.
One of the main functions of closures is to cache values, which is similar to the effect of declaring a variable in assignment. The difficulty with closures is that variables are explicitly declared, while closures are often implicit. When a closure is created and when the value of the closure is updated, it is easy to ignore.
It can be said that functional programming revolves around closures. In the source code analysis below, you will see numerous examples of closures.
applyMiddleware is the storeEnhancer officially implemented by Redux, which implements a set of plug-in mechanisms to increase store capabilities, such as implementing asynchronous Actions, implementing logger log printing, implementing state persistence, and so on.
export default function applyMiddleware<Ext, S = any>(
...middlewares: Middleware<any, S, any>[]
): StoreEnhancer<{ dispatch: Ext }>
Personal point of view, the advantage of this is that it provides space for making wheels
applyMiddleware accepts one or more middleware instances and passes them to createStore:
import { applyMiddleware, createStore } from "redux";
import thunk from "redux-thunk"; // 使用 thunk 中间件
import reducer from "./reducer";
const store = createStore(reducer, applyMiddleware(thunk));
The createStore parameter only accepts one storeEnhancer. If you need to pass in more than one, you can use the compose function in Redux Utils to combine them.
The compose function will be introduced later
Looking at the above usage, you can guess that applyMiddleware must also be a higher-order function. As mentioned earlier, some if..else logic in front of createStore was omitted because storeEnhancer was not used. Let's take a look here.
First look at the function signature of createStore, it can actually accept 1-3 parameters. Among them, reducer must be passed. When the second parameter is a function type, it will be recognized as storeEnhancer. If the second parameter is not a function type, it will be recognized as preloadedState, and you can also pass a function type storeEnhancer:
function createStore(reducer: Reducer, preloadedState?: PreloadedState | StoreEnhancer, enhancer?: StoreEnhancer): Store
You can see the logic of parameter verification in the source code:
// src/createStore.ts:71
if (
(typeof preloadedState === 'function' && typeof enhancer === 'function') ||
(typeof enhancer === 'function' && typeof arguments[3] === 'function')
) {
// 传递两个函数类型参数的时候,抛出异常
// 也就是只接受一个 storeEnhancer
throw new Error();
}
When the second parameter is a function type, handle it as storeEhancer:
// src/createStore.ts:82
if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
enhancer = preloadedState as StoreEnhancer<Ext, StateExt>
preloadedState = undefined
}
Next is a more difficult logic:
// src/createStore.ts:87
if (typeof enhancer !== 'undefined') {
// 如果使用了 enhancer
if (typeof enhancer !== 'function') {
// 如果 enhancer 不是函数就抛出异常
throw new Error();
}
// 直接返回调用 enhancer 之后的结果,并没有往下继续创建 store
// enhancer 肯定是一个高阶函数
// 先传入了 createStore,又传入 reducer 和 preloadedState
// 说明很有可能在 enhancer 内部再次调用 createStore
return enhancer(createStore)(
reducer,
preloadedState
)
}
Let's take a look at the source code of applyMiddleware. For ease of reading, the type annotations in the source code have been removed:
// src/applyMiddleware.ts
import compose from './compose';
function applyMiddleware(...middlewares) {
return (createStore) => (reducer, preloadedState) => {
const store = createStore(reducer, preloadedState);
let dispatch = () => {
throw new Error();
}
const middlewareAPI = {
getState: store.getState,
dispatch: (action, ...args) => dispatch(action, ...args)
}
const chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);
return {
...store,
dispatch
}
}
}
You can see that there is not much code here, but there is a situation where a function nests a function:
const applyMiddleware = (...middlewares) =>
(createStore) =>
(reducer, preloadedState) => {
// ...
}
Analyze the call link in the source code:
- When calling applyMiddleware, pass in the middleware instance and return the enhancer. From the usage of the remaining parameters, it can be seen that multiple middleware can be passed in;
- The enhancer is called by createStore, and the createStore and reducer and preloadedState are passed in twice;
- The createStore is called again internally. This time, since the enhancer is not passed, the process of creating the store is directly followed;
- Create a modified dispatch method that overrides the default dispatch;
- Construct middlewareAPI and inject middlewareAPI into middleware;
- Combine middleware instances into a function, and pass the default store.dispatch method to middleware;
- Finally, a new store instance is returned. At this time, the dispatch method of the store has been modified by middleware;
The compose function is involved here, which is a process often used in the functional programming paradigm. It creates a data flow from right to left, the result of the execution of the function on the right is passed as a parameter to the left, and finally returns a data flow executed with the above data flow. function:
// src/compose.ts:46
export default function compose(...funcs) {
if (funcs.length === 0) {
return (arg) => arg
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce(
(a, b) =>
(...args) =>
a(b(...args))
)
}
Thinking question: If you want to change the execution order from left to right, how do you need to change it?
Through the code here, it is not difficult to infer the structure of a middleware:
function middleware({ dispatch, getState }) {
// 接收 middlewareAPI
return function(next) {
// 接收默认的 store.dispatch 方法
return function(action) {
// 接收组件调用 dispatch 传入的 action
console.info('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
}
}
Seeing this, I think most readers will have two questions:
- Are the dispatch function obtained through the middleware API and the dispatch function finally exposed by the store instance modified?
- In order to prevent calling dispatch when creating middleware, applyMiddleware initializes the new dispatch as an empty function, and the call will throw an exception, so when is this function replaced;
You can try to think about it first.
To be honest, I was also troubled by these two problems when reading the source code, and most technical articles did not give explanations. No way, only by debugging the source code to find the answer. After continuous debugging, I finally figured out that the dispatch function of the middlewareAPI itself is actually introduced in the form of a closure. This closure may not be seen by many people:
// 定义新的 dispatch 方法
// 此时是一个空函数,调用会抛出异常
let dispatch = () => {
throw new Error();
}
// 定义 middlewareAPI
// 注意这里的 dispatch 是通过闭包形式引入的
const middlewareAPI = {
getState: store.getState,
dispatch: (action, ...args) => dispatch(action, ...args)
}
// 对 middleware 注入 middlewareAPI
// 此时在 middleware 中调用 dispatch 会抛出异常
const chain = middlewares.map(middleware => middleware(middlewareAPI));
Then the following code actually does two things. On the one hand, it combines the middleware into a function and injects the default dispatch function. On the other hand, it replaces the initial empty function of the new dispatch with a normal executable function. At the same time, since the dispatch of middlewareAPI is introduced in the form of a closure, when the dispatch is updated, the value in the closure is also updated accordingly:
// 将 dispatch 替换为正常的 dispatch 方法
// 注意闭包中的值也会相应更新,middleware 可以访问到更新后的方法
dispatch = compose(...chain)(store.dispatch);
That is to say, the dispatch and middleware exposed by the instance generated by createStore are all modified dispatch, and should look like this:
function(action) {
// 注意这里存在闭包
// 可以获取到中间件初始化传入的 dispatch、getState 和 next
// 如果你打断点,可以在 scope 中看到闭包的变量
// 同时注意这里的 dispatch 就是这个函数本身
console.info('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
4. Handling asynchronous actions
Since the reducer needs to be strictly controlled as a pure function, it cannot perform asynchronous operations in it, nor can it perform network requests. Some students may say that although the asynchronous code cannot be placed in the reducer, the dispatch function can be called in the asynchronous callback:
setTimeout(() => {
this.props.dispatch({ type: 'HIDE_NOTIFICATION' })
}, 5000)
In React components, connect is usually used to map the dispatch to the props of the component, similar to the usage of mapAction in Vuex.
It does! Redux author Dan Abramov has a very good answer on Stackoverflow, which endorses this usage:
I summarize Dan Abramov's core points below.
- Redux does provide some alternatives to handling asynchronous actions, but should only be used when you realize you've written a lot of template code. Otherwise, use the simplest solution (if not necessary, do not add entities);
- When multiple components need to use the same action.type, in order to avoid misspelling of action.type, it is necessary to extract the common actionCreator, for example:
// actionCreator.js
export function showNotification(text) {
return { type: 'SHOW_NOTIFICATION', text }
}
export function hideNotification() {
return { type: 'HIDE_NOTIFICATION' }
}
// component.js
import { showNotification, hideNotification } from '../actionCreator'
this.props.dispatch(showNotification('You just logged in.'))
setTimeout(() => {
this.props.dispatch(hideNotification())
}, 5000)
The above logic is completely feasible in simple scenarios, but as the business complexity increases, several problems will arise:
- Usually there are several steps in the status update, and there is a logical sequence, such as the display and hiding of notifications, resulting in a lot of template code;
- The submitted action has no state. If a race condition occurs, it may cause a bug in the state update;
- Due to the above problems, it is necessary to extract the asynchronous actionCreator, encapsulate the operations involving state updates, and facilitate reuse. At the same time, a unique id is generated for each dispatch:
// actions.js
function showNotification(id, text) {
return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
return { type: 'HIDE_NOTIFICATION', id }
}
let nextNotificationId = 0
export function showNotificationWithTimeout(dispatch, text) {
const id = nextNotificationId++
dispatch(showNotification(id, text))
setTimeout(() => {
dispatch(hideNotification(id))
}, 5000)
}
Then use it like this in the page component to solve the conflict between template code and state update:
// component.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')
// otherComponent.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged out.')
- Careful students should notice that dispatch is passed here. This is because normally only the component can access the dispatch. In order to allow the externally encapsulated functions to also be accessed, we need to pass the dispatch as a parameter;
- At this time, some students will question, if the store is used as a global singleton, it can be accessed directly:
// store.js
export default createStore(reducer)
// actions.js
import store from './store'
// ...
let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
const id = nextNotificationId++
store.dispatch(showNotification(id, text))
setTimeout(() => {
store.dispatch(hideNotification(id))
}, 5000)
}
// component.js
showNotificationWithTimeout('You just logged in.')
// otherComponent.js
showNotificationWithTimeout('You just logged out.')
- The above is indeed feasible in operation, but the Redux team does not agree with the singleton writing method. Their reason is that if the store becomes a singleton, it will make the implementation of server-side rendering difficult, and testing is also inconvenient. If you want to use the mock store instead, you need to modify all imports;
- For the above reasons, the Redux team still recommends passing dispatch as function arguments, although this is cumbersome. So is there a solution? Yes, using Redux-thunk solves this problem;
- In fact, the role of Redux-thunk is to teach Redux to recognize special Actions for function types;
- After the middleware is enabled, when the Action of the dispatch is a function type, Redux-thunk will pass the dispatch as a parameter to the function. It should be noted that the final reducer still gets the ordinary JavaScript object as the Action:
// actions.js
function showNotification(id, text) {
return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
return { type: 'HIDE_NOTIFICATION', id }
}
let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
return function (dispatch) {
const id = nextNotificationId++
dispatch(showNotification(id, text))
setTimeout(() => {
dispatch(hideNotification(id))
}, 5000)
}
}
Use the following in the component:
// component.js
this.props.dispatch(showNotificationWithTimeout('You just logged in.'))
Well, that concludes Dan Abramov's point.
Seeing this, everyone should be clear about the role of Redux-thunk. Redux-thunk itself does not provide an asynchronous solution. The easiest way to achieve asynchronous is to put the dispatch function in the asynchronous callback. Many times we will encapsulate the asynchronous actionCreator. It is very troublesome to pass the dispatch every time in the asynchronous operation. Redux-thunk encapsulates the dispatch function in a high-level, allowing to accept the Action of the function type, and at the same time pass the dispatch and the dispatch to the Action. getState as a parameter, so you don't have to manually pass it every time.
Before looking at the source code, you can combine the source code of applyMiddleware and think about the internal implementation of Redux-thunk.
In fact, the implementation principle of Redux-thunk is very simple. The code is as follows:
// src/index.ts:15
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument)
}
return next(action)
}
}
Inside Redux-thunk, the createThunkMiddleware method is first called to get a higher-order function and then exported. This function is the middleware structure we analyzed earlier:
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument)
}
return next(action)
}
First, in the initialization phase, applyMiddleware will inject middlewareAPI (corresponding to the dispatch and getState parameters) and store.dispatch (that is, the original dispatch method, corresponding to the next parameter) into the thunk.
After the initialization is completed, the dispatch of the store instance will be replaced with a modified dispatch method (the dispatch in the middlewareAPI will also be replaced because it is a closure reference). Printing with dispatch.toString() can output the following:
// 注意这里可以访问到闭包中的 dispatch、getState 和 next
// 初始化完成后的 dispatch 实际上就是下面这个函数本身
action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument)
}
return next(action)
}
The next thing is very simple, when we submit a function type Action:
// actions.js
const setUserInfo = data => ({
type: "SET_USER_INFO",
payload: data
})
export const getUserInfoAction = userId => {
return dispatch => {
getUserInfo(userId)
.then(res => {
dispatch(setUserInfo(res));
})
}
}
// component.js
import { getUserInfoAction } from "./actionCreator";
this.props.dispatch(getUserInfoAction("666"));
When the submitted action is a function type, this function is called, and then the dispatch, getState, and extraArgument parameters are passed in:
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument)
}
(It can be seen from here that in addition to dispatch, getState and extraArgument can also be accessed inside the Action of the function type)
When the asynchronous operation is completed, when the dispatch method passed by Redux-thunk is called to submit the object type Action, the modified dispatch method is still entered, but another branch is entered when judging the type:
return next(action);
The next here is the original dispatch method of Redux, which submits the Action of the object type to the reducer method, and finally performs the state update.
5. Summary
Redux is a very classic state management solution. It follows the principles of functional programming, the state is read-only and immutable, and the state can only be updated through pure functions.
But Redux also has many problems. First of all, for beginners, the cost of getting started is high, and you need to understand the concepts and design ideas of functional programming before using it. Secondly, Redux is very cumbersome in actual development. Even if a very simple function is implemented, 4-5 files may need to be modified at the same time, which reduces the development efficiency. In contrast, Vuex has a very low start-up cost, is very friendly to novices, and is very simple to use. It requires neither asynchronous middleware nor additional UI binding. The functions provided by plugins in Redux are all built-in out of the box. .
In this regard, Redux officially provides a packaging solution Redux Toolkit, and the community also provides many packaging solutions, such as Dva, Rematch, etc., to simplify the use of Redux, and many places for API packaging refer to Vuex. There is even a Mobx state management solution that is similar to Vue's responsiveness and uses mutable data. In addition, the official React team also recently launched the Recoil state management library.
refer to
https://github.com/reduxjs/redux
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。