原文来源于:程序员成长指北;作者:@frontendmastery
译者:@PP 旭
译文:https://zhuanlan.zhihu.com/p/541391922
原文:https://frontendmastery.com/posts/the-new-wave-of-react-state-management/
如有侵权,联系删除
了解状态管理库需要解决的核心问题。以及大量涌现的现代库是如何用新的方式解决这些问题。
随着 React 应用程序的规模和复杂性的不断增长,如何管理可共享的全局状态已经成为一个挑战。通常建议是仅在真正需要时才引入全局状态管理方案。
这篇文章将详细讨论全局状态管理库需要解决的核心问题。
了解这些潜在问题将有助于我们评估这些状态管理「新浪潮」们所做的取舍。对于其他方面,最好从局部开始引入并只在需要时进行扩展。
React 本身并没有为如何解决全局应用状态共享提供任何明确的指导方案。因此,随着时间的推移,React 生态圈已经积累了很多的方法和库来解决这个问题。
因此在评估采用哪个库或模式时,这可能会让人感到困惑。
常见的方法是把它放在外层,并使用目前最主流的工具来处理。这就是我们看到的,早期大家广泛使用 Redux 就是这种情况,其实许多应用并不需要它。
通过理解状态管理库使用上的问题,可以帮助我们更好地理解为什么有这么多不同的库采用了不同的方法。
每个库在解决不同的问题上都做了一些不同的取舍,导致在 API、模式以及思考状态的概念模型上有许多不同。
我们接下来会看一下在 Recoil、Jotai、Zusand、Valtio 这些库中所用到的现代方法和模式,以及其他类似 React tracked 和 React Query 的库。看看他们是如何适应环境发展的。
最后当我们需要选择一个对我们的应用真正有用的库时,我们应该对准确评估这个库实现上的取舍有更充分的准备。
全局状态管理库需要解决的问题
1、「能够从组件树中的任何位置读取存储状态。」 这是状态管理库最基本的功能。
它允许开发人员将状态保存在内存中,并避免大量属性传递的问题。在 React 生态系统的早期,我们经常不合适地使用 Redux 来解决这个痛点。
实际上,当涉及到实际存储状态时,有两种主要方法。
第一个是在 React 运行时内部。这通常是指利用 React 提供的 useState、useRef 或 useReducer 等 API 并结合 React 上下文来传递共享值。这里最大的挑战是如何正确的优化重复渲染问题。
第二个是 React 知识体系之外的问题,叫做模块状态。模块状态允许以类似单例的形式存储状态。这样优化重复渲染问题会比较容易,只需要在状态变更时选择性的处理相关的订阅。但因为它是内存中的单个值,所以不同的子树不能有不同的状态。
2、「能够写入存储状态。」 一个库应该提供一个直观的 API 来读写存储中的数据。
一个直观的 API 通常是符合现有心智模型的 API。因此这可能有点主观,具体取决于库的使用者是谁。
通常,心智模型中的冲突会导致使用上的阻力或者增加学习成本。在 React 中常见的心智模型冲突就是可变状态与不可变状态。
React 中将 UI 作为状态函数的模型适用于引用相等以及通过不可变更新来检测何时发生变化以便正确进行重新渲染的概念。但是 Javascript 本身是一种可变语言。
在使用 React 时,我们必须牢记引用相等之类的事情。这对于不习惯函数式概念的 Javascript 开发人员来说,可能是一个混乱的根源,也增加了学习 React 的成本。
Redux 遵循此模型,并要求所有状态更新都以不可变的方式完成。做这样的选择需要权衡取舍。在这种情况下,一个常见的缺点是对那些习惯于可变方式更新的人来说必须要编写大量样板代码来进行更新。
这就是为什么像 Immer 这样的库很受欢迎的原因,它允许开发人员编写可变形式的代码(即使在底层更新还是不可变的)。
在新一波「post-redux」全局状态管理方案中还有一些库,例如 Valtio,允许开发人员使用可变形式的 API。
3、「提供优化渲染的机制。」 将 UI 作为状态函数的模型应该既简单又高效。
然而,当大规模的状态发生变化时的协调过程是极其复杂的。这通常导致大型应用的运行时性能问题。
使用此模型,全局状态管理库需要检测当状态更新时何时进行重新渲染,并且仅重新渲染必要的内容。
优化这个过程就是状态管理库需要解决的最大挑战之一。
通常采取两种主要方法。首先是允许开发人员手动优化这个过程。
手动优化的一个例子是通过选择器函数订阅一小块存储状态。通过该选择器读取状态的组件只会在特定状态更新时重新渲染。
第二个方法是自动为开发人员处理这个问题,这样就不必考虑手动优化的问题。
Valtio 就是一个示例库,它在后台使用 Proxy 来自动跟踪状态变更并自动管理组件何时重新渲染。
4、「提供优化内存占用的机制。」 对于大型前端应用,大量不合理地管理内存可能会带来问题。
特别是当用户用低配置的设备访问这些大型应用。
挂到 React 生命周期上的状态意味着在组件卸载时更容易利用自动垃圾回收机制。
对于像 Redux 这样提倡单一全局状态的库,你需要自己管理它。因为它会持续保留对数据的引用,不会自动进行垃圾回收。
同样,使用状态管理库将状态存储在 React 运行时之外意味着它不依赖于任何特定组件,可能需要手动管理。
「更多需要解决的问题:」 除了上面这些基础问题,在与 React 集成时还有一些常见问题需要考虑:
1、「与并发模式的兼容性。」 并发模式 允许 React 在渲染过程中「暂停」和切换优先级。以前这个过程是完全同步的。
将并发引入任何地方通常都会带来一些边缘场景。对于状态管理库,如果两个组件从一个外部存储中读值,在渲染过程中这个值发生了变化,那么两个组件可能会读到不同的值。
这被称为「撕裂」。这个问题导致 React 团队为库创建者开发了 useSyncExternalStore 来解决这个问题。
2、「数据序列化。」 拥有完全可序列化的状态是很有用的,这样你就可以从某个存储中保存和恢复应用状态。一些库会为你处理这个问题,而其他库可能需要使用者做一些额外工作才能使用此能力。
3、「上下文丢失问题。」 对于 将多个 react 渲染混合在一起 的应用程序来说,这是一个问题。例如,你可能有一个同事使用了 react-dom 和 类似 react-three-fiber 的库的应用。React 无法协调两个独立的上下文。
4、「过期的属性问题。」 Hooks 解决了很多传统类组件的问题。对此的取舍是要接受闭包带来的一系列新问题。
一个常见问题是闭包里的数据在当前渲染周期中不再是「新鲜的」。这导致渲染到屏幕上的数据不是最新值。当碰到使用了依赖这些属性来计算状态的选择器函数时就会产生问题。
5、「僵尸子组件问题。」 这是 Redux 的一个老问题,如果子组件首先挂载并在父组件之前连接到存储,同时在父组件挂载之前发生状态变更,就会导致数据不一致。
状态管理生态系统简史
正如我们所见,全局状态管理库需要考虑很多问题和边缘场景。
为了更好地理解 React 状态管理的现代方法。我们可以回忆一下历史,看看过去什么痛点形成了我们今天称之为「最佳实践」的方法。
通常,这些最佳实践是通在反复试验和试错发现的。并且发现某些解决方案最终无法很好地适用。
从一开始,React 最初发布时的原始标语就是定位 MVC 模型 中的「视图」。
它没有包含如何构建或管理状态的观点。这意味着在处理前端应用中最复杂的部分时,开发人员只能靠自己。
在 Facebook 内部,使用了一种称为「Flux」的模式,它有助于单向数据流和可预测的更新,这与 React 的「总是重新渲染」的模型相一致。
这种模式非常符合 React 的心智模型,并且在 React 生态系统的早期就流行起来。
Redux 的原始崛起
Redux 是被广泛采用的 Flux 模型的首批实现之一。
它提倡使用单一存储,部分灵感来自 Elm 架构,而不是其他 Flux 实现中常见的多存储。
在启动一个新项目时,你不会因为选择 Redux 作为状态管理库而被解雇。它还具有很酷的演示能力,例如很方便的实现撤消 / 重做功能和时间旅行调试能力。
整个模型至今都是简单而优雅的。尤其是与 React 上一代的 MVC 风格框架例如 Backbone(大规模系统)相比。
虽然 Redux 对特定应用场景来说仍然是一个很棒的状态管理库。但是随着时间的推移,以及整个社区的成长,Redux 遇到了一些常见的问题,导致它不再受欢迎:
1、小型应用中的问题
对于早期的很多应用,它解决了第一个问题。从树中的任何位置访问存储状态,避免了层层传递数据和函数来将数据更新到多个层级的痛苦。
对于获取少量数据并且几乎没什么交互的简单应用来说,这通常太重了。
2、大型应用中的问题
随着时间的推移,很多小型应用逐渐变成了大型应用。正如我们在实践中发现的,前端应用中有许多不同类型的状态。每个都有自己的一系列问题。
比如本地 UI 状态、远程服务器缓存状态、url 状态和全局共享状态,以及更多不同类型的状态。
例如,对于本地 UI 状态,随着应用的发展,在数据和更新数据的方法中进行属性传递通常很快就会成为一个问题。为了解决这个问题,结合使用 组件组合模式 和 状态提升 可以帮助你更好的度过这段时期。
对于远程服务器缓存状态,存在一些常见问题,例如请求去重、重试、轮询、处理突变等等。
随着应用的发展,Redux 倾向于吸收所有状态,无论其是什么类型,因为它提倡使用单一存储。
这就会导致将所有东西都存储在一个超大的单一存储中。这往往会引出第二个问题,运行时性能优化。
因为 Redux 通常只处理全局共享状态,所以很多这些子问题都需要反复处理(或者通常无人关注)。
这导致形成一个大型单一存储,在一个地方管理 UI 和远程实体状态之间的所有内容。
随着应用的发展,这当然会变得非常难以管理。特别是在前端开发人员需要快速迭代的团队中。解耦的处理独立的复杂组件变得更加有必要。
不再强调 Redux
随着我们遇到更多这样的痛点,慢慢的,在启动新项目时默认使用 Redux 变得不受欢迎。
实际上,很多 Web 应用都是 CRUD(创建、读取、更新和删除)类型的应用,主要做的就是将前端与远程状态数据同步。
换句话说,值得花时间研究的主要问题是一系列与远程服务器缓存相关的问题。包括如何获取、缓存和同步服务器状态。
它还包括许多其他问题,例如处理竞态、失效和重新获取过期数据、去重、重试、组件重新聚焦时重新获取数据,以及相比 Redux 的样板代码更方便的改变远程数据。
这些用例的样板是没必要且过于复杂的。特别是通常需要绑定使用的中间件例如 redux-saga 和 redux-observable。
就从客户端获取和改变数据的成本而言,这套工具链对于这些类型的应用来说都太重了。并且对这些相对简单的操作来讲也太复杂了。
转向更简单的方法
随着 hooks 和新的上下文 API 的出现。风向从使用像 Redux 这样的重度抽象转向使用新的 hooks API 的原生能力已经有一段时间了。通常是简单的使用 useContext 并结合 useState 或者 useReducer。
对于简单的应用程序,这是一种很好的方法。许多小型应用都可以这么做。但是随着应用的发展,这会带来两个问题:
-
「再造一个 Redux。」 并且很容易陷入我们之前讨论过的许多问题。与一些致力于解决这些特殊边缘情况的库相比,要么没解决问题,要么只是解决了一点点。导致许多人觉得有必要倡导 React 上下文与状态管理无关 的想法。
-
「优化运行时性能。」 另一个核心问题是优化重复渲染。在使用原生上下文时,随着应用的发展,这会很难实现。
值得一提的是一些现代用户侧的库,例如 useContextSelector 旨在帮助解决此问题。同时 React 团队也开始考虑 在未来作为 React 的一部分自动解决这个痛点。
用于解决远程状态管理问题的专用库的兴起
对于大多数 CRUD 类型的 Web 应用,本地状态与专用的远程状态管理库相结合可以帮助你很好的解决问题。
在这趋势中的示例库包括 React query、SWR、Apollo 和 Relay。以及一些「革新」的 Redux 库比如 Redux Toolkit 和 RTK Query。
这些是专门为解决远程数据问题而构建的,这些问题如果单独使用 Redux 来解处理的话通常会很复杂。
虽然这些库对于单页应用来说是很好的抽象。就获取和改变数据所需的 Javascript 而言,它们仍然需要很多的开销。作为一个 Web 构建者社区,Javascript 的实际成本 变得越来越重要。
值得注意的是,像 Remix 这样的新兴元框架已经解决了这个问题。通过提供对服务端优先的数据加载的抽象和声明性突变,它不再需要引入一个专门的库。它把「将 UI 作为状态函数」的概念 扩展到客户端 之外,包括后端远程状态数据。
全局状态管理库和模式的新浪潮
对于大型应用,通常不可避免地需要有与远程服务器状态不同的全局状态共享。
自下而上模式的兴起
我们可以看到之前的状态管理解决方案(如 Redux)在他们的实现上比较「自上而下」。随着时间的推移,它倾向于吸收组件树顶部的所有状态。状态都在树的顶部,下面的组件通过选择器获取它们需要的状态。
在 构建面向未来的前端架构 中,我们看到了自下而上的模式在构建具有组合模式的组件方面的作用。
hooks 既提供也提倡了将可组合部件组合在一起形成更大整体的原则。使用 hooks,标志着巨型单一全局存储的状态管理方法的转变。走向自下而上的「微」状态管理,强调通过 hooks 消费更小的状态片段。
像 Recoil 和 Jotai 这样的流行库用他们的「原子」状态概念来验证了这种自下而上的方法。
原子是很小但完整的状态单位。它们是状态的一小块,可以连接在一起形成新的派生状态。这样最终就会形成一个关系图。
这套模型允许开发者以自下而上的方式逐步构建状态。并可以通过只让关系图中已更新的原子状态无效来优化重复渲染。
这与直接订阅一个巨型的单一状态形成对比,并可以尽量减少不必要的重复渲染。
现代库如何解决状态管理的核心问题
下面是每个「新浪潮」中的库为解决状态管理中的核心问题所采用的不同方法的简单总结。这些是我们在文章开头定义的问题。
能够从子树中的任何位置读取存储状态
能够写入和更新存储状态
运行时重复渲染性能优化
「手动优化」 通常意味着创建订阅特定状态片段的选择器函数。这里的好处是消费者可以对如何订阅和优化订阅该状态的组件如何重新渲染进行细粒度控制。一个缺点是这是一个手动过程,容易出错,并且有人可能会质疑这里需要一些不必要的开销,这不应该是 API 的一部分。
「自动优化」 是让库优化这个过程,该过程仅自动重新渲染必要的内容。这里的优势当然是更加方便,以及开发者能够专注于实现功能而无需关心手动优化的方法。这样做的一个缺点是,对开发者来说优化过程是一个黑盒,没有暴露出口来手动优化某些部分,可能有人会觉得有点魔幻。
内存优化
内存优化往往只是大型应用的问题。这在很大程度上取决于库是在模块级别存储状态还是在 React 运行时中存储状态。这还取决于你如何构建存储状态。
与大型单体存储相比,小型独立存储的好处是,当所有订阅的组件卸载时,它们可以自动进行垃圾回收。而大型单体存储在没有做合适的内存管理的情况下更容易出现内存泄漏。
总结
关于什么是最好的全局状态管理库,目前还没有一个正确答案。这个问题很大程度上取决于你的应用需求以及构建它的人。
但是了解状态管理库需要解决的核心问题可以帮助我们评估现在和未来将出现的库。
深入了解具体实现超出了本文的范围。如果你有兴趣深入研究,我推荐 Daishi Kato 的 React 状态管理书,这是一个非常好的资源,它对本文中提到的一些较新的库和方法进行了非常详细的比较。
参考
-
Garbage Collection in Redux Applications
-
React without memo
-
The zombie child problem
-
useMutableSource -> useSyncExternalStore discussion
-
Proxy compare
-
useContextSelector
-
Data flow in Remix