聊聊 React 中的状态管理

1,366 阅读13分钟

什么是“状态”

状态一句话简单解释就是界面上的动态数据,这些动态数据可以是按钮的颜色输入框的内容表格中的数据 ,也可以是一些湾湾绕绕的逻辑中的中间数据,修改状态会引起界面上显示的内容变化,操作界面上的可交互部分也会引起状态的变化。

React 中的状态就是 this.state 或者 useState 的产物。

为什么需要“状态管理”

其实如果是比较简单(代码层面上的简单)的界面,那么我们可能真的不需要专门状态管理,每个组件管好自己的状态,父子组件之间通信(不跨级)就使用 props 传一下就行了。简单的跨级通信其实用 HOC(高阶组件)也是可以解决问题的。

但是现实往往并不都是这么简单,前端的页面功能越来越多,项目越来越复杂,组件间需要跨级通信的情况可能也就越来越多,再用父传子这种模式去搞那将会把状态搞得非常乱,代码复杂度也会急剧增加,以后想看懂代码就难了。

状态管理方案

useReducer + useContext

这是一个比较推荐的方案,尤其在不是那么复杂的项目中,我们真的不需要一个状态管理的库,来增加项目的依赖、复杂性,甚至是学习成本。

下面来看一个 useReducer + useContext 来做状态管理的例子:

useContext + useReducer codesandbox 示例

App.js

import React, { createContext, useReducer } from 'react';
import './App.css';
import Child from './child';

/** 创建 context */
type StateType = { count: number };
type DispatchType = React.Dispatch<{
  type: string;
}>
export const AppContext = createContext<{ state: StateType, dispatch: DispatchType }>({
  state: { count: 0 },
  dispatch: () => {}
});

/** 定义 reducer */
export const UPDATE_COUNT = 'UPDATE_COUNT';
function reducer(state: StateType, action: { type: string }) {
  switch (action.type) {
    case UPDATE_COUNT:
      return { count: Math.random() };
    default:
      return state;
  }
}

/** 定义父组件 App */
function App() {
  console.log('App render');
  const [state, dispatch] = useReducer(reducer, { count: 1 });

  return (
    <AppContext.Provider value={{state, dispatch}}>
      <Child />
    </AppContext.Provider>
  )
}

export default App;

child.tsx

import React from "react";
import GrandChild from "./grandchild";

const Child = () => {
  console.log('Child render');
  return (
    <>
      <p>我是 Child 组件</p>
      <GrandChild />
    </>
  );
};

// 会重复渲染
// export default Child;
// 避免重复渲染
export default React.memo(Child);

grandchild.tsx

import { useContext } from "react";
import { AppContext, UPDATE_COUNT } from "./App";

export default function GrandChild() {
  console.log('GrandChild render');
  const { state, dispatch } = useContext(AppContext);

  return (
    <>
      <p>我是 GrandChild 组件,在此要用一下爷爷组件的状态和方法</p>
      <p>{state.count}</p>
      <button onClick={() => { dispatch({ type: UPDATE_COUNT }) }}>Update Count</button>
    </>
  );
}

可以从上面这个例子看到我们已经可以跨级地去操作状态了, 还是蛮方便的,其实用起来从代码层面上看和 Redux 也差不多太多。

不过还是不得不说一下 useContext + useReducer 这个方案的局限(要不然搞那么多状态管理库干嘛呢🙄):

  1. UI 的代码与状态的代码无法独立存在,互相耦合。
  2. 性能问题,一旦触发状态变化那么父组件 -> 子组件 -> 孙组件 这一路都会重新渲染,虽然可以使用 memo 来优化,但是还是有一定局限。
  3. 无法使用中间件。

Redux

说到状态管理 Redux 可能是一个无法避开的存在,就算你没用过也一定听过,可以说是状态管理库中的大哥大了。

不过 Redux 的上手成本真不低,尤其在 useDispatchuseSelector 等 hook 出现之前,还需要用高阶函数(connect)把 Redux 与 React 组件连接起来,再加上各种各样的中间件,很容易把人劝退了。

Redux 之所以复杂,还是因为他的设计原则是要让状态被集中管理起来并且可追踪、可预测,也正因如此才有了 actionsreducerstoredispatcher 以及单向数据流这些东西。

详细的介绍可以看我以前写的 Redux 学习笔记

下面用 Redux 来实现一下上面的那个例子:

Redux codesandbox 示例

App.tsx

import { Provider } from 'react-redux';
import store from './store';
import Child from './child';


function App() {
  console.log('App render');
  return (
    <Provider store={store}>
      <Child />
    </Provider>
  );
}

export default App;

store.ts

import { createStore, applyMiddleware } from 'redux';
import { createLogger } from 'redux-logger';
import reducers from './reducers';

const logger = createLogger();

// 现在 createStore 已经被标记为 deprecated 了
// 官方更推荐使用 @reduxjs/toolkit,不过这是另外一个话题了
const store = createStore(
  reducers,
  applyMiddleware(logger),
);

export default store;

reducers.ts

import { ActionType, UPDATE_COUNT } from "./actions";

export type StateType = {
  count: number
}

export default function reducers(state: StateType = { count: 0 }, action: ActionType) {
  switch (action.type) {
    case UPDATE_COUNT:
      return { count: Math.random() };
    default:
      return state;
  }
}

actions.ts

export type ActionType = {
  type: string
}

export const UPDATE_COUNT = 'UPDATE_COUNT';

child.tsx

import GrandChild from "./grandchild";

export default function Child() {
  console.log('Child render');
  return (
    <>
      <p>我是 Child 组件</p>
      <GrandChild />
    </>
  );
}

grandchild.tsx

import { useDispatch, useSelector } from "react-redux";
import { UPDATE_COUNT } from "./actions";
import { StateType } from "./reducers";

export default function GrandChild() {
  console.log('GrandChild render');
  const { count } = useSelector((state: StateType) => state);
  const dispatch = useDispatch();
  return (
    <>
      <p>我是 GrandChild 组件</p>
      <p>{count}</p>
      <button onClick={() => { dispatch({ type: UPDATE_COUNT }) }}>Update Count</button>
    </>
  );
}

可以运行一下上面的代码试一下,代码在执行时可以通过中间件看到状态变化前、变化后以及是什么 action 触发的状态更新,而且状态变化并不会伴随多余的渲染。总体来说,Redux 在大型项目中去使用还是非常优秀的,性能、可预测、可追踪、可调式都有保证,唯一的缺点就是代码写起来确实比较麻烦,为了实现一个简单的状态更新操作就要改好几个文件。

最后,必须再说下 Immutable(不可变数据),它其实是 Redux 的一个运行基础,你不可以去直接修改 store 中的值,而是必须要复制它的值,产生一个新的数据,新的数据包含了你要修改的部分。这样做的好处有以下几个:

  1. 性能优化,只需要比较 store 的引用是否一样就知道需不需要更新,不需要深层次的遍历每一个值是否相等;
  2. 易于调试和跟踪,因为可以看到前后两个状态;
  3. 易于推测,因为 action 前后的状态都是可以知道的,所以可以知道当前的 action 是否被正确处理了。

配合 immerjs 我们可以很方便地操作不可变数据。

MobX

MobX 也是一个十分成熟的状态管理库,MobX 与 Redux 不同,它使用的是 mutable(可变数据),也就是每次都是修改的同一个状态对象,基于响应式代理,在支持 proxy 的浏览器中它使用 proxy 实现,在不支持 proxy 的浏览器中它使用 Object.defineProperty 实现,也就是在 getset 操作时通过代理把修改收集起来,然后再通知所有依赖做更新,这其实和 Vue 的响应式代理类似。

就连尤大也说 React + MobX 本质上就是一个更繁琐的 Vue

下图是 MobX 的心智模型,和 Redux 很像吧。

image.png

下面看一下 Mobx 的具体使用方法:

MobX codesandbox 示例

App.tsx

import Child from "./child";

function App() {
  console.log('App render');

  return (
    <div>
      <Child />
    </div>
  )
}

export default App;

mobx-store.ts

import { makeAutoObservable } from "mobx";

class MobxStore {
  count = 0;
  constructor() {
    // 创建可观察对象
    makeAutoObservable(this);
  }

  updateCount() {
    this.count = Math.random();
  }
}

export default new MobxStore();

// 下面的写法与上面的 class 写法等价
// import { observable } from "mobx";

// 创建可观察对象
// const MobxStore = observable({
//   count: 2,
//   updateCount: function() {
//     this.count = Math.random();
//   }
// });

// export default MobxStore;

child.tsx

import GrandChild from "./grandchild";

const Child = () => {
  console.log('Child render');

  return (
    <div>
      <p>我是 Child 组件</p>
      <GrandChild />
    </div>
  )
};

export default Child;

grandchild.tsx

import { action } from "mobx";
import { observer } from "mobx-react-lite";
import MobxStore from "./mobx-store";

const GrandChild = () => {
  console.log('GrandChild render');

  return (
    <div>
      <p>我是 GrandChild 组件</p>
      <p>{MobxStore.count}</p>
      {/* action 状态更新 */}
      <button onClick={action(() => { MobxStore.count = Math.random() })}>Update Count1</button>
      {/* 下面这种更新方式与上面等价 */}
      <button onClick={() => MobxStore.updateCount()}>Update Count2</button>
    </div>
  )
};

// 设置观察者
export default observer(GrandChild);

这里我们同样是实现了一个和前面相同的功能,从上面的示例不难看出 MobX 完全是函数式的开发,不像 Redux 需要搞 reduceraction 这些东西,使用起来也十分简单,你甚至不用知道什么心智模型,掌握几个 API 就可以开始用了。

再来说一下,异步情况下需要注意的地方,直接上代码:

修改一下 mobx-store.ts,相信看完代码你已经知道怎么用了。

import { makeAutoObservable, runInAction } from "mobx";

function stop(ms: number): Promise<number> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(Math.random());
    }, ms);
  });
}

class MobxStore {
  count = 0;
  constructor() {
    // 创建可观察对象
    makeAutoObservable(this);
  }

  updateCount() {
    // 在严格模式下会报错
    // stop(100).then(res => {
    //   this.count = res;
    // });
    // runInAction(f) 实际上是 action(f)() 的语法糖,适合在异步下使用
    stop(1000).then(res => {
      runInAction(() => {
        this.count = res;
      });
    });
  }
}

export default new MobxStore();

再来看一下 autorun,这个 API 在程序第一次调用的时候会被直接执行,随后在其依赖项每次变化的时候也会执行,类似 useEffect 这样的副作用。举个例子:

修改一下 grandchild.tsx

// 省略一些代码
const GrandChild = () => {
  console.log("GrandChild render");

  useEffect(() => {
    // 如果执行 disposer 可以清理 autorun 产生的副作用
    const disposer = autorun(() => {console.log(MobxStore.count)})
  });

  return (
    // 省略一些代码
  );
};

export default observer(GrandChild);

界面第一次渲染的时候会打印 count 状态,此后每次点击按钮修改 count 的时候也会打印 count 状态,但是如果打印的不是 count 状态,而是一个点击按钮的时候不会被修改的其它状态,就不会触发打印了。另外, autorun 还可以设置执行的延时,具有参加官方文档

computed 可以根据现有状态或其它计算值衍生出的值,与 autorun 不同,他是可以产生新值的。如果任何影响计算值的值发生变化了,计算值将根据状态自动进行衍生。

const numbers = observable([1,2,3]);
const sum = computed(() => numbers.reduce((a, b) => a + b, 0));

最后总结 MobX:

  1. 函数式开发,简单好上手,性能也不错,组件没有多余的渲染;
  2. 可调试性较差;
  3. Mobx 会在组件挂载时收集依赖,和 state 建立联系,这个方式和 react 18 的并发模式(Concurrent Mode)中,可能无法平滑地迁移(至少在我写下这篇文章的时候 2022.5, Mobx 还未支持 React 18)。

Recoil

Recoil 是 2020 年 Facebook 官方出品的一个状态管理解决方案,目前(2022.5)还处于试验阶段,不过 Recoil 确实是一个有潜质的状态管理库。

Recoil 开发的动因是为了解决 React 的一些局限性,也就是 useContext + useReducer 那种方案的局限性,并希望改善其局限性的同时,保持 React 的样子。

Recoil 定义了一个有向图 (directed graph),正交同时又天然连结于 React 树上。状态的变化从该图的顶点atom)开始,流经纯函数 ( selector) 再传入组件。基于这样的实现有点如下(摘抄自官网):

  • 我们可以定义无需模板代码的 API,共享的状态拥有与 React 本地 state 一样简单的 get/set 接口 (当然如果需要,也可以使用 reducer 等进行封装)。
  • 我们有了与 Concurrent 模式及其他 React 新特性兼容的可能性。
  • 状态的定义是渐进式和分布式的,这使代码分割成为可能。
  • 无需修改对应的组件,就能将它们本地的 state 用派生数据替换。
  • 无需修改对应的组件,就能将派生数据在同步与异步间切换。
  • 我们能将导航视为头等概念,甚至可以将状态的转变编码进链接中。
  • 可以很轻松地以可回溯的方式持久化整个应用的状态,持久化的状态不会因为应用的改变而丢失。

下面看一下 Recoil 的具体使用方法:

Recoil codesandbox 示例

App.tsx

import { atom, RecoilRoot } from 'recoil';
import Child from './child';

export const countState = atom({
  key: 'countState',
  default: 0
});

function App() {
  return (
    <RecoilRoot>
      <Child />
    </RecoilRoot>
  );
}

export default App;

state.ts

import { atom, selector } from "recoil";

// atom 原子状态,某个状态的最小集合
export const countState = atom({
  key: 'countState',
  default: 0, // 可以是数组,对象,字面量
});

// selecor 派生状态,类似计算属性
export const countStateAdd1 = selector({
  key: 'countStateAdd1', // unique ID (with respect to other atoms/selectors)
  get: ({ get }) => {
    const count = get(countState);

    return count + 1;
  },
});

child.tsx

import GrandChild from "./grandchild";

const Child = () => {
  console.log('Child render');

  return (
    <div>
      <p>我是 Child 组件</p>
      <GrandChild />
    </div>
  )
};

export default Child;

grandchild.tsx

import { useRecoilState, useRecoilValue } from "recoil";
import { countState, countStateAdd1 } from "./state";

const GrandChild = () => {
  console.log('GrandChild render');

  const [count, setCount] = useRecoilState(countState);

  const countAdd1 = useRecoilValue(countStateAdd1);

  return (
    <div>
      <p>我是 GrandChild 组件</p>
      <p>{count}</p>
      <p>{countAdd1}</p>
      <button onClick={() => { setCount(Math.random()) }}>Update Count</button>
    </div>
  )
};

export default GrandChild;

总结:Recoil 的确是个优秀的状态管理库,简洁易用,但是目前还处于试验阶段,对于要上大项目的小伙伴还是要慎重考虑,只能说,未来可期。

Dva

听别人说过这个状态管理库,但是看了一下 GitHub - dvajs,好几年没更新了,所以不太推荐。

Redux Toolkit

Redux Toolkit 简称 RTK,是 redux 作者 Dan 大神主导开发的一个工具库 @reduxjs/toolkit

RTK 的出现主要是为了解决 redux 的几个痛点:

  1. 配置 Redux 太复杂,尤其使用 TS 的情况下,往往需要配置 actionTypesactionsreducersstore 等;
  2. 必须添加很多其它包都能让 redux 用起来更爽,比如:immerjsredux-thunk

使用 RTK 之后我们就只需要专心写 reducer 就行了。

如果我们使用下面命令创建项目

# Redux + Plain JS template
npx create-react-app my-app --template redux

# Redux + TypeScript template
npx create-react-app my-app --template redux-typescript

那么,你将会得到一个配置齐全的项目,专注写 reducer 就够了。

如果是现有项目想要添加直接安装 @reduxjs/toolkit

npm install @reduxjs/toolkit

安装完成之后还是需要自己添加一些配置才可以使用。

核心 API:

  • configureStore 用于配置 store,会把 store 默认和 UI 进行连接,并添加 redux-thunk 中间件
  • createAction、createReducer 创建 action、reducer 的一种方式,其实用 createSlice 就可以不用这俩 API 了
  • createSlice 创建 reducer 切,并集成了 immerjs
  • createAsyncThunk 用来创建异步逻辑,用于 reducer 里面

RTK-demo - codesandbox 示例

App.tsx

import { Provider } from 'react-redux';
import { store } from './store';
import Child from './child';

function App() {
  return (
    <Provider store={store}>
      <Child />
    </Provider>
  );
}

export default App;

store.ts

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

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

// 从 store 本身 推断 `RootState` 和 `AppDispatch` 的类型
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

hooks.ts

import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'

// 使用下面的 API,不要用 `useDispatch` 和 `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

counterSlice.ts

import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from './store';

// 为状态切片定义一个类型
interface CounterState {
  value: number
}

// 给该状态定义一个初始值
const initialState: CounterState = {
  value: 0,
}

function stop(ms: number): Promise<number> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(Math.random());
    }, ms);
  });
}

export const incrementAsync = createAsyncThunk(
  'counter/fetchCount',
  async () => {
    const response = await stop(1000);
    return response;
  },
);

export const counterSlice = createSlice({
  name: 'counter',
  // `createSlice` 将从 `initialState` 参数推断出状态类型
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    // 使用 PayloadAction 类型来声明 `action.payload` 的内容
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
  // 额外的 reducer,用于处理异步 action 的 reducer
  extraReducers: (builder) => {
    builder
      .addCase(incrementAsync.pending, (state) => {
        state.value += 10;
      })
      .addCase(incrementAsync.fulfilled, (state, action) => {
        state.value += 100;
      })
      .addCase(incrementAsync.rejected, (state) => {
        state.value -= 10;
      });
  },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// 其他代码,比如选择器,可以使用导入的 `RootState` 类型
export const selectCount = (state: RootState) => state.counter.value;

export default counterSlice.reducer;

child.tsx

import GrandChild from "./grandchild";

const Child = () => {
  console.log('Child render');

  return (
    <div>
      <p>我是 Child 组件</p>
      <GrandChild />
    </div>
  )
};

export default Child;

grandchild.tsx

import { useAppSelector, useAppDispatch } from './hooks';
import { decrement, increment, incrementAsync } from './counterSlice'

const GrandChild = () => {
  console.log('GrandChild render');

  const count = useAppSelector((state) => state.counter.value);
  const dispatch = useAppDispatch();

  return (
    <div>
      <p>我是 GrandChild 组件</p>
      <p>{count}</p>
      <button onClick={() => { dispatch(decrement()) }}>-</button>
      <button onClick={() => { dispatch(increment()) }}>+</button>
      <button onClick={() => { dispatch(incrementAsync()) }}>async +</button>
    </div>
  )
};

export default GrandChild;

除此之外,RTK 还提供了一个 RTK QueryRTK Query 是一种高级的数据获取和缓存工具,旨在简化 web 应用程序中加载数据的常见情况。RTK Query 本身构建在 Redux Toolkit 之上,并利用 RTK 的 API,如:createSlicecreateAsyncThunk 来实现其功能。

总结

方案成熟度上手成本编码成本并发支持
Redux支持
RTK支持
MobX暂不支持
Recoil支持

大项目直接推荐使用 RTK,因为根正苗红,成熟度高;如果对并发没什么要求 MobX 也是不错的选择;Recoil 未来可期,自己的项目或者玩玩还是很不错的,不过大项目还是等后续正式版本上线再斟酌使用;Dva 多年无人更新,不太推荐。