前言
我们的前端团队在一直深度使用 React
,从最早的 CRA ,到后来切换到 umijs ,从 1.x、2.x、3.x 再到现在的 4.x,其中有一点不变的,就是我们一直在使用基于 react-redux
思想的 dva 作为状态管理工具。
在状态共享这方面,不像 Vuex
,React
的官方并没有强力推荐某种封装方案,所以 React
的状态管理工具五花八门,百花齐放。其中就有:
- 做什么都要
dispatch
的redux
流派。包括:react-redux
、dva
、新星代表zustand
- 响应式流派
mobx
。以及新星代表valtio
,以及一个很有特点的库resso
- 原子状态流派。来自
facebook
开源的recoil
,以及新星代表jotai
- 完全体
hooks
流派。hox
、reto
、umijs@4 内置数据流
,包括Vue
官方推荐的新状态管理工具pinia
也是这个流派。
更为重要的一点是,传统的 MVC
模式,建议我们将视图层的逻辑层分离。而在 dva
中,页面即视图,effects
则被我们用于业务逻辑的编写。在这种思想的影响下,不论是简单还是复杂的页面,我们都习惯去创建一个 dva-model
,再加上 dispatch
都不是强依赖关系,久而久之, model
越来越臃肿,关系越来越难找,吐槽声越来越大。
随着技术不断发展,我们终归是要摆脱繁琐的 dva
,寻找一个新的状态管理工具,来减少我们这一块的代码量,保护我们的秀发。
所以经过了一系列的试点,我也来介绍一下各流派的优缺点和我个人的倾向。
我们需要什么样的状态管理工具
可能不需要?
我们阅读一些状态管理工具的文档时候,可能就会先被这样一篇文章甩在脸上:《你可能不需要状态管理工具》。
是的,不管是用起来繁琐的 dva
还是更为简洁的 recoil
,我们都不应该滥用状态管理工具。滥用只会给我们后面的维护和重构带来麻烦。
什么时候需要?
状态管理工具的作用,就是状态的共享,当共享状态发生变化,所有使用方都会触发重新渲染。所以,当然是状态需要被多方共享的时候,才需要使用状态管理工具了。比如:
- 当前登录的用户信息,姓名,角色,所属组织等
- 静态数据字典的缓存
- 需要 keep-alive 的数据(不一定用)
- 页面功能复杂,模块化后,模块之间仍需要共享的数据
请不要在【基础组件】中使用共享的状态,【基础组件】应该保持自身的独立性,做到高内聚
对状态管理工具的要求?
随着项目经验的积累,我总结出了状态管理工具应该满足的几个特性:
- 共享状态(基础),能够满足上面列举的几个场景;
- 共享业务逻辑,比如修改个人密码后需要退出并跳转至登录页(在菜单栏和个人中心都要调用,相同的逻辑不应该写多次);
- 共享状态模块化,即按不同业务逻辑,分开不同的文件创建共享状态。
- 再复杂一些的,涉及到共享状态之间的依赖,比如当我修改当前登录人的角色之后(比如从”项目经理“切换至”系统管理员“),记录菜单权限的状态也需要更新。
- 使用时,有清晰的依赖来源(
import from
)。 - 对 TypeScript 支持良好,易编写。
主流状态管理工具都是怎么做的
传统流派 dva
对比 dva
和 Vuex
不能说是非常相似,只能说是一模一样了。
dva
的state
和Vuex
的state
,用于存放需要共享的状态。dva
的reducers
和Vuex
的mutations
,用于编写修改共享状态
的方法。dva
的effects
和Vuex
的actions
,用于编写存在副作用的方法,比如处理异步业务逻辑。dva
使用namespace
属性标记子模块的名称,Vuex
使用modules
属性,拆分子模块。
优点
- 在当时没有
hooks
API 的环境下,算是一套不错的整合方案,能够满足绝大多数的共享业务场景。 - 深度整合
redux
redux-saga
,便于redux
用户能够快速切换。
缺点
- 使用时没有清晰的来源
dva
使用hoc
connect
的方式,将store
中的属性注入到组件的props
上。如下图:以JS
的角度来看,products 的来源和类型都是非常不清晰的。 - 对
TypeScript
支持不友好
没有清晰的依赖关系,类型支持自然也是很差的。 - 不能满足共享状态之间的依赖
比如我修改了当前用户角色,需要根据角色权限重新查询可访问的菜单。但我不能在菜单store
中监听,只能在用户store
主动触发菜单查询,或单独写一个组件,利用componentDidUpdate
监听用户store
再发起请求。
zustand
拥有 22K stars 的 zustand
则是非常值得尝试的传统派替代品。
- 它面向
hooks
API,一个store
就是一个hooks
。 - 它更简洁,直接将
state
/reducers
/effects
平铺开来,function
即effects
/reducers
,其它都是state
。 import
来源清晰,对TypeScript
的支持也更友好- 提供了
subscribe
接口,可在组件外监听状态变化,以实现状态依赖。
基本把上述的缺点都解决了。
响应式流派 mobx
mobx
的出现给 redux
带来了很大的冲击。
通过一个装饰器(observer
/ observable
),就能使普通的组件能够监听变量的变化而渲染,完全抛弃掉 state
。不仅如此,mobx
提供了 computed
等一些好东西,使 React
也能使用到 Vue
组件的特性。而从 Vue
转过来学习 React
的同学,都会对 mobx
拍手称赞。
在体验过 mobx
的爽快之后,当时团队中有部分声音,是希望以后抛弃掉 state
,转而全面使用 mobx
作为组件状态。是啊,一套方案,解决内部状态和共享状态两个问题,何乐而不为呢。
缺憾
很快,这种 mobx
为王的声音很快又消失了,因为...
- 响应式 API 和
React
水土不服,React
就是需要setState
来修改状态,现在你state.value++
就改了,是要造反么?新来的同学学完React
基础,结果和他说,那些用不上,再学学mobx
响应式吧,新同学是否会心中充满问号? - 会有一些隐蔽的坑,比如往
observable
添加属性时,不能直接添加,而要通过extendObservable
,我们有很多同学踩了这坑。 - 基础组件如果也使用
mobx
则违反了高内聚的原则,不使用,两边风格又不统一。你见过哪个组件库需要附带一套状态管理工具的? - 响应式其实就是基于
Proxy
实现的,我明明希望传递的是一个数组,但拿到的却是一个Proxy
。强迫症实在受不了。
所以,mobx
很优秀,但我实在爱不来。
原子状态流派 recoil
体验过 recoil
之后,我能感受到,recoil
是希望你在使用全局状态时,和 useState
的体验完全一致。是的,useRecoilState
和 useState
的使用方式几乎是完全一样的,只不过 recoil
的默认值需要使用 atom
包裹一下罢了。于是你的 atom
状态就实现全局共享了。
为了解决共享状态依赖
的需求,recoil
还很贴心地提供了 selector
API,用于实现共享状态的拆分和依赖,你把它当作 useMemo
或者计算属性
来看待就可以了。(当然 selector
还有支持写入(set
)以及异步处理,但我还没找到必须要用它的场景)
不足
recoil
理念真的很简单,就是以 useState
的习惯实现状态共享。所以在业务逻辑共享
这一块,它似乎没有给出很好的方案。但是既然已经是面向 hooks
API 了,自定义 hooks
本身就可以实现业务逻辑的复用了。比如下面这段伪代码:
// src/hooks/useChangePassword.js
// 修改密码动作
export function useChangePassword(){
// 当前用户信息的共享状态
const [userInfo, setUserInfo] = useRecoilState(userAtom);
// 修改密码
const changePassword = async (oldPassword, newPassword) => {
// 1. 调用修改密码接口
const result = await post('/api/password', { oldPassword, newPassword });
if(reuslt.success) {
// 2. 清空当前用户信息
setUserInfo(null);
// 3. 跳转至登录页
history.push('/login');
// 4. 提示信息
message.warn('已修改密码,请重新登录');
} else {
// 操作失败提示
message.error(result.errMsg);
}
}
return changePassword;
}
这样,在个人中心
、菜单栏
、密码过期
的几个场景中,我都可以这一段 hooks
实现修改密码后的系列动作,而不是每个地方都调用一次接口。
jotai 几乎是完全对标 recoil 的,我就不赘述了
hooks 完全体 -- hox
hox
刚出来不久,我就关注到了,并觉得其思想非常棒。但翻阅了一下源码后发现,它依赖了一个实验性的渲染器 react-reconciler
,以至于我不敢将它用于生产环境。直到 react@18.x
带来了一个新的 hooks
: useSyncExternalStore
,以及基于它实现的 hox@2.x
。
我们来看它的介绍:
在 hox 中,任意的自定义 Hook,经过
createStore
包装后,就变成了持久化,可以在组件间进行共享的状态。
我的天,真的太神奇了,你只要用 createStore
包裹你写的某个 hooks
,它里面的状态就变成了可共享的了。
实现原理
举个简单的例子,我写了一个自定义 hooks
useMyHook:
export const useMyHook = () => {
const [value, setValue] = useState(1);
return [value, setValue];
}
我在 组件 A
和 组件 B
中都使用了它。正常情况下,A 和 B 中的 value 当然是不同的。
但是假如我“偷偷地”将 hooks
放在最外面执行,比如 App
下,然后再用 Context
传递下去:
// App.tsx
export const Context = React.createContext({});
export default function App() {
// 在最外层执行 hooks
const myHookResult = useMyHook();
// 通过 Context 向下传递
return (
<Context.Provider value={myHookResult}>
{children}
</ContextProvider>
)
}
我再给你一个封装后的 Hook:
// 在 组件A 和 组件B 中使用这个 hooks
export const useMyHookWrapped = () => {
// 从外部获取到 useMyHook 的内容
const myHookResult = useContext(Context);
return myHookResult;
}
综上,你就会发现,相当于 useMyHook
只被使用了一次,其它需要用到的地方,都是使用 useContext
获取的。那么自然就实现了状态共享了。而这些,都是 createStore
实现的。
并且,和状态相关的业务逻辑,也写在了同一个 hooks
,还可以不受限制地获得完整的 hooks
体验,使用第三方 hooks
库。最令人惊叹的是,由于都是 hooks
API,你可以先在组件中编写业务逻辑,当发现逻辑需要共享时,直接复制抽离出去;或者是当你需要迁移部分功能到另外的项目,不需要共享了,只需要去掉 createStore
,它就又变成了普通 hooks
了。
umijs@4.x 数据流方案
umijs@4.x
正式推出后,我注意到它内置了一套和 hox
一模一样的数据流方案。我大概翻了一下,虽然它不是直接引用的 hox
,但内部实现逻辑如出一辙。
它的特点是,采用约定式
目录结构,不用专门写 createStore
,而是自动帮我们引入了所有 model
目录下的 hooks
,并注册。在页面中则是通过统一的 useModel
,通过其自动生成的 namespace
引用,比如 useModel('product')
。但这也导致了依赖不明确的问题,umi4
还特地通过编写插件的方式解决跳转问题。
但也正是它的约定式,产生了一些让我觉得膈应的地方:
useModel('product')
必须要通过装插件才能点击跳转。- 必须要在
umijs@4.x
体系下才能使用,无法快速复制迁移到其它的框架下使用。 - 插件偷偷帮你实现了
createStore
,乍一看和普通的hooks
完全一样,其实已经持久化了。对新人学习很不友好。(你写个createStore
他还知道有不一样的地方,回去查。umijs@4.x
没看到文档根本不知道有这回事)
我的选择
对几个工具的主观评价(满分5)
场景 | dva | zustand | mobx | recoil/jotai | hox | umijs@4.x |
---|---|---|---|---|---|---|
状态共享 | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 |
业务逻辑共享 | effects | function | 不提供 | 不提供 | hooks 自由实现 |
hooks 自由实现 |
状态拆分/模块化 | namespace | 独立 store | 支持任意属性、对象 | 独立 atom | 独立 store | 独立 store |
共享状态依赖 | 不支持 | 自定义 useStore 实现,或 subscribe ,不支持 create 时的 getter |
computed | selector | hooks 直接依赖即可 |
需要通过 useModel 依赖 |
使用时的依赖清晰 | 不支持 | 支持 | 支持 | 支持 | 支持 | 不支持 |
对 TypeScript 友好 | ⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
易用性 | ⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐<br/>(5分给atom,2分给selector) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
和 React 亲和程度 | ⭐ | ⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
综上,你可以发现,hox
的方案算是所有工具中最好的,能够满足所有的场景,并获得不错的评分。我也会在今后的工作中更深入地使用 hox
方案,发掘其是否还有更优秀的用法或隐藏的问题。
最后也欢迎各位一同讨论你们在状态管理工具上的取舍。
PS:为什么不选
zustand
zustand
很优秀,但使用它仍然会让我联想到在 effects
中使用 dispatch
更新状态的痛苦时光。是啊,在 zustand
你还是要使用 set
来修改状态。虽然它也提供了额外的 setState
方法让你直接更新状态,但是风格和 useState
差得远啊,让我觉得不伦不类。
mobx / valtio
这两个很明显了,响应式的风格,跟 React
对着干,我当然是不选的。
recoil / jotai
我原本是比较倾向于 recoil
的,奈何 selector
的用法有一定的上手成本,让新手学完 useMemo
就能运用上不好么。
细心点你会发现,zustand / valtio / jotai 是同一个开源团队的作品,而且他们的名字分别是 德语、芬兰语、日语 中的“状态”。所以,不选择某个工具,不是说这些工具不好,他们之间并没有孰优孰劣,只是面向风格不一样罢了。