大家好,我是柒八九。
前面,我们针对-前端框架-React
系列,讲了很多东西。
分别从不同的角度,来介绍React
中比较重要的概念和容易让人产生混淆的知识点。
而从根本上讲,「React 是一个用于构建用户界面的 JavaScript
库」。
❝它的「核心」是「跟踪组件状态的变化」并将更新的状态投射到屏幕上。 ❞
而如果要想成为一个真正的功能完善的前端应用,需要借助一些工具库(Redux/Mobx
)来管理应用的数据状态。当然,只使用React
中提供的数据管理API(context/reducer/state/props
)也能构建一个比较简单的应用。但是如果你的前端应用功能和数据过于复杂。这些API就会显得「捉襟见肘」。
今天,我们就来谈谈,React
中状态管理的群魔乱舞。
❝
❞
随着React
应用程序的规模和复杂性的增加,处理「全局状态管理」将是一个挑战。一般的建议是,只有在你需要的时候才去找全局状态管理解决方案。
React
本身并没有为如何解决全局状态管理提供任何强有力的指导方针。因此,随着时间的推移,React
生态系统收集了许多方法和库来解决这个问题。
如何从中挑选这些库,变的让人捉摸不透。正如我们看到的,在早期,无论何种的React
应用都「无脑」的投入到Redux
的生态中。
随着,社区的完善和进步,大家逐渐发现Redux
并不是解决React
状态管理的「银弹」。所以,各种不同的库和方法,如雨后春笋般出现。与此同时,提出了很多「设计思路」和「心智模式」。这就在选择状态管理库的时候,让人很抓狂。
而接下来,我们来分析一下React
中状态管理的新贵
等库中所涉及的设计理念和心智模式。
❝
❞
「这是状态管理库的最基本功能」。
它允许开发者将他们的状态「持久化在内存中」,并避免在大型的项目中,通过props
将顶层数据,一层一层向下传递的问题。在早期开发React
应用时,我们总是通过Redux
来解决此类问题。
在实践中,当涉及到实际「状态存储」时,有两种主要方法。
❝第一种是「由
React
自身维护」。这通常意味着利用React
提供的API
,如useState
、useRef
或useReducer
,结合React
上下文来传播一个共享值。 「但是」,这种情况,在遇到「大量数据」的传递时候,性能优化是一个不小的挑战。 ❞❝第二种方式是「将数据存储在
React
外部」,然后以「单例」的形式存储。并且通过「发布-订阅」的模式来使得React
组件树中的某个节点能够及时准确的获取到最新的值。从而避免因为一个值的变更,使得整个组件树重新发生渲染。 「然而」,因为它是内存中的一个「单一值」,你不能为「不同的子树」提供不同的数据状态。 ❞
一个库应该提供一个直观的API
来读取和写入存储的数据。
一个直观的API
应该是符合人们现有心智模式的。很多时候,心智模式的冲突会导致使用该库的学习和应用曲线陡增。在React
中,一个常见的心智模式的冲突是状态的「可变与不可变」。
React
中的「组件看作是一个使用state
和props
来计算UI表现的函数」,而这个函数是依靠「数据引用相等」和「不可变的更新操作」来判断是否触发重新渲染。但是,JS是「动态弱类型」语言,在运行阶段,不同的数据类型是可以随意切换的。
Redux
遵循这种模式,要求「所有的状态更新都以不可变的方式进行」。像这样的选择是有取舍的。在这种情况下,一个弊端就是你必须写大量的模板,以满足那些早已习惯数据可随时变更的人进行数据更新。
这就是为什么像Immer[5]这样的库很受欢迎,它允许开发者编写可变风格的代码。
在一些「后-redux」的全局状态管理解决方案中还有其他一些库,如Valtio[6],也允许开发者使用可变风格的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')
);
hook
解决了传统类组件的很多问题。但这样做的代价是出现使用「闭包」时出现了一系列新的问题。
一个常见的问题是「闭包内的数据在当前的渲染周期内不再是 "新鲜 "的」。导致渲染到屏幕上的数据不是最新的值。
这指的是 Redux
的一个老问题,在这个问题上,如果子组件先被挂载,并在父组件之前和Redux
建立关联,那么如果在父组件被挂载之前更新状态,就会造成不一致的情况。
正如我们所看到的,有很多问题和边缘情况是全局状态管理库需要考虑到的。
为了更好地理解React
状态管理的所有现代方法。我们可以回顾一下过去,正所谓「以史为镜,可以知兴替」,看看过去的痛点是如何导致影响现在状态管理库的设计理念和心智模式。
从一开始,React
最初发布时的口号就是「MVC」中的 「V」。它没有关于如何结构化或管理状态的意见。这意味着开发人员在处理开发前端应用程序中最复杂的部分时,只能靠自己。
在Facebook
内部使用了一种叫做 Flux
的模式,它适合「单向数据流」和「可预测的更新」,与React
的数据处理模式一脉相承。
Redux
是 Flux
模式的「最早实现之一」,得到了广泛的采用。
它提倡使用「单一存储」,部分灵感来自「Elm架构」,而不是其他Flux
实现中常见的「多点存储」。
除了「数据的单一存储」。它还有一些辅助功能,方便在开发中调试,比如容易实现撤销/重做功能和时间旅行调试。
总之,「优雅,是在是太优雅了」。 --《间谍过家家》
虽然Redux
仍然是一个伟大的状态管理库,对特定的应用程序有真正的用处。随着时间的推移,Redux
在一些特定的领域,变现不尽人意,导致它不再受到青睐。
对于很多早期的应用,它解决了第一个问题。
❝从组件树中的「任何地方」访问存储的状态,以避免在多个层次上对数据和函数进行「逐层向下传递」。 ❞
对于那些组件层级简单、没有什么交互性的简单应用来说,这往往是「矫枉过正」。
随着时间的推移,我们较小的应用程序发展成为较大的应用程序。我们发现,在实践中,一个前端应用程序有许多「不同类型的状态」。每种类型都有属于各自的子问题。
❝大致可以分为4类
url
状态❞
例如,在「本地UI状态」下,随着事情的发展,「自顶向下」传递数据和更新数据的方法往往会很快成为一个问题。使用「组件封装」与「状态提升」相结合可以解决大部分问题。
对于「远程服务器缓存状态」,有一些常见问题,如请求去重、重试、轮询、处理突变等。
随着应用程序的发展,Redux
倾向于「吸纳所有的状态」,不管它是什么类型,因为它提倡单一的存储。
这通常会「导致将所有的东西存储在一个大的单体存储中」。将UI和远程实体状态之间的所有东西都放在一个地方管理,这变得非常难以管理。对性能造成了不小的压力。
此时,对应用如何「高效的解耦」就变成了一个项目中需要解决的问题了。
随着我们遇到更多这样的痛点,在启动一个新项目时默认使用 Redux
的做法变得不受欢迎。
在现实中,很多Web应用都是CRUD(create
, read
, update
和 delete
)风格的应用,主要目的是「将前端与远程状态数据同步」。
换句话说,值得花时间解决的主要问题是「远程服务器缓存」的一系列问题。这些问题包括如何获取、缓存和与服务器状态同步。
随着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 | 「半自动」--订阅组件卸载时收集的垃圾 |
关于什么是最好的全局状态管理库,没有正确的答案。很多东西都取决于你的具体应用的需求以及谁在构建它。
了解状态管理库需要解决的底层不变的问题可以帮助我们评估今天的库和未来开发的库。
「分享是一种态度」。
参考资料:
[1]
Recoil: https://recoiljs.org/
[2]
Jotai: https://jotai.org/
[3]
Zustand: https://github.com/pmndrs/zustand
[4]
Valtio: https://github.com/pmndrs/valtio
[5]
Immer: https://github.com/immerjs/immer
[6]
Valtio: https://valtio.pmnd.rs/
[7]
Support cross-renderer portals: https://github.com/facebook/react/issues/13332
[8]
the-new-wave-of-react-state-management: https://frontendmastery.com/posts/the-new-wave-of-react-state-management/