什么是“状态”
状态一句话简单解释就是界面上的动态数据,这些动态数据可以是按钮的颜色、输入框的内容、表格中的数据 ,也可以是一些湾湾绕绕的逻辑中的中间数据,修改状态会引起界面上显示的内容变化,操作界面上的可交互部分也会引起状态的变化。
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 这个方案的局限(要不然搞那么多状态管理库干嘛呢🙄):
- UI 的代码与状态的代码无法独立存在,互相耦合。
- 性能问题,一旦触发状态变化那么父组件 -> 子组件 -> 孙组件 这一路都会重新渲染,虽然可以使用
memo来优化,但是还是有一定局限。 - 无法使用中间件。
Redux
说到状态管理 Redux 可能是一个无法避开的存在,就算你没用过也一定听过,可以说是状态管理库中的大哥大了。
不过 Redux 的上手成本真不低,尤其在 useDispatch、useSelector 等 hook 出现之前,还需要用高阶函数(connect)把 Redux 与 React 组件连接起来,再加上各种各样的中间件,很容易把人劝退了。
Redux 之所以复杂,还是因为他的设计原则是要让状态被集中管理起来并且可追踪、可预测,也正因如此才有了 actions、reducer、store、dispatcher 以及单向数据流这些东西。
详细的介绍可以看我以前写的 Redux 学习笔记。
下面用 Redux 来实现一下上面的那个例子:
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 中的值,而是必须要复制它的值,产生一个新的数据,新的数据包含了你要修改的部分。这样做的好处有以下几个:
- 性能优化,只需要比较
store的引用是否一样就知道需不需要更新,不需要深层次的遍历每一个值是否相等; - 易于调试和跟踪,因为可以看到前后两个状态;
- 易于推测,因为
action前后的状态都是可以知道的,所以可以知道当前的action是否被正确处理了。
配合 immerjs 我们可以很方便地操作不可变数据。
MobX
MobX 也是一个十分成熟的状态管理库,MobX 与 Redux 不同,它使用的是 mutable(可变数据),也就是每次都是修改的同一个状态对象,基于响应式代理,在支持 proxy 的浏览器中它使用 proxy 实现,在不支持 proxy 的浏览器中它使用 Object.defineProperty 实现,也就是在 get、set 操作时通过代理把修改收集起来,然后再通知所有依赖做更新,这其实和 Vue 的响应式代理类似。
就连尤大也说 React + MobX 本质上就是一个更繁琐的 Vue 。
下图是 MobX 的心智模型,和 Redux 很像吧。
下面看一下 Mobx 的具体使用方法:
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 需要搞 reducer、action 这些东西,使用起来也十分简单,你甚至不用知道什么心智模型,掌握几个 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:
- 函数式开发,简单好上手,性能也不错,组件没有多余的渲染;
- 可调试性较差;
- 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 的具体使用方法:
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 的几个痛点:
- 配置 Redux 太复杂,尤其使用 TS 的情况下,往往需要配置
actionTypes、actions、reducers、store等; - 必须添加很多其它包都能让 redux 用起来更爽,比如:
immerjs、redux-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 里面
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 Query,RTK Query 是一种高级的数据获取和缓存工具,旨在简化 web 应用程序中加载数据的常见情况。RTK Query 本身构建在 Redux Toolkit 之上,并利用 RTK 的 API,如:createSlice 和 createAsyncThunk 来实现其功能。
总结
| 方案 | 成熟度 | 上手成本 | 编码成本 | 并发支持 |
|---|---|---|---|---|
| Redux | 高 | 高 | 高 | 支持 |
| RTK | 高 | 中 | 中 | 支持 |
| MobX | 高 | 中 | 中 | 暂不支持 |
| Recoil | 低 | 低 | 低 | 支持 |
大项目直接推荐使用 RTK,因为根正苗红,成熟度高;如果对并发没什么要求 MobX 也是不错的选择;Recoil 未来可期,自己的项目或者玩玩还是很不错的,不过大项目还是等后续正式版本上线再斟酌使用;Dva 多年无人更新,不太推荐。