React 状态管理器,我是这样选的

10,428 阅读15分钟

前言

我们的前端团队在一直深度使用 React ,从最早的 CRA ,到后来切换到 umijs ,从 1.x、2.x、3.x 再到现在的 4.x,其中有一点不变的,就是我们一直在使用基于 react-redux 思想的 dva 作为状态管理工具。

Pasted image 20221010153251.png

在状态共享这方面,不像 VuexReact 的官方并没有强力推荐某种封装方案,所以 React 的状态管理工具五花八门,百花齐放。其中就有:

  • 做什么都要 dispatchredux 流派。包括:react-reduxdva、新星代表 zustand
  • 响应式流派 mobx。以及新星代表 valtio ,以及一个很有特点的库 resso
  • 原子状态流派。来自 facebook 开源的 recoil ,以及新星代表 jotai
  • 完全体 hooks 流派。hoxretoumijs@4 内置数据流,包括 Vue 官方推荐的新状态管理工具 pinia 也是这个流派。

Pasted image 20221010144421.png

更为重要的一点是,传统的 MVC 模式,建议我们将视图层的逻辑层分离。而在 dva 中,页面即视图,effects 则被我们用于业务逻辑的编写。在这种思想的影响下,不论是简单还是复杂的页面,我们都习惯去创建一个 dva-model,再加上 dispatch 都不是强依赖关系,久而久之, model 越来越臃肿,关系越来越难找,吐槽声越来越大。

随着技术不断发展,我们终归是要摆脱繁琐的 dva,寻找一个新的状态管理工具,来减少我们这一块的代码量,保护我们的秀发。

所以经过了一系列的试点,我也来介绍一下各流派的优缺点和我个人的倾向。

我们需要什么样的状态管理工具

可能不需要?

我们阅读一些状态管理工具的文档时候,可能就会先被这样一篇文章甩在脸上:《你可能不需要状态管理工具》

Pasted image 20221010154205.png

是的,不管是用起来繁琐的 dva 还是更为简洁的 recoil ,我们都不应该滥用状态管理工具。滥用只会给我们后面的维护和重构带来麻烦。

什么时候需要?

状态管理工具的作用,就是状态的共享,当共享状态发生变化,所有使用方都会触发重新渲染。所以,当然是状态需要被多方共享的时候,才需要使用状态管理工具了。比如:

  • 当前登录的用户信息,姓名,角色,所属组织等
  • 静态数据字典的缓存
  • 需要 keep-alive 的数据(不一定用)
  • 页面功能复杂,模块化后,模块之间仍需要共享的数据

请不要在【基础组件】中使用共享的状态,【基础组件】应该保持自身的独立性,做到高内聚

对状态管理工具的要求?

随着项目经验的积累,我总结出了状态管理工具应该满足的几个特性:

  1. 共享状态(基础),能够满足上面列举的几个场景;
  2. 共享业务逻辑,比如修改个人密码后需要退出并跳转至登录页(在菜单栏和个人中心都要调用,相同的逻辑不应该写多次);
  3. 共享状态模块化,即按不同业务逻辑,分开不同的文件创建共享状态。
  4. 再复杂一些的,涉及到共享状态之间的依赖,比如当我修改当前登录人的角色之后(比如从”项目经理“切换至”系统管理员“),记录菜单权限的状态也需要更新。
  5. 使用时,有清晰的依赖来源(import from)。
  6. 对 TypeScript 支持良好,易编写。

主流状态管理工具都是怎么做的

传统流派 dva

Pasted image 20221010162917.png Pasted image 20221010163018.png

对比 dvaVuex 不能说是非常相似,只能说是一模一样了。

  • dvastateVuexstate,用于存放需要共享的状态。
  • dvareducersVuexmutations,用于编写修改共享状态的方法。
  • dvaeffectsVuexactions,用于编写存在副作用的方法,比如处理异步业务逻辑。
  • dva 使用 namespace 属性标记子模块的名称,Vuex 使用 modules 属性,拆分子模块。

优点

  • 在当时没有 hooks API 的环境下,算是一套不错的整合方案,能够满足绝大多数的共享业务场景。
  • 深度整合 redux redux-saga ,便于 redux 用户能够快速切换。

缺点

  • 使用时没有清晰的来源 dva 使用 hoc connect 的方式,将 store 中的属性注入到组件的 props 上。如下图:以 JS 的角度来看,products 的来源和类型都是非常不清晰的。 Pasted image 20221009144954.png
  • TypeScript 支持不友好 没有清晰的依赖关系,类型支持自然也是很差的。
  • 不能满足共享状态之间的依赖 比如我修改了当前用户角色,需要根据角色权限重新查询可访问的菜单。但我不能在菜单store中监听,只能在用户store主动触发菜单查询,或单独写一个组件,利用 componentDidUpdate 监听 用户store 再发起请求。

zustand

拥有 22K stars 的 zustand 则是非常值得尝试的传统派替代品。

  • 它面向 hooks API,一个 store 就是一个 hooksPasted image 20221009152647.png
  • 它更简洁,直接将 state / reducers / effects 平铺开来,functioneffects / reducers ,其它都是 state
  • import 来源清晰,对 TypeScript 的支持也更友好
  • 提供了 subscribe 接口,可在组件外监听状态变化,以实现状态依赖。

基本把上述的缺点都解决了。

Pasted image 20221010165125.png

响应式流派 mobx

Pasted image 20221010165515.png

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 ,我们有很多同学踩了这坑。 Pasted image 20221009155725.png
  • 基础组件如果也使用 mobx 则违反了高内聚的原则,不使用,两边风格又不统一。你见过哪个组件库需要附带一套状态管理工具的?
  • 响应式其实就是基于 Proxy 实现的,我明明希望传递的是一个数组,但拿到的却是一个 Proxy。强迫症实在受不了。

所以,mobx 很优秀,但我实在爱不来。

原子状态流派 recoil

Pasted image 20221010165859.png

体验过 recoil 之后,我能感受到,recoil 是希望你在使用全局状态时,和 useState 的体验完全一致。是的,useRecoilStateuseState 的使用方式几乎是完全一样的,只不过 recoil 的默认值需要使用 atom 包裹一下罢了。于是你的 atom 状态就实现全局共享了。

为了解决共享状态依赖的需求,recoil 还很贴心地提供了 selector API,用于实现共享状态的拆分和依赖,你把它当作 useMemo 或者计算属性来看待就可以了。(当然 selector 还有支持写入(set)以及异步处理,但我还没找到必须要用它的场景)

Pasted image 20221010165947.png

不足

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

Pasted image 20221010170053.png

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 实现的。

Pasted image 20221010171828.png

并且,和状态相关的业务逻辑,也写在了同一个 hooks,还可以不受限制地获得完整的 hooks 体验,使用第三方 hooks 库。最令人惊叹的是,由于都是 hooks API,你可以先在组件中编写业务逻辑,当发现逻辑需要共享时,直接复制抽离出去;或者是当你需要迁移部分功能到另外的项目,不需要共享了,只需要去掉 createStore ,它就又变成了普通 hooks 了。

umijs@4.x 数据流方案

umijs@4.x 正式推出后,我注意到它内置了一套和 hox 一模一样的数据流方案。我大概翻了一下,虽然它不是直接引用的 hox ,但内部实现逻辑如出一辙。

它的特点是,采用约定式目录结构,不用专门写 createStore ,而是自动帮我们引入了所有 model 目录下的 hooks,并注册。在页面中则是通过统一的 useModel,通过其自动生成的 namespace 引用,比如 useModel('product')。但这也导致了依赖不明确的问题,umi4 还特地通过编写插件的方式解决跳转问题。

但也正是它的约定式,产生了一些让我觉得膈应的地方:

  1. useModel('product') 必须要通过装插件才能点击跳转。
  2. 必须要在 umijs@4.x 体系下才能使用,无法快速复制迁移到其它的框架下使用。
  3. 插件偷偷帮你实现了 createStore,乍一看和普通的 hooks 完全一样,其实已经持久化了。对新人学习很不友好。(你写个 createStore 他还知道有不一样的地方,回去查。umijs@4.x 没看到文档根本不知道有这回事)

我的选择

对几个工具的主观评价(满分5)

场景dvazustandmobxrecoil/jotaihoxumijs@4.x
状态共享支持支持支持支持支持支持
业务逻辑共享effectsfunction不提供不提供hooks 自由实现hooks 自由实现
状态拆分/模块化namespace独立 store支持任意属性、对象独立 atom独立 store独立 store
共享状态依赖不支持自定义 useStore 实现,或 subscribe,不支持 create 时的 gettercomputedselectorhooks 直接依赖即可需要通过 useModel 依赖
使用时的依赖清晰不支持支持支持支持支持不支持
对 TypeScript 友好⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
易用性⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
(5分给atom,2分给selector)
⭐⭐⭐⭐⭐⭐⭐⭐⭐
和 React 亲和程度⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

综上,你可以发现,hox 的方案算是所有工具中最好的,能够满足所有的场景,并获得不错的评分。我也会在今后的工作中更深入地使用 hox 方案,发掘其是否还有更优秀的用法或隐藏的问题。

最后也欢迎各位一同讨论你们在状态管理工具上的取舍。

PS: 相同的支持者 —— Pinia

Vue 已经将默认的状态管理工具切换为了 Pinia。它的最大特点就是专注于提供 组合式API 风格的API(其实就是 Reacthooks 风格)。

特别是在 store 声明这一块,虽然 Pinia 还是从 Vuex 的风格开始介绍的,但是看到了它 组合式API 风格的 demo,我悟了:

Pasted image 20221011110012.png

Pasted image 20221011110042.png

ref 换成 useState ,这和 hox 有啥区别?Pinia 的案例也让我对 hox 有了更大的信心。是的,连隔壁 Vue 也推崇这种风格的状态共享方案。

关于 Vue 和 React 对比的一些感想: 我记得以前看过一些文章,大概意思就是说 Vue3.x 的组合式API 抄袭 React 的 hooks 之类的,社区吵的也很厉害,也有人开始反驳说 hooks 和 组合式API 根本不是一个东西,setup不会每次都执行之类的。 确实,他们肯定不是同一个东西,毕竟两个框架的渲染机制都是不一样的,但他们更多的在于思想的借鉴。hooks 风格有很多好处,功能结构清晰,数据来源明确等,其它框架参考了这种风格,结合自身实现了一套方案,达到了相同的目的,当我们后续需要使用更多其它框架的时候,能够很自然地映射过去,难道不是更好么。

PS:为什么不选

zustand

zustand 很优秀,但使用它仍然会让我联想到在 effects 中使用 dispatch 更新状态的痛苦时光。是啊,在 zustand 你还是要使用 set 来修改状态。虽然它也提供了额外的 setState 方法让你直接更新状态,但是风格和 useState 差得远啊,让我觉得不伦不类。

mobx / valtio

这两个很明显了,响应式的风格,跟 React 对着干,我当然是不选的。

recoil / jotai

我原本是比较倾向于 recoil 的,奈何 selector 的用法有一定的上手成本,让新手学完 useMemo 就能运用上不好么。

细心点你会发现,zustand / valtio / jotai 是同一个开源团队的作品,而且他们的名字分别是 德语、芬兰语、日语 的状态。所以没有选择某个,并不是说这些工具不好,他们之间没有孰优孰劣,只是面向风格不一样罢了。

redux-toolkit

因为长期接触 dva 并饱受其糟粕,比较少关注 redux 相关的内容了。

我简单阅读了下文档,说一下我的想法。 

看了下发布记录,近两年出来的 redux-toolkit 应该是利用 createSlice 补足 redux 本身在 状态拆分/模块化 这块的不足,以及解决 dispatch({ type:'xxx' }) 这种依赖关系不清晰的设计缺陷。

Demo 来看,整体风格还是借鉴的 Vuex / dva,但允许将 reducers 的方法单独提取出来,以供单独业务逻辑封装(比如 Demo 中的 incrementAsync) 

image.png

但我还是想吐槽的是 

  1. 既然 createSlice 已经是对 reducers 的封装,那么我直接执行 actions 的时候,直接调用不就好了,为什么还要套一层 dispatch(incrementByAmount(amount))? 是为了延续 dispatch 的坏习惯么? 
  2. 既然 createSelector 是为了更好地做模块拆分,为何不像 zustand 一样提供专门的 hooks 用于获取状态,而是仍要通过 useSelector 再写一遍 selectCount? 
  3. 内置了 immer。我觉得 immer 是一个非常牛x 的库,我在处理树状结构的数据时,用得非常爽。但我非常不建议将 immer 作为一个默认特性内置在 reducers 中。 immer 的特性会导致新人对引用类型的认知产生误解:为什么我在 reducers 中直接修改 state 不会影响原状态,在外面就会? 

immer 本身设计的是没问题的,使用的时候必须用 produce 包裹,并且内部状态都命名为 draftXXX,告诉我们在 produce 环境下的变量和外面是不一样的。  我认为,immer 应该由使用者根据自身的数据复杂度在代码中主动显示地使用(自己写 produce 创建环境)。工具库内部,你可以使用,但不应该将 immer 环境暴露给使用者。

总的来说,redux-toolkit 应该只是一个 redux 体系的填坑作品,确实弥补了 redux 在业务开发上的不足,但比起其它库,还是无法打动我。redux 的核心概念虽然简单,但仍需要有额外的学习成本,各种 redux 生态工具的学习成本就更高了。但 hox 的学习成本,在你会了 hooks 之后,是 0 啊😊。