焦虑的时候,大脑需要一种叫做多巴胺的神经递质来对抗焦虑。而打游戏、刷微博等娱乐,则是让大脑产生多巴胺最快的途径。
大家好,我是柒八九。
前面,我们针对-前端框架-React
系列,讲了很多东西。
分别从不同的角度,来介绍React
中比较重要的概念和容易让人产生混淆的知识点。
而从根本上讲,React 是一个用于构建用户界面的 JavaScript
库。
它的核心是跟踪组件状态的变化并将更新的状态投射到屏幕上。
而如果要想成为一个真正的功能完善的前端应用,需要借助一些工具库(Redux/Mobx
)来管理应用的数据状态。当然,只使用React
中提供的数据管理API(context/reducer/state/props
)也能构建一个比较简单的应用。但是如果你的前端应用功能和数据过于复杂。这些API就会显得捉襟见肘。
今天,我们就来谈谈,React
中状态管理的群魔乱舞。
你能所学到的知识点
- 全局状态管理库需要解决的问题 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
- 状态管理生态系统的发展史 推荐阅读指数 ⭐️⭐️⭐️⭐️
- 解决远程状态管理问题的专用库的崛起 推荐阅读指数 ⭐️⭐️⭐️全局状态管理库和模式的新浪潮 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
- 现代库如何解决状态管理的核心问题 推荐阅读指数 ⭐️⭐️⭐️
随着React
应用程序的规模和复杂性的增加,处理全局状态管理将是一个挑战。一般的建议是,只有在你需要的时候才去找全局状态管理解决方案。
React
本身并没有为如何解决全局状态管理提供任何强有力的指导方针。因此,随着时间的推移,React
生态系统收集了许多方法和库来解决这个问题。
如何从中挑选这些库,变的让人捉摸不透。正如我们看到的,在早期,无论何种的React
应用都无脑的投入到Redux
的生态中。
随着,社区的完善和进步,大家逐渐发现Redux
并不是解决React
状态管理的银弹。所以,各种不同的库和方法,如雨后春笋般出现。与此同时,提出了很多设计思路和心智模式。这就在选择状态管理库的时候,让人很抓狂。
而接下来,我们来分析一下React
中状态管理的新贵
等库中所涉及的设计理念和心智模式。
全局状态管理库需要解决的问题
- 从组件树的任何地方读取存储的状态
- 写入存储状态的能力
- 提供优化渲染的机制
- 提供优化内存使用的机制
- 与并发模式的兼容性
- 数据的持久化
- 上下文丢失问题
- props失效问题
- 孤儿问题
从组件树的任何地方读取存储的状态
这是状态管理库的最基本功能。
它允许开发者将他们的状态持久化在内存中,并避免在大型的项目中,通过props
将顶层数据,一层一层向下传递的问题。在早期开发React
应用时,我们总是通过Redux
来解决此类问题。
在实践中,当涉及到实际状态存储时,有两种主要方法。
第一种是由
React
自身维护。这通常意味着利用React
提供的API
,如useState
、useRef
或useReducer
,结合React
上下文来传播一个共享值。
但是,这种情况,在遇到大量数据的传递时候,性能优化是一个不小的挑战。
第二种方式是将数据存储在
React
外部,然后以单例的形式存储。并且通过发布-订阅的模式来使得React
组件树中的某个节点能够及时准确的获取到最新的值。从而避免因为一个值的变更,使得整个组件树重新发生渲染。
然而,因为它是内存中的一个单一值,你不能为不同的子树提供不同的数据状态。
写入存储状态的能力
一个库应该提供一个直观的API
来读取和写入存储的数据。
一个直观的API
应该是符合人们现有心智模式的。很多时候,心智模式的冲突会导致使用该库的学习和应用曲线陡增。在React
中,一个常见的心智模式的冲突是状态的可变与不可变。
React
中的组件看作是一个使用state
和props
来计算UI表现的函数,而这个函数是依靠数据引用相等和不可变的更新操作来判断是否触发重新渲染。但是,JS是动态弱类型语言,在运行阶段,不同的数据类型是可以随意切换的。
Redux
遵循这种模式,要求所有的状态更新都以不可变的方式进行。像这样的选择是有取舍的。在这种情况下,一个弊端就是你必须写大量的模板,以满足那些早已习惯数据可随时变更的人进行数据更新。
这就是为什么像Immer这样的库很受欢迎,它允许开发者编写可变风格的代码。
在一些后-redux的全局状态管理解决方案中还有其他一些库,如Valtio,也允许开发者使用可变风格的API。
提供优化渲染的机制
然而,随着数据量的增加,当状态发生变化时的调和过程是一件耗时操作。经常导致大型应用的运行时性能不佳。
在这种模式下,全局状态管理库需要在状态被更新时检测出重新渲染的时间,并且只重新渲染必要的内容。
优化这一过程是状态管理库需要解决的最大挑战之一。
通常有两种主要的方法。
第一种是允许开发者手动优化这个过程。
手动优化的一个例子是通过选择器函数订阅一块存储的状态。通过选择器读取状态的组件只有在该特定状态更新时才会重新渲染。
第二种是为开发者自动处理,这样他们就不必考虑手动优化。
Valtio
是另一个例子,它在JS引擎下使用Proxy
来自动跟踪事物的更新,并自动管理一个组件何时应该重新渲染。
提供优化内存使用的机制
对于非常大的前端应用,不正确地内存管理会默默地导致应用数据直线上升。
特别是当用户从低配设备上访问这些大型应用程序时,数据增大,设备无法及时进行数据回收,就导致了应用卡顿等性能问题。
利用React
生命周期来存储状态意味着更容易利用组件卸载时的自动垃圾收集。--> 组件卸载,存储在组件实例中的数据没有被引用,然后在新的一期GC中就会被JS引擎回收,从而有效的减低了应用内存。
对于像Redux
这样提倡单一全局存储模式的库,你需要对其中的存储的数据进行手动回收。因为它将继续持有对你的数据的引用,这样它就不会自动被垃圾收集。
同样,使用一个在React
之外的状态管理库存储数据,意味着它不与任何特定的组件绑定,可能需要手动管理。
其他问题
除了上面的基础问题外,在与React
集成时还有一些其他的常见问题需要考虑。
与并发模式的兼容性
并发模式允许React在渲染过程中 "暂停 "并切换优先级。以前,这个过程是完全同步的。
React
引入并发特性,通常会引入边缘案例。对于状态管理库来说,如果在渲染过程中读取的值发生了变化,那么两个组件就有可能从外部存储中读取不同的值。
这就是所谓的 数据撕裂。这个问题导致React
团队为库创建者(Redux/Mobx
)创建了useSyncExternalStore
hook来解决这个问题。
useSyncExternalStore
这个 hook
并不是给我们在日常项目中用的,它是给第三方类库如 Redux
、Mobx
等内部使用的。
它通过强制的同步状态更新,使得外部 store
可以支持并发读取。它实现了对外部数据源订阅时不在需要 useEffect
,并且推荐用于任何与 React
外部状态集成的库。
数据的持久化
拥有完全可持久化的状态是非常有用的,这样你就可以从某处存储中保存和恢复应用程序的状态。一些库为你处理这个问题,而另一些库可能需要开发者自行对数据进行处理。
上下文丢失问题
这是将多个 react渲染器
混合在一起的应用程序的一个问题。例如,你可能有一个同时利用 react-dom
和 react-three-fiber
库的应用程序。在这种情况下,React
无法调和两个独立的上下文。
例如,存在如下的示例:
import React, { createContext, useContext, useState, useEffect } from 'react' import ReactDOM from 'react-dom' import { Canvas } from 'react-three-fiber' // 定义全局Context const Context = createContext(0) const { Provider, Consumer } = Context const Square = () => { // 使用顶层组件中的数据 const rotation = useContext(Context) return ( <group rotation={[0, 0, -rotation]}> // 这里做动画操作 </group> ) } // 定义一个Provider const TickProvider = ({ children }) => { const [rotation, setRotation] = useState(0) useEffect(() => { // 定期对指定数据进行修改操作 setTimeout(() => { setRotation(r => r + 0.01) }, 100) }, [rotation]) return <Provider value={rotation}>{children}</Provider> } 复制代码
上面基本的Context和组件都定义好了,然后我们需要在react-dom
和react-three-fiber
中传递context
数据,使得功能能够正常运作。
// 👎 上下文不能通过<Canvas>,所以<Square>不能读取旋转 ReactDOM.render( // React-Dom 维护的组件 <TickProvider> // React-Three-Fiber 维护的组件 <Canvas> <Square /> </Canvas> <Consumer>{value => value.toFixed(2)}</Consumer> </TickProvider>, document.getElementById('outside') ); // 👎 上下文都在<Canvas>内,所以不能从外部传递/读取。 ReactDOM.render( <> <Canvas> <TickProvider> <Square /> </TickProvider> </Canvas> 此处,无法获取`rotation`的信息 </>, document.getElementById('inside') ); 复制代码
props失效问题
hook
解决了传统类组件的很多问题。但这样做的代价是出现使用闭包时出现了一系列新的问题。
一个常见的问题是闭包内的数据在当前的渲染周期内不再是 "新鲜 "的。导致渲染到屏幕上的数据不是最新的值。
孤儿问题
这指的是 Redux
的一个老问题,在这个问题上,如果子组件先被挂载,并在父组件之前和Redux
建立关联,那么如果在父组件被挂载之前更新状态,就会造成不一致的情况。
状态管理生态系统的发展史
正如我们所看到的,有很多问题和边缘情况是全局状态管理库需要考虑到的。
为了更好地理解React
状态管理的所有现代方法。我们可以回顾一下过去,正所谓以史为镜,可以知兴替,看看过去的痛点是如何导致影响现在状态管理库的设计理念和心智模式。
从一开始,React
最初发布时的口号就是MVC中的 V。它没有关于如何结构化或管理状态的意见。这意味着开发人员在处理开发前端应用程序中最复杂的部分时,只能靠自己。
在Facebook
内部使用了一种叫做 Flux
的模式,它适合单向数据流和可预测的更新,与React
的数据处理模式一脉相承。
Redux的最初崛起
Redux
是 Flux
模式的最早实现之一,得到了广泛的采用。
它提倡使用单一存储,部分灵感来自Elm架构,而不是其他Flux
实现中常见的多点存储。
除了数据的单一存储。它还有一些辅助功能,方便在开发中调试,比如容易实现撤销/重做功能和时间旅行调试。
总之,优雅,是在是太优雅了。 --《间谍过家家》
虽然Redux
仍然是一个伟大的状态管理库,对特定的应用程序有真正的用处。随着时间的推移,Redux
在一些特定的领域,变现不尽人意,导致它不再受到青睐。
小型应用程序中的问题
对于很多早期的应用,它解决了第一个问题。
从组件树中的任何地方访问存储的状态,以避免在多个层次上对数据和函数进行逐层向下传递。
对于那些组件层级简单、没有什么交互性的简单应用来说,这往往是矫枉过正。
大型应用程序中的问题
随着时间的推移,我们较小的应用程序发展成为较大的应用程序。我们发现,在实践中,一个前端应用程序有许多不同类型的状态。每种类型都有属于各自的子问题。
大致可以分为4类
- 本地UI状态
- 远程服务器缓存状态
url
状态- 全局共享状态
例如,在本地UI状态下,随着事情的发展,自顶向下传递数据和更新数据的方法往往会很快成为一个问题。使用组件封装与状态提升相结合可以解决大部分问题。
对于远程服务器缓存状态,有一些常见问题,如请求去重、重试、轮询、处理突变等。
随着应用程序的发展,Redux
倾向于吸纳所有的状态,不管它是什么类型,因为它提倡单一的存储。
这通常会导致将所有的东西存储在一个大的单体存储中。将UI和远程实体状态之间的所有东西都放在一个地方管理,这变得非常难以管理。对性能造成了不小的压力。
此时,对应用如何高效的解耦就变成了一个项目中需要解决的问题了。
不再强调Redux的作用
随着我们遇到更多这样的痛点,在启动一个新项目时默认使用 Redux
的做法变得不受欢迎。
在现实中,很多Web应用都是CRUD(create
, read
, update
和 delete
)风格的应用,主要目的是将前端与远程状态数据同步。
换句话说,值得花时间解决的主要问题是远程服务器缓存的一系列问题。这些问题包括如何获取、缓存和与服务器状态同步。
偏向React-Hook的实现方式
随着hook
的出现。一时间,开发应用管理状态的方式又从Redux
这样的重度抽象摇身一变为利用新的hook
API的原生上下文。这通常涉及简单的useContext
与useState
或useReducer
的结合。
对于简单的应用程序来说,这是一个很好的方法。很多小的应用程序可以用这种方法来解决。
解决远程状态管理问题的专用库的崛起
对于大多数CRUD
风格的Web应用来说,本地状态结合专门的远程状态管理库能解决所有状态都杂糅在一起的问题。
这个趋势中的一些例子库包括React query
、SWR
、Apollo
和Relay
。
这些都是为了解决远程数据问题领域的问题而建立的,这些问题很多时候仅用Redux
来实现很是棘手。
虽然这些库对单页应用程序来说是很好的抽象。使用它们仍然需要进行额外的JS开销。并且需要时刻关注资源的更新。Javascript的实际成本正变得越来越突出。
全局状态管理库和模式的新浪潮
自下而上模式的崛起
我们可以看到以前的状态管理解决方案,如Redux
,设计理念是状态 自上而下流动。它倾向于在组件树的顶端吸走所有的状态。状态被维护在组件树的高处,下面的组件通过选择器拉取他们需要的状态。
在新的组件构建理念中,一种自下而上的观点对构建具有组合模式的应用具有很好的指导作用。
而hook
就是这种理念的践行者,即把可组合的部件放在一起形成一个更大的整体。
通过
hook
,我们可以从具有巨大全局存储的单体状态管理转变为向自下而上的 微状态管理,通过hook
消费更小的状态片。
像Recoil
和Jotai
这样的流行库以其 原子状态的概念体现了这种自下而上的理念。
原子是一个最小但完整的状态单位。它们是小块的状态,可以连接在一起形成新的衍生状态。最终形成了一个应用状态图。
这个模型允许你自下而上地建立起状态图。并通过仅使图中已更新的原子失效来优化渲染。
这与拥有一个大的单体状态球形成鲜明对比,你可以订阅并试图避免不必要的渲染。
现代库如何解决状态管理的核心问题
下面是每个库为解决状态管理的每个核心问题所采取的不同方法的简化总结。
从子树的任何地方读取存储状态
库 | 更新时机 | API示例 |
React-Redux |
嵌入到React 运行时 |
useSelector(state => state.foo) |
Recoil |
嵌入到React 运行时 |
const todos = atom({ key: 'todos', default: [] }) const todoList = useRecoilValue(todos) |
Jotai |
嵌入到React 运行时 |
const countAtom = atom(0) const [count, setCount] = useAtom(countAtom) |
Valtio |
JS引擎维护 | const state = proxy({ count: 0 }) const snap = useSnapshot(state) state.count++ |
写入和更新存储状态的能力
库 | API更新类型 |
React-Redux |
更新不可变 |
Recoil |
更新不可变 |
Jotai |
更新不可变 |
Zustand |
更新不可变 |
Valtio |
更新可变 |
运行时性能重新渲染的优化
- 手动优化通常意味着创建订阅特定状态的选择器函数(
Selector
)。
这样做的好处是,消费者可以精细地控制如何订阅和优化订阅该状态的组件将如何重新渲染。
缺点是这是一个手动的过程,可能容易出错,而且人们可能会说这需要不必要的开销,不应该成为API的一部分。 - 自动优化是指库对这个过程进行优化,只重新渲染必要的东西,自动地,为你作为一个消费者。
这里的优点当然是易于使用,而且消费者能够专注于开发功能,而不需要担心手动优化。
这样做的缺点是,作为消费者,优化过程是一个黑盒,如果没有手动优化的方式,有些特定场景会让人很抓狂。
库 | 描述 |
React-Redux |
利用特定选择器函数,手动优化 |
Recoil |
通过订阅原子的半手动方式 |
Jotai |
通过订阅原子的半手动方式 |
Zustand |
利用特定选择器函数,手动优化 |
Valtio |
通过Proxy 快照进行自动优化 |
内存优化
内存优化往往只在非常大的应用程序上才会出现问题。
与大型单体存储相比,较小的独立存储的好处是,当所有订阅的组件卸载时,它们可以自动收集垃圾。而大型单体存储如果没有适当的内存管理,则更容易出现内存泄漏。
库 | 描述 |
React-Redux |
手动管理 |
Recoil |
0.3.0版本后- 自动管理 |
Jotai |
自动管理 - atoms 作为键存储在WeakMap 中 |
Zustand |
半自动--API可用来帮助手动取消订阅的组件 |
Valtio |
半自动--订阅组件卸载时收集的垃圾 |
总结
关于什么是最好的全局状态管理库,没有正确的答案。很多东西都取决于你的具体应用的需求以及谁在构建它。
了解状态管理库需要解决的底层不变的问题可以帮助我们评估今天的库和未来开发的库。
后记
分享是一种态度。
参考资料:
- React-Fiber机制1
- React-Fiber机制2
- Recoil
- Jotai
- Zustand
- Valtio
- Support cross-renderer portals
- the-new-wave-of-react-state-management
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。