All articles

What is Redux?

Dec 20, 2022

本章记录一下 Redux 的学习过程,对于状态管理库,之前用的比较多的就是 VuexPinia,换到 React 之后,随即而来的也是适合它的状态库。后续的话会接触到 Recoil

react-redux experiment - github

redux

简单的来说下为什么需要 reduxJavaScript 的应用程序变得越来越复杂,状态也越来越多,包括了服务端返回的数据缓存的数据等。管理这些状态因此也变得比较困难,并且状态之间也存在依赖关系,React 只是在视图层帮我们解决了 DOM 的渲染过程,但是 State 依然是留给自己来管理的,因此无论是组件自己定义的 state 还是组件之间通过 props 进行通信,都是我们自己来维护 state。这个时候我们可以借助 ReduxRecoil 这样的状态容器进行管理 state

Keywords

action

action 是一个具有 type 字段的普通 JavaScript 对象,用于描述应用程序中发生了什么的事件,所有需要改变 state 的内容,都需要用 action,将 action 传入到 reducer 进行修改 state,例如:

const addTodoAction = {
  type: "todos/todoAdded",
  payload: "Buy milk",
};

action creator

顾名思义创建出一个 action 对象的函数,不需要每次都去手写 action 对象。

const addTodo = (text) => {
  return {
    type: "todos/todoAdded",
    payload: text,
  };
};

reducer

前面提到了需要将 action 传入 reducer 进行修改 state,首先说明 reducer 是一个函数,并且其一定是一个纯函数。函数签名是:(state, action) => newState 。接收当前的 stateaction,必要时决定如何更新状态,并返回新状态。

reducer 的原则:

单一数据源:

  • 整个 state 被存储在一棵 object tree 中,并且这个 tree 只能存储一个 store
  • Redux 并没有强制让我们不能创建多个 store,但这样不利于数据的维护。
  • 单一数据源可以让整个应用程序的 state 变得方便维护、追踪、修改。

state是只读的:

  • 唯一修改 state 的方法一定是触发 action,不要试图在其他地方通过任何方式来修改 state
  • 这样就确保了 view 或网络请求都不能直接修改 state,只能通过 action 来描述自己想要如何修改 state
  • 保证所有的修改都集中化处理,并且按照严格顺序来执行,不用担心 race condition 的问题。

使用纯函数来执行修改:

  • 通过 reducerold stateaction 联系起来,并且返回一个新的 state。
  • 可以将 reducer 拆分为多个小的 reducers,分别操作不同的 state tree 的一部分。
  • 但是所有的 reducer 都要是纯函数,不产生副作用。
const initialState = { value: 0 };

function counterReducer(
  state = initialState,
  action
) {
  // 检查 reducer 是否关心这个 action
  if (action.type === "counter/increment") {
    // 如果是,复制 `state`
    return {
      ...state,
      // 使用新值更新 state 副本
      value: state.value + 1,
    };
  }
  // 返回原来的 state 不变
  return state;
}

store

前面也反复提到了 storeredux 应用的 state 存在于一个名为 store 的对象中。store 是通过传入一个 reducer 来创建的,并且有一个名为 getState 的方法,返回当前状态值。

import { configureStore } from "@reduxjs/toolkit";

const store = configureStore({
  reducer: counterReducer,
});

console.log(store.getState());
// {value: 0}

注意这里用了 toolkit 工具,如果没有使用的话,需要用 createStore,传入 reducer

dispatch

Redux store 有一个方法叫做 dispatch,即是更新 state 的唯一方法,调用 store.dispatch() 传入一个 action 对象。store 将执行所有 reducer 函数并计算出更新后的 state,调用 getState 获取新的 state

redux process

  1. 整个应用程序的 state 通过订阅数据变化
  2. state 作为 props 传入到组件中
  3. 如果需要改变 state,通过 dispatch 派发预先定义好的 action
  4. reducer 接收到 action,根据 actiontype 进行更新 state
  5. 最终将 state 更新到整个引用程序的 state

react-redux

react-redux 目的就是将单独的 redux 代码融入到 react 中。还是以计数器为例,用类组件来简单说明。核心的代码主要也就两个:

  1. componentDidMount 中定义数据的变化,当数据变化时,重新设置 counter
  2. 在发生事件点击时,调用 storedispatch 来派发对应的 action

simple

下面的 index.jsreducer.jsactionCreators.js 表示一整个 store 最简单的配置方式。

  1. 通过 createStore 创建一个 store
// index.js
import { createStore } from "redux";
import reducer from "./reducer";

const store = createStore(reducer);

export default store;
  1. 编写 index.js 中需要的 reducer 纯函数。
// reducer.js
import * as actionTypes from "./constants";

const initialState = {
  counter: 100,
};

function reducer(state = initialState, action) {
  switch (action.type) {
    case actionTypes.ADD_NUMBER:
      return {
        ...state,
        counter: state.counter + action.num,
      };
    case actionTypes.SUB_NUMBER:
      return {
        ...state,
        counter: state.counter - action.num,
      };
    default:
      return state;
  }
}

export default reducer;
  1. 为了方便创建出 action,编写 actionCreators
// actionCreators.js
import * as actionTypes from "./constants";

export const addNumberAction = (num) => ({
  type: actionTypes.ADD_NUMBER,
  num,
});

export const subNumberAction = (num) => ({
  type: actionTypes.SUB_NUMBER,
  num,
});

下面具体在 home.jsx 中的使用 store

import React, { PureComponent } from "react";
import store from "../store";
import { addNumberAction } from "../store/actionCreators";

export class Home extends PureComponent {
  constructor() {
    super();
    // 初始化组件的 state
    this.state = {
      // 获取 store 中的 counter
      counter: store.getState().counter,
    };
  }

  componentDidMount() {
    // 订阅 store 当 counter 改变时,重新 set 组件内的 state
    store.subscribe(() => {
      const state = store.getState();
      this.setState({ counter: state.counter });
    });
  }

  // 调用 store 的 dispatch 派发任务
  addNumber(num) {
    store.dispatch(addNumberAction(num));
  }

  render() {
    const { counter } = this.state;

    return (
      <div>
        <h2>Counter: {counter}</h2>
        <div>
          <button
            onClick={(e) => this.addNumber(1)}
          >
            +1
          </button>
        </div>
      </div>
    );
  }
}

export default Home;

connect

connect 是一个高阶组件,(mapStateToProps, mapDispatchToProps),可以传入两个函数:

  • 第一个函数是将 state 映射到组件的 props 上,主要是使用 state
  • 第二个函数是将 dispatch 映射到组件的 props 上,主要是修改 state

如下代码,第一个参数是 store 中的 state,可以直接映射 counter

const mapStateToProps = (state) => ({
  counter: state.counter,
});

dispatch 会作为第二个函数的第一个参数,可以直接获取到 store.dispatch 派发任务。

const mapDispatchToProps = (dispatch) => ({
  addNumber(num) {
    dispatch(addNumberAction(num));
  },
  subNumber(num) {
    dispatch(subNumberAction(num));
  },
});

因此可以将最上面的 simple 版本代码,修改成如下代码,使用 connect 的高阶组件 Homestatedispath 都可以在 props 中获取或调度。

export class Home extends PureComponent {
  calcNumber(num, isAdd) {
    if (isAdd) {
      this.props.addNumber(num);
    } else {
      this.props.subNumber(num);
    }
  }

  render() {
    const { counter } = this.props;

    return (
      <div>
        <h2>Counter: {counter}</h2>
        <div>
          <button
            onClick={(e) =>
              this.calcNumber(6, true)
            }
          >
            +6
          </button>
          <button
            onClick={(e) =>
              this.calcNumber(6, false)
            }
          >
            -6
          </button>
        </div>
      </div>
    );
  }
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Home);

后续会使用 useDispatchuseSelector 在函数组件中使用 redux。可以暂时参考官方文档:The React Counter Component

redux-thunk

上面所有对 counter 的操作都是同步操作 dispatch action 的,state 会被立即更新,但是实际的开发中有大量的操作是需要异步进行的,redux 中也可能有许多从服务器异步拉下来的数据。

在类组件中,一般都只是在 componentDidMount 的生命周期中发送异步请求,然后再等获取到之后通过调用 dispatch action 去改变 storestate

这样的话逻辑上会有缺陷,网络请求的异步代码需要放在组件的生命周期中完成;其次,网络请求到的数据也属于状态管理的一部分,更好的方式是,让 redux 来进行请求管理。如果想在 redux 中完成异步操作的话,需要引入**中间件(Middleware)**的概念。

Middleware

中间件的目的是在 dispatchaction 和最终达到的 reducer 之间,扩展一些自己的代码。而在官网中推荐的、演示的网络请求中间件是 redux-thunk

默认情况下我们的 dispatch(action)action 是一个普通的 JS 对象,在使用 redux-thunk 后,可以使用 dispatch(actionFunction),并且此函数会被调用,调用时候会传入一个 dispatch 函数和一个 getState 函数。

Usage

对于 redux-thunk 的用法,需要 store 时传入应用 middlewareenhance 函数。

const enhancer = applyMiddleware(thunk);
const store = createStore(reducer, enhancer);

返回定义一个函数的 action,这里已经不再是对象,dispatch 派发的是一个函数,该函数会在 dispatch 之后执行。

// actionCreators.js
export const fetchDataAction = () => {
  return (dispatch, getState) => {
    axios
      .get("https://localhost:7777/get")
      .then((res) => {
        dispatch(changeDataAction(res.data));
      });
  };
};

回到类组件中,还是使用 connectdispatch 作为 props 传入到类组件中,后续的流程和前面的保持一致,在 componentDidMount 中调用 this.props.fetchData() 获取数据,并且保存到 store 中。

const mapDispatchToProps = (dispatch) => ({
  fetchData() {
    dispatch(fetchDataAction());
  },
});

reducer code splitting

之前提到过,通过 createStore 创建 store 时,需要传入一个 reducer,但是针对不同的模块进行一起管理会很混乱,这个时候引入了 reducer 的代码分块,将一个大的 reducer 分为许多小模块的 reducer,通过 combineReducers API 进行组合起来,再通过 createStore 创建 store

const reducer = combineReducers({
  counter: counterReducer,
  home: homeReducer,
  user: userReducer,
});
const store = createStore(reducer, enhancer);

对于 combineReducers 的实现原理,本质上是将我们传入的 reducers 合并到一个对象,最终返回一个 combination 函数。执行 combination 之前会先判断前后返回的数据是否相同来决定返回之前的 state 还是新的 state。新的 state 会触发订阅者发生对应的刷新,旧的 state 可以有效的阻止订阅者发生刷新。

function reducer(state = {}, action) {
  return {
    counter: counterReducer(
      state.counter,
      action
    ),
    home: homeReducer(state.home, action),
    user: userReducer(state.user, action),
  };
}

如果这里这么用了之后,前面所再类组件中使用 storestate 需要注意,这时应该走到对应模块的 reducer 下面,再获取 state

redux toolkit

如果在前面是用 vscode 进行上述实验,会发现 createStore 这个 API 已经不推荐使用,这是因为官方推荐使用 rtk 进行相关逻辑的编写。虽然前面也可以通过代码拆分、文件拆分达到分模块管理,但是代码量过多,不利于管理。

rtk 中有几个核心的 API

  • configureStore:对前面的 createStore 进行了简化配置选项,进行包装。可以自动组合我们的 slice reducer,添加提供的任务 redux middleware,并且默认包含了 redux-thunkredux devtools extension
  • createSlice:顾名思义创建一个切片,接收 reducer 函数的对象、切片名称和初始状态值,并且自动生成切片 reducer,带有响应的 actions
  • createAsyncThunk:接受一个动作类型字符串和一个返回承诺的函数,并生成一个 pendingfulfilledrejected 分派动作类型的 thunk

在底层,rtk 使用 immerJS 库,保证了数据的不可变性。会跟踪我们尝试进行的所有更改,然后使用该更改列表返回一个安全的、不可变的更新值,就好像我们手动编写了所有不可变的更新逻辑一样。

You can only write “mutating” logic in Redux Toolkit’s createSlice and createReducer because they use Immer inside! If you write mutating logic in reducers without Immer, it will mutate the state and cause bugs!

refactor

下面使用 createSlice 重构上面不同模块的 reducer。其包含如下几个参数:

  • name:用户标记 slice 的名词。在之后的 redux-devtool 中会显示对应的名词。
  • initalState:初始化值,第一次初始化的 state
  • reducers:相当于之前的 reducer 函数,对象类型,并且可以添加很多的函数,每个函数类似于之前 reducer 中的 case。其包括两个参数:
    • state
    • 调用这个 action 时,传递的 action 参数。

重构上面计数器案例。

import { createSlice } from "@reduxjs/toolkit";

const counterSlice = createSlice({
  name: "counter",
  initialState: {
    counter: 888,
  },
  reducers: {
    addNumber(state, { payload }) {
      state.counter = state.counter + payload;
    },
    subNumber(state, { payload }) {
      state.counter = state.counter - payload;
    },
  },
});

export const { addNumber, subNumber } =
  counterSlice.actions;
export default counterSlice.reducer;

当使用 createSlice 重构之前的 reducer 之后,使用 configureStore 创建 store

import { configureStore } from "@reduxjs/toolkit";

import counterReducer from "./features/counter";
import homeReducer from "./features/home";

const store = configureStore({
  reducer: {
    counter: counterReducer,
    home: homeReducer,
  },
});

export default store;

aysnc

在之前的内容中可以通过 redux-thunk middlewaredispatch 进行异步操作。在 rtk 中,通过 createAsyncThunk 进行异步操作。

export const fetchHomeMultidataAction =
  createAsyncThunk(
    "fetch/homemultidata",
    async (payload, extraInfo) => {
      const res = await axios.get(
        "https://localhost:7777/get"
      );
      return res.data;
    }
  );

createSliceextraReducer 中监听结果

extraReducers: {
  [fetchHomeMultidataAction.pending](state, action) {
    console.log("fetchHomeMultidataAction pending");
  },
  [fetchHomeMultidataAction.fulfilled](state, { payload }) {
    state.banners = payload.data.banner.list;
    state.recommends = payload.data.recommend.list;
  },
  [fetchHomeMultidataAction.rejected](state, action) {
    console.log("fetchHomeMultidataAction rejected");
  },
},

当然也可以直接在 createAsyncThunk 中进行 dispatch 派发。

export const fetchHomeMultidataAction =
  createAsyncThunk(
    "fetch/homemultidata",
    async (extraInfo, { dispatch, getState }) => {
      // 1.发送网络请求, 获取数据
      const res = await axios.get(
        "https://localhost:7777/get"
      );

      // 2.取出数据, 并且在此处直接dispatch操作(可以不做)
      const banners = res.data.data.banner.list;
      const recommends =
        res.data.data.recommend.list;
      dispatch(changeBanners(banners));
      dispatch(changeRecommends(recommends));

      // 3.返回结果, 那么action状态会变成fulfilled状态
      return res.data;
    }
  );

custom connect

前面通过引入 react-reduxconnect 实现将 storestatedispatch 映射到类组件的 props 上,从用法上来看,本质上返回的就是一个高阶组件。下面简单实现一下 connect

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(About);

从这里看来,connect 需要传入两个参数,并且返回一个函数,返回的函数可以传入类组件,因此不难写出框架。

export function connect(
  mapStateToProps,
  mapDispatchToProps
) {
  return function (WrapperComponent) {};
}

然后对传入进来的 WrapperComponent 进行二次包装之后返回出去。其次就是需要处理 connect 的核心逻辑,connect 主要就是将 statedispatch 挂到新类组件的属性上去,让它可以通过 props 能够访问。

code

export function connect(
  mapStateToProps,
  mapDispatchToProps
) {
  return function (WrapperComponent) {
    class NewComponent extends PureComponent {
      constructor(props) {
        super(props);
        this.state = mapStateToProps(
          store.getState()
        );
      }
      componentDidMount() {
        this.unsubscribe = store.subscribe(() => {
          this.setState(
            mapStateToProps(store.getState())
          );
        });
      }
      componentWillUnmount() {
        this.unsubscribe();
      }
      render() {
        const stateObj = mapStateToProps(
          store.getState()
        );
        const dispatchObj = mapDispatchToProps(
          store.dispatch
        );
        return (
          <WrapperComponent
            {...this.props}
            {...stateObj}
            {...dispatchObj}
          />
        );
      }
    }

    return NewComponent;
  };
}

通过上面的代码实现了 connect 的核心功能,但是比较依赖于导入的 store。如果将其封装成一个独立的库,需要依赖与创建的 store。因此需要对代码进行改进。

optimization code

React 官方文档

因此可以提供一个 Provider,来源于创建的 Context,让用户将 store 传入到value。这样可以在类组件中通过 contextconnect 中获取到 store。优化后的代码如下所示。

export function connect(
  mapStateToProps,
  mapDispatchToProps
) {
  return function (WrapperComponent) {
    class NewComponent extends PureComponent {
      constructor(props, context) {
        super(props);
        this.state = mapStateToProps(
          context.getState()
        );
      }
      componentDidMount() {
        this.unsubscribe = this.context.subscribe(
          () => {
            this.setState(
              mapStateToProps(
                this.context.getState()
              )
            );
          }
        );
      }
      componentWillUnmount() {
        this.unsubscribe();
      }
      render() {
        const stateObj = mapStateToProps(
          this.context.getState()
        );
        const dispatchObj = mapDispatchToProps(
          this.context.dispatch
        );
        return (
          <WrapperComponent
            {...this.props}
            {...stateObj}
            {...dispatchObj}
          />
        );
      }
    }
    NewComponent.contextType = StoreContext;

    return NewComponent;
  };
}

index.js 中,需要提供 value

// index.js
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <StoreContext.Provider value={store}>
        <App />
      </StoreContext.Provider>
    </Provider>
  </React.StrictMode>
);

custom middleware

在使用 redux-thunk 时,提到了 middleware 的概念,在很多场景下,可能需要我们来自定义中间件,因此这里以自定义一些简单的中间件为例。

const store = createStore(reducer, enhancer);

在没有使用 rtk 时,通过 createStore 来创建 store,将使用的中间件作为第二个参数 enhancer 传递进去。因此我们在构建创建 store 时,就不采用传递第二个参数。

以自定义一个 thunk 为例,thunk 是在 store 派发(dispatch)时,可以传入一个函数,并且会执行该函数之后再进行派发。

  1. 先创建一个 thunk 函数,将 store 作为参数传递进去。
  2. 先保存原本的 dispatch 内容到 next
  3. 修改 dispatch 的指向,让它指向我们自定义的 dispatchThunk 函数。
  4. dispatchThunk 中,需要判断 action 是函数或者是对象。 4.1. 如果是对象,直接使用原本的 dispatch 派发下去。 4.2. 如果是函数,需要执行这个函数。
function thunk(store) {
  const next = store.dispatch;
  function dispatchThunk(action) {
    if (typeof action === "function") {
      action(store.dispatch, store.getState);
    } else {
      next(action);
    }
  }
  store.dispatch = dispatchThunk;
}

export default thunk;

store/index.js 创建 store 后,只需要对 thunk 进行调用,thunk(store) 即可实现对自定义中间件的使用。

use hooks

react-redux 中提供了两个 hooks 函数,useSelectoruseDispatch,从命名角度不难看出它想表达的意思。useSelector 主要用于选择需要的 storeuseDispatch 主要是用于派发任务。

下面将使用 React Hooks 来体验一下 redux。对于创建 store 这里使用 rtk 来创建,步骤和前面所写到的内容是一致的。这里只说明一下函数组件中如何使用 store

import { useState } from "react";
import {
  useSelector,
  useDispatch,
} from "react-redux";
import {
  selectCounter,
  addNumber,
  subNumber,
  addNumberAsync,
} from "../store/features/counter/counterSlice";

export const Counter = () => {
  const count = useSelector(selectCounter);
  const dispatch = useDispatch();
  const [increment, setIncrement] = useState(8);
  return (
    <div>
      <div className="store">
        store counter: {count}
      </div>
      <div className="increment">
        increment / decrement: {increment}
      </div>
      <div>
        <input
          type="text"
          value={increment}
          onChange={(e) =>
            setIncrement(e.target.value)
          }
        />
      </div>
      <div className="operator">
        <button
          onClick={() =>
            dispatch(addNumber(Number(increment)))
          }
        >
          Add (+)
        </button>
        <button
          onClick={() =>
            dispatch(subNumber(Number(increment)))
          }
        >
          Sub (-)
        </button>
        <button
          className="asyncButton"
          onClick={() =>
            dispatch(
              addNumberAsync(Number(increment))
            )
          }
        >
          Add Async
        </button>
      </div>
    </div>
  );
};

summary

本篇文章记录了 redux 的一些简单逻辑、使用方法。并且最后和朋友讨论了下redux 确实概念多,使用繁琐,不过学习完能够了解到 react 相关生态。在不同的公司不同部门对状态管理的选择都是不同的,有的使用 redux,有的使用 recoil,甚至有些厉害的部门会使用自研的状态管理工具,所以学习 redux 只是学习这个思想,再熟悉之后,自己能够开发出适合自己的状态管理库。如果后续有时间的话,可以尝试自己写一个状态管理库。

antcao.me © 2022-PRESENT

: 0x9aB9C...7ee7d