react 状态管理应该用什么样的方式才能更好?是直接用 hook,还是第三方库 redux 这些?如何保证代码性能,又能提高可维护性?项目中一开始用的是 dva,写久了就发现不少问题,后来试着只用 hook 方式来管理,结果更是一团糟的,而且很容易导致 re-render。
dva 是基于 react + redux + saga 的数据流方案, 显著的提高数据层的开发体验,在 model 里面就可以完成 state 初始化、reducer 和 effect 函数,包含了同步和异步的方式。api 也容易理解,上手快。dva 可以用来处理业务逻辑、状态变化,并负责 http 请求,这种组织数据的方式,将 view 和 model 分割开来,各司其责。不过用久了也就暴露出不少问题了。
- 臃肿复杂的 model。model 的直观感受,容易写的非常长,看看日常业务开发的 model 代码量
其中大部分 ui 自身的状态都写在组件里面,model 代码量就接近 500 行。
model 里面缺少更细分的模块概念,所有状态、effect、reducer 都混在一起。如果要对某个业务组件单独开辟一个 model,只能通过建立新的命名看见方式。状态一多,维护很麻烦。相同状态的修改可能两个相隔很远的方法里面,每次修改都要 command + F 搜索下,看看有没有遗漏的。也可以通过定义模块细分,然后汇成一个 model 对象,不过这样操作更加奇怪了,会带来状态重复,操作混乱的问题。
后面试图降低 model 层的大小,只做联动状态处理,将数据管理放到各个 UI 组件里面,也就是分离状态管理和数据管理。不过这又会导致另外一个问题,交互复杂下的数据请求以及处理都放在了组件,无法统一处理,并且绑定在一起,会导致业务组件可维护性下降。如果状态不复杂,那也没有必要用 dva 了。
- 操作麻烦。不管是 dispatch 还是 put,都需要指定 type,无法简单调用函数。effect 里面获取状态需要调用 select,修改状态要通过 put,reducer 里面无法再次获取 state,只能修改。下面是一个简单的状态重置:
*reset({}: any, { put }: any): any {
yield put({
type: 'resetA',
});
yield put({
type: 'resetB',
});
yield put({
type: 'resetC',
});
yield put({
type: 'resetD',
});
}
一个简单的重置处理,涉及 A-D 的状态,原本 4 行代码就可以了,结果变成了 12 行,虽然 dva 的写法比 redux 体验好了很多,不过还是挺啰嗦的。
-
无法使用 async/await,dva 封装了 redux-saga,其采用的 generator 异步方式。毫无疑问 generator 对异步的控制方式更加细致,适用于复杂异步处理方面,async 函数可以用 generator 的自执行的方式来实现。不过在日常业务里面 async/await 更加通用方便,2023 年了,用 yield 在项目里面违和感太强了。
-
typescript 提示不够完善。dva 没有类型导出,基本的 put、select 这些要自己实现,社区有对应的方案,比如 dva-model-enhance,umi4 里面也有做升级 commit。
-
connect 使用,提供了组件和 model 之间的关联,不过带来两个问题,为了状态传递,在原组件之上不仅有个 connect 高阶组件,还隐藏了 ReactRedux.Provider,虽然中间已经用了很多 useMemo 优化了,不过性能开销少不了。第二个则是,每次使用新的状态,都需要先在 connect 进行传入,如果遇到后面不需要了,忘记删除,状态更新还是会导致函数更新的。
-
最重要的 dva,已经被弃坑了,主要开发截止到 2019 年底,这两年主要是更新依赖版本,没有新的功能。目前 umi 4 里面文档介绍的包含三种数据流:useModel、valtio 和 dva。在最新讨论里面也是建议不用 dva。
umi 自身内置数据流管理插件,采用 useModel 就可以在全局范围里面使用,非常方便,一开始想要替换 dva 的时首选也是这个方案。只是问题来了,这种 model 是全局的,意味着下面这种写法
import { useRequest } from 'ahooks';
import { getUser } from '@/services/user';
export default () => {
const { data: user, loading: loading } = useRequest(async () => {
const res = await getUser();
if (res) {
return res;
}
return {};
});
return {
user,
loading,
};
};
每次刷新任意一个页面都会执行生成 user 数据,对于通用全局状态,自然是没有问题,只是局部状态呢,完全不适用。不同页面涉及到的状态和数据,应该是各自维护,无需要暴露到全局。虽然 redux 在 connect 的时候会传入所有的命名空间状态,不过这里是初始化 state,并不会执行上面 useRequest 这样的方式。useModal 还是适用于全局变量,或者一些非常简单的系统。
状态管理初衷为了组件间通信,同时可以合理拆分逻辑,抽离状态,复用它们的能力。
react 本身的状态管理方式是 context,可以通过 Provider/Consumer 来传递使用,hook 方式则是 useContext。只是 react 的 context 实在过于简单,并且需要提供 Provider 才能使用,而且层层嵌套,容易导致 re-render。
状态管理在 class 里面主要是 redux 的方式,单向数据流,包括 dva 也是封装了 redux,其实现方式主要包含提供 createStore 以及 dispatch 方式,通过 action 执行对应的 reducer 来更新 state,简单实现如下:
// redux
const createStore = (reducer) => {
let state;
return { dispatch: (action) => state = reducer(state, action) };
};
// react-redux 7.0
const connect = (initMapStateToProps) => {
const contextValue = useContext(ContextToUse);
const actualChildProps = initMapStateToProps(contextValue.store);
return (WrappedComponent) => <ContextToUse.Provider value={overriddenContextValue}>
<WrappedComponent {...actualChildProps} >
</ContextToUse.Provider>
}
简化后结构还是很明了的。react-redux 连接 redux 以及 组件,将状态变化转递下去。此外还有其他状态管理的比如 MobX 这些。
hook 模式下,状态有了新的变化,从 class 的 setState 变化到 hook 方式,数据变得原子化、去中心化,不再是一个大一统的 state 里面维护,函数组件里面维护自身状态,redux 也推出了 useDispatch 以及 redux-toolkit,虽然没有以前那么模板化代码了,不过还是没有很简洁,感兴趣的可以看看 redux-toolkit。这里介绍几个轻量状态库:facebook 的 recoil、jotai 以及 zustand。这里看下它们的写法:
// recoil
const textState = atom({
key: 'textState',
default: '',
});
const [text, setText] = useRecoilState(textState);
// jotai
const countAtom = atom(0);
const [count, setCount] = useAtom(countAtom);
// zustand
const useBearStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
}));
const bears = useBearStore((state) => state.bears);
可以看出 recoil、jotai 两者风格很相似,都是分散的原子化状态管理模式,其中 jotai 介绍是受到 recoil 启发推出的,命名都是 atom。API 和原生 hook 方式很类似,尤其是 jotai。
jotai 也是需要在 APP 根部增加 Context.Provider 组件,然后 useAtom 的时候获取对应的 value。只是这里有个问题,Context 修改会导致父组件更新,从而导致子组件 re-render,这里又是如何避免的?
export function useAtomValue<Value>(atom: Atom<Value>, options?: Options) {
const store = useStore(options);
const [[valueFromReducer], rerender] = useReducer(
(prev) => {
const nextValue = store.get(atom)
// ...省略,判断是否返回 pre
return [nextValue]
},
undefined,
() => [store.get(atom)]
);
useEffect(() => {
const unsub = store.sub(atom, () => {
rerender()
})
rerender()
return unsub;
}, [store, atom]);
return valueFromReducer;
}
export const createStore = () => {
const atomStateMap = new WeakMap<AnyAtom, AtomState>()
const mountedMap = new WeakMap<AnyAtom, Mounted>()
// ...省略
return {
get: readAtom,
set: writeAtom,
sub: subscribeAtom,
}
}
为了避免更新 Context 导致全局更新,jotai 采用自己的一套发布订阅模式,在 useEffect 阶段收集 atom 和 rerender(也就是 dispatch),当 atom 的修改的时候,会执行所有订阅的 dispatch,从而触发组件更新,规避 context 更新问题。
zustand 可以说是 2022 年最热门的 react 状态管理库,看看其趋势,简直是遥遥邻先,一年下载量翻了四倍,比其他两个总和还多,目前大概是老大哥 redux 的 1/5。
zustand 相较前两个,特性非常明显,中心化的状态管理,从数据流考虑,分开 UI 组件和状态逻辑控制,可以说和 dev、redux 很类似,但是其 API 简单,并且支持 async 函数,同时不是单一的 store,可以按照功能模块组件划分创建 store,一个页面可能会有多个 store,解决了 dva 的 model 层过于臃肿问题。
zustand 提供了 selector,类似 redux 的 mapStateToProps,可以处理返回复杂的内容,并解构内容。如下面第一个例子
// 第一个例子
const { list, count } = useBearStore((state) => {
return {
list: state.data.filter(item => item.type === state.type),
count: state.count,
}
},
shallow,
);
// 第二个例子
const { count } = useBearStore();
const count = useBearStore((state) => state.count);
上面第一个例子中,shallow 表示浅对比,可以用来优化性能,默认严格相等才不会更新,避免 re-render。
而对于第二个例子,由于 useBearStore() 返回了所有状态,当有任意一个 state 发生更新的时候,组件就会更新,所以应该改为下面的第二种写法。对于第一个例子的写法,如果 useBearStore 去除第二个参数,也就是 shallow,则每次返回的都是新的对象,会导致组件异常 re-render。关于 selector 的性能优化可以看这里的讨论。
zustand 自身的实现还是比较容易理解,简化下如下所示
const createStoreImpl: CreateStoreImpl = (createState) => {
let state: TState
const listeners: Set<Listener> = new Set()
const getState: () => TState = () => state
const setState: SetStateInternal<TState> = (nextState, replace) => {
if (!Object.is(nextState, state)) {
const previousState = state
state =
replace ?? typeof nextState !== 'object'
? (nextState as TState)
: Object.assign({}, state, nextState)
}
}
const api = { setState, getState }
state = createState(setState, getState, api)
return api as any
}
const create = (createState) => (selector?: any, equalityFn?: any) => useStore(api, selector, equalityFn);
state 存储状态,setState 的时候修改 state,可以通过 getState 返回最新状态。上面代码省略了订阅处理,setState 会执行订阅的 listeners。
useStore 则是 react 自身的 useSyncExternalStoreWithSelector 方法,在 useSyncExternalStore 基础上多了 selector 和 isEqual 判断。useSyncExternalStore 让组件获取状态管理库的最新状态,避免渲染过程状态异常撕裂情况,现在第三方的状态管理库包括 redux 都用 useSyncExternalStore。
不管是 recoil 还是 jotai、zustand 核心问题是解决状态通信,而随着 hook 带来的改变,状态不再是以前单一中心,自上而下更新,出现类 hook 操作的原子化趋势,组件自治,state 变化,则订阅该 state 组件会 re-render。这三者 api 也非常容易上手,支持 async 函数的异步请求,toolkit 稍微繁琐一点。另外 zustand 还有中间件 zustand/middleware,提供了 redux 转换以及 immer 等能力。
除了状态管理,其实还有远程状态库,比如 TanStack Query、SWR、use-request 这些,如果只是 CURD 业务,用这些远程库后,你就会发现没有剩多少状态了,连引入新的状态库都不用,不过远程状态库自己用的也不多,就不深入介绍了。
最后,选择哪个组件库要看业务。目前工作相关的业务其逻辑交互复杂,同一页面联动状态多,可以抽出可复用的逻辑,适合 zustand 来代替 dva。
ChatGPT 火得一塌糊涂,来一张 zustand 的: