如何选择React状态管理库
当你开始使用React时,状态的概念是比较棘手的事情之一,随着你的应用程序的增长,你的状态管理需求也在增长。
在这篇文章中,我将向你介绍React中的状态管理选项,并帮助你决定在你的项目中使用哪一种。
什么是状态?
为了让我们站在同一起跑线上,让我们先来谈谈状态问题。
每个交互式应用都涉及到对事件的响应,比如当用户点击一个按钮时,侧边栏就会关闭。或者有人发了一条信息,它就会出现在一个聊天窗口中。
当这些事件发生时,应用程序被更新以反映它们,我们说应用程序的状态已经改变。该应用程序看起来与以前不同,或者它在幕后处于一种新的模式。
像 "侧边栏是打开还是关闭 "和 "聊天框中的信息 "都是状态的一部分。在编程方面,你可能会在应用程序的某个地方有一个isSidebarOpen 变量,设置为true ,还有一个chatMessages 数组,里面有你收到的消息。
在任何给定的时刻,广泛地说,"你的应用程序的状态 "是由所有这些数据决定的。所有这些单独的变量,无论它们是存储在本地组件状态还是一些第三方状态管理存储中--那就是你的应用程序的状态。
这就是 "应用程序状态 "的高级概念。我们还没有谈论React特定的东西,如useState 、Context或Redux或任何东西。
什么是状态管理?
所有这些决定你的应用程序处于何种状态的变量都必须被存储在某个地方。所以状态管理是一个广泛的术语,它结合了你如何存储状态和如何改变它。
React及其生态系统提供了许多不同的方法来存储和管理这些状态。当我说很多时,我的意思是很多。
储存数据
对于存储,你可以...
- 将这些变量保存在本地组件状态中--无论是用钩子(
useState或useReducer)还是用类(this.state和this.setState)。 - 使用第三方库,如Redux、MobX、Recoil或Zustand,将数据保存在一个商店中
- 你甚至可以把它们保存在
window对象上。
React并不关心你把数据放在哪里,但是......
更新数据和重新渲染
为了使你的应用程序具有交互性,你需要一种方法让React知道有什么变化,并且它应该重新渲染页面上的一些(或所有)组件。
因为React,尽管它的名字,并不像其他一些框架那样是 "反应性 "的。
有些框架会 "观察 "事物,并进行相应的更新。Angular、Svelte和Vue就是这样做的。
但React不是这样。它不会 "观察变化 "并神奇地重新渲染。你(或其他东西)需要告诉它这样做:
- 使用
useState,useReducer, 或this.setState(类),React会在你调用其中一个setter函数时重新渲染。 - 如果你把数据保存在Redux、MobX、Recoil或其他一些存储中,那么该存储将告诉React什么时候发生了变化,并为你触发重新渲染。
- 如果你选择在
window,你需要告诉React在你改变这些数据后进行更新。
哦,我想说的是,我不建议在window 上全局保留你的状态,因为所有的原因都是要避免全局数据的。混乱的代码,难以推理,等等等等。我提到它只是想说这是可能的,以表明React真的不在乎它的数据来自哪里 :)
什么时候useState是不够的?
useState钩子对于少量的本地组件状态是完美的。每个useState 调用可以持有一个值,虽然你可以让这个值成为一个包含一堆其他值的对象,但把它们分割开来是一个更好的主意。
一旦你在一个组件中超过3-5个useState 调用,事情可能会变得难以追踪。特别是当这些状态位相互依赖的时候。对于复杂的相互依赖关系,适当的状态机可能是一个更好的方法。
下一步,useReducer
从useState "向上 "的下一步是useReducer 。reducer函数给你一个集中的地方来拦截 "行动 "并相应地更新状态。一个useReducer ,就像useState ,只能容纳一个值,但是对于一个reducer来说,这个单一的值是一个包含多个值的对象,这是很常见的。useReducer钩子使得管理该对象更加容易。
避免用上下文钻取道具
除了useState 和useReducer ,你可能会感到的下一个痛点是道具钻取。这是当你有一个持有一些状态的组件,然后一个5层以下的子组件需要访问它,而你必须通过每层手动钻取道具。
这里最简单的解决方案是Context API。它是内置于React的:
// Step 1: create a context. do this outside of any components,
// at the top level of a file, and export it.
export const MyDataContext = React.createContext();
// Step 2: In the component that holds the data, import that
// context and use the Provider to pass the data down
function TheComponentWithState() {
const [state, setState] = useState('whatever');
return (
<MyDataContext.Provider value={state}>
component's content goes here
<ComponentThatNeedsData/>
</MyDataContext.Provider>
)
}
// Step 3: Anywhere in the subtree under the Provider, pull out
// the `value` you passed in by using useContext
function ComponentThatNeedsData() {
const data = useContext(MyDataContext);
// use it
}
尽管它很简单,但Context有一个重要的缺点,那就是性能,除非你非常小心地使用它。
原因是,每一个调用useContext 的组件都会在Provider的value prop发生变化时重新渲染。到目前为止似乎还不错,对吗?当数据变化时,组件会重新渲染?听起来很好!但现在设想一下,如果数据发生变化,会发生什么?
但是现在设想一下,如果这个值是一个包含50个不同的状态位的对象,在整个应用程序中都被使用,会发生什么?而且它们经常变化,而且是独立变化。每当这些值中的一个发生变化,每个使用它们的组件都会重新渲染。
为了避免这种陷阱,在每个Context中存储小块的相关数据,并在多个Context中分割数据(你可以有你想要的数量)。或者,考虑使用一个第三方库。
另一个需要避免的性能问题是每次都将一个全新的对象传入提供者的value 。这看起来无伤大雅,但很容易被忽略。这里有一个例子:
function TheComponentWithState() {
const [state, setState] = useState('whatever');
return (
<MyDataContext.Provider value={{
state,
setState
}}>
component's content goes here
<ComponentThatNeedsData/>
</MyDataContext.Provider>
)
}
这里我们传递一个包含state 和它的设置器setState 的对象。这两个值是好的。setState 永远不会改变,而state 只有在你告诉它的时候才会改变。问题是包裹着它们的对象,每次渲染TheComponentWithState ,它都会被重新创建。
你可能会注意到,我们在这里谈论的东西并不是真正的状态管理,而只是在传递变量。这就是Context的主要目的。状态本身被保存在其他地方,而Context只是把它传递出去。我建议阅读这篇关于Context与Redux有何不同的文章,以了解更多细节。
另外,看看下面链接的参考资料,了解更多关于如何用useCallback 来解决 "新鲜对象 "的问题。
第三方状态管理库
让我们来看看需要了解的最广泛使用的重要状态管理工具。我已经提供了链接来了解每个人的情况。
Redux
在这里提到的所有库中,Redux存在的时间最长。它遵循函数式(如函数式编程)风格,严重依赖不可更改性。
你将创建一个单一的全局存储来保存应用程序的所有状态。一个reducer函数将接收你从你的组件派发的 动作,并通过返回一个新的状态副本来响应。
因为变化只通过动作发生,所以有可能保存和重放这些动作并达到相同的状态。你也可以利用这一点来调试生产中的错误,像LogRocket这样的服务通过在服务器上记录动作来使之变得简单。
优点
- 自2015年以来进行了战斗测试
- 官方Redux工具包库减少了模板代码。
- 优秀的开发工具使调试工作变得简单
- 时间旅行调试
- 软件包体积小(redux + react-redux约为3kb)
- 功能性风格意味着很少有隐藏在幕后的东西
- 有自己的生态系统库,可以做一些事情,如同步到localStorage,管理API请求,以及更多的事情。
缺点
- 心理模型需要一些时间来理解,尤其是当你不熟悉函数式编程的时候
- 对不可变性的严重依赖会使编写还原器变得很麻烦(这可以通过添加Immer库或使用包含Immer的Redux工具包来缓解)。
- 要求你对所有的事情都要明确(这可能是一个优点或缺点,取决于你喜欢什么)。
MobX
MobX可能是Redux内置Context API之外最流行的替代方案。Redux是关于显式和功能的,而MobX则采取相反的方法。
MobX是基于观察者/可观察模式的。你将创建一个可观察的数据模型,将你的组件标记为该数据的 "观察者",MobX将自动跟踪它们所访问的数据,并在数据变化时重新渲染它们。
它让你自由地定义你认为合适的数据模型,并给你提供工具来观察该模型的变化并对这些变化做出反应。
MobX在幕后使用ES6代理来检测变化,因此更新可观察的数据就像使用普通的= 赋值操作一样简单。
优点
- 以真正的 "反应式 "方式管理状态,因此,当你修改一个值时,任何使用该值的组件都会自动重新渲染。
- 没有动作或还原器的连接,只需修改你的状态,应用程序就会反映出来。
- 神奇的反应性意味着要写的代码更少。
- 你可以写普通的可变代码。不需要特殊的setter函数或不可变性。
缺点
- 没有像Redux那样广泛使用,所以社区支持较少(教程等),但在用户中很受欢迎
- 神奇的反应性意味着更少的明确代码。(这可能是一个优点,也可能是一个缺点,取决于你对自动更新 "魔法 "的看法)。
- 对ES6代理的要求意味着不支持IE11及以下版本。(如果支持IE是您的应用程序的要求,旧版本的MobX可以在没有代理的情况下工作)。
MobX状态树
MobX状态树(或MST)是MobX上面的一层,它给你一个反应式状态树。你将使用MST的类型系统创建一个类型化的模型。该模型可以有视图(计算的属性)和动作(设置函数)。所有的修改都要经过动作,所以MST可以跟踪正在发生的事情。
下面是一个模型的例子:
const TodoStore = types
.model('TodoStore', {
loaded: types.boolean,
todos: types.array(Todo),
selectedTodo: types.reference(Todo),
})
.views((self) => {
return {
get completedTodos() {
return self.todos.filter((t) => t.done);
},
findTodosByUser(user) {
return self.todos.filter((t) => t.assignee === user);
},
};
})
.actions((self) => {
return {
addTodo(title) {
self.todos.push({
id: Math.random(),
title,
});
},
};
});
模型是可观察的,这意味着如果一个组件被标记为MobX观察者,它将在模型变化时自动重新渲染。你可以把MST和MobX结合起来,不用太多的代码就可以写出反应式组件。
MST的一个好的用例是存储领域模型数据。它可以表示对象之间的关系(例如,TodoList有许多Todos,TodoList属于一个用户),并在运行时强制执行这些关系。
更改是以补丁流的形式创建的,你可以保存和重新加载整个状态树的快照或其中的部分。几个用例:在页面重载之间将状态持久化到localStorage,或将状态同步到服务器上。
优点
- 类型系统保证你的数据处于一致的状态
- 自动跟踪依赖关系意味着MST可以聪明地只重新渲染需要的组件。
- 更改是以细化补丁流的形式创建的
- 可以简单地对整个状态或其中一部分进行可序列化的JSON快照。
缺点
- 你需要学习MST的类型系统
- 魔术与明确性的权衡
- 补丁、快照和动作的一些性能开销。如果你的数据变化非常快,MST可能不是最合适的。
了解更多
- Github上的mobx-state-tree
- 官方入门教程
- 创造者在egghead上提供的免费MobX状态树课程
Recoil
Recoil是这个列表中最新的库,由Facebook创建。它可以让你把你的数据组织成一个图形结构。它与MobX State Tree有点类似,但没有预先定义类型化的模型。它的API就像React的useState和Context API的组合,所以它感觉和React非常相似。
为了使用它,你把你的组件树包裹在一个RecoilRoot (类似于你自己的Context Provider)。然后在顶层创建状态的 "原子",每个原子都有一个唯一的键:
const currentLanguage = atom({
key: 'currentLanguage',
default: 'en',
});
然后,组件可以通过useRecoilState 钩子访问这些状态,其工作原理与useState 非常相似:
function LanguageSelector() {
const [language, setLanguage] = useRecoilState(currentLanguage);
return (
<div>Languauge is {language}</div>
<button onClick={() => setLanguage('es')}>
Switch to Español
</button>
)
}
还有一个 "选择器 "的概念,可以让你创建一个原子的视图:把派生状态想象成 "过滤后的TODO列表",只保留已完成的。
通过跟踪对useRecoilState 的调用,Recoil 可以跟踪哪些组件使用哪些原子。这样,当数据发生变化时,它可以只重新渲染那些 "订阅 "了某项数据的组件,所以这种方法在性能上应该有很好的扩展。
优点
- 简单的API,与React非常相似
- 它被Facebook用于他们的一些内部工具中
- 为性能而设计
- 无论是否有React Suspense(截至本文撰写时仍是实验性的),都可以使用。
缺点
- 该库只有几个月的历史,所以社区资源和最佳实践还没有其他库那么强大。
反应查询 React-Query
React-Query与列表中的其他库不同,因为它是一个数据获取库,而不是一个状态管理库。
我把它放在这里,是因为通常情况下,应用程序中很大一部分的状态管理都是围绕着加载数据、缓存、显示/清除错误、在适当的时候清除缓存(或者在没有清除的时候遇到bug)等等,而react-query很好地解决了这一切。
优点
- 将数据保留在每个组件都能访问的缓存中
- 可以自动重新获取(stale-while-revalidate, Window Refocus, Polling/Realtime)。
- 支持获取分页数据
- 支持 "加载更多 "和无限滚动的数据,包括滚动位置恢复
- 你可以使用任何HTTP库(fetch、axios等)或后端(REST、GraphQL)。
- 支持React Suspense,但并不要求它。
- 并行+依赖性查询
- 突变 + 反应式重取("在我更新这个项目后,重新取回整个列表")。
- 支持取消请求
- 用它自己的React Query Devtools进行良好的调试
- 捆绑包体积小(6.5k minified + gzipped)。
缺点
- 如果你的需求很简单的话,可能会显得有些多余
XState
最后一个也不是真正意义上的状态管理库,但它非常有用!XState在JavaScript中实现了状态机和状态图。
XState在JavaScript中实现了状态机和状态图(以及React,但它也可以用于任何框架)。状态机是一个 "众所周知 "的想法(从学术文献的角度来看),已经存在了几十年,它们在解决棘手的有状态问题方面做得非常好。
当很难推理出一个系统可能采取的所有不同组合和状态时,状态机是一个很好的解决方案。
举个例子,想象一下一个复杂的自定义输入,比如Stripe的那些花哨的信用卡号码输入--那些知道什么时候在数字之间插入空格以及光标放在哪里的输入。
现在想想:当用户点击右键时,你应该怎么做?嗯,这取决于光标的位置。这取决于盒子里有什么文本(光标是否在我们需要跳过的空格附近?)也许他们按的是Shift键,而你需要调整选定的区域......有很多变量在起作用。你可以看到这将变得多么复杂。
用手来管理这种事情是很棘手的,而且容易出错,所以用状态机你可以列出系统可能处于的所有状态,以及它们之间的转换。XState将帮助你做到这一点。
优点
- 简单的基于对象的API来表示状态和它们的转换
- 可以处理复杂的情况,如平行状态
- XState Visualizer对于调试和浏览状态机是非常好的。
- 状态机可以极大地简化复杂的问题
缺点
- "用状态机思考 "需要一定的适应性
- 状态机的描述对象可能会变得非常冗长(但是,想象一下用手写的方式)。
"那X呢?"
还有很多我没能在这里介绍的库,比如Zustand、easy-peasy和其他的。看看这些吧,它们也很不错 :)