状态管理方案发展概览
自前端进入现代化时期以来,状态管理就是个经久不衰的话题。这篇文章就以react这个经典的前端框架为切入点从头理一理状态管理方案的发展历程。
为什么需要状态管理
现代化的前端框架都强调一件事:开发者只需要集中精力在状态数据到视图的映射处理,其中的渲染细节由框架接管。但状态数据本身其实就是一种很杂的数据源,且特征随着来源的不同而不同。比如
- 来自服务端的应用数据
- 页面间需要共享的局部前端数据
- 用户信息等全局数据
- 等等
虽然react本身提供了一些管理状态的方案,但都不够完善,各有各的问题。
- state提升:嵌套组件传参繁琐,路径组件的无意义重渲染。
- context: 只能存储单一值,无法存储多个各自拥有消费者的值的集合,且Provider也容易嵌套。
react迭代至现在的18版本,虽然架构上出现了很多变化,但上述问题依然存在,所以不断地有三方状态管理方案产生,适应react本身的架构变化的同时,来解决状态数据管理的问题。
Flux
严格来说,Flux并不是一套具体的状态管理方案,而是一种架构思想。它的诞生非常重要,主要在于在当时视图状态无规则更新的年代,提出了一个简单的约束规则:单向数据流。以此来保证视图层对状态的操作和渲染是可预测的。
简单介绍就是在Flux架构中,状态的流转需要以Action为媒介经过3个模块:
- View:视图层。在这里交互触发一个Action。
- Dispatcher: 分发层。收到触发的Action,找到并通知对应Store进行状态数据更新。
- Store: 对应Store收到更新请求,更新数据后将最新数据流转到View。
以上就是一次可预测的单向数据流转。以Flux为起始,后续的状态管理方案或多或少都借鉴了思想。
Redux
redux就是基于Flux思想实现的一套最有名的方案。在Flux基础上进一步提出了3大原则:
- 单一数据源store
- state只读
- 纯函数修改state
并提供了一套具体的flux架构实践方案。虽然redux的这套方案在当时非常流行,但回过头看其实并不是一套很通用的方案,当想用其解决通用项目的状态管理问题时,就会遇到非常多的问题。包括但不限于:
- 繁琐的模板代码
- 全触发再拦截的订阅更新效率较低
- 基于字符串实现的action调用无法开发时跟踪定义代码,开发体验较差
前文也说了,项目中的状态来源是很杂的,也各有特征,当想用统一的全局单一store去管理时,实际上是一种偷懒。
mobx
和redux同期风靡的另一套方案就是mobx了。严格来说mobx只是一套引擎实现,对标的状态管理方案是Mobx State Tree。其核心在于实现了一套和Vue相同的数据响应系统强制结合react视图更新。在当时的class时代用着问题确实不大,但不可否认的是这套基于mutable的响应式更新实现和react的immutable理念是相违背的,这个问题在hooks时代react大改为并发架构之后显得更加明显,很难和hooks的新特性结合,反而会带来一系列问题。
就我看来,这套状态依赖收集并触发视图精确更新的理念很好,但需要和视图层的渲染做深度结合才算是比较完美,而这点vue就做的很好。如果喜欢这套方案,为什么不出门左转vue呢。
新时代的状态管理
从上文也可以看到,我对redux和mobx的评价并不高,但这也是基于时代特征的,就像jquery一样,一代工具解决一代问题。react的架构变化,带来了hooks,随之而来的也是基于此的一系列新生代状态管理方案。从这一步开始,我们终于能够得到贴合react渲染体系的简洁好用的状态管理方案了。
Recoil
这个状态管理库应该是hooks时代第一个进入大众视野的框架,因为开发团队背靠facehook,所以也受到了不少的关注。
相比于class时代的单一store分发到各个组件的自顶向下的数据组织方式,recoil反其道而行之,Recoil采用了自底向上的原子化状态数据组织形式。同时由于react的hooks的出现,新时代状态管理方案包括Recoil更倾向于结合其特性,而不是独立于react外部靠连接工具结合。
Recoil核心设计在于定义了一个有向图 (directed graph),正交同时又天然连结于你的 React 树上。状态的变化从该图的顶点(我们称之为 atom)开始,流经纯函数 (我们称之为 selector) 再传入各个组件。
状态的定义从最底层的atom开始。
const fontSizeState = atom({
key: 'fontSizeState',
default: 14,
});
使用方式很简单,和hooks的语法一致。
function FontButton() {
const [fontSize, setFontSize] = useRecoilState(fontSizeState);
return (
<button onClick={() => setFontSize((size) => size + 1)} style={{fontSize}}>
Click to Enlarge
</button>
);
}
自底向下的组织状态生成由selector完成。selector代表一个派生状态,类似于Vue的计算属性,由纯函数生成。这使得我们避免了冗余atom,通常无需使用 reduce 来保持状态同步性和有效性。所有状态数据根据最小粒度的状态进行有效计算。
const fontSizeLabelState = selector({
key: 'fontSizeLabelState',
get: ({get}) => {
const fontSize = get(fontSizeState);
const unit = 'px';
return `${fontSize}${unit}`;
},
});
综上可知,Recoil的核心思想就在于
- 利用hooks的内部特性操作状态流转
- 自底向下的组织状态
- 保持尽可能简洁并且和react一致的API,降低心智负担和样板代码的存在
以上也是hooks时代状态管理方案的一个大方向。
zustand
当然如果你更喜欢类flux的自顶向下的状态流转,依然有合适的选择。zustand和redux很像,不过相比redux更加适合hooks的写法,也没有繁琐的样板代码。同时也不强制单一store,相对回归原始flux的理念。
import create from 'zustand'
const useBearStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}))
function BearCounter() {
const bears = useBearStore((state) => state.bears)
return <h1>{bears} around here ...</h1>
}
function Controls() {
const increasePopulation = useBearStore((state) => state.increasePopulation)
return <button onClick={increasePopulation}>one up</button>
}
jotai
jotai和Recoil类似,是一个受Recoil启发的自底向上的原子化模型状态管理方案。Recoil中的atom
通过hooks和selector纯函数来组合、创建、更新。只有使用到该atom
的组件才会在atom
更新时触发re-render。因此在原子式中,无需定义模版代码和大幅改动组件设计,直接沿用类似于useState
的API就能实现高性能的状态共享和代码分割。
虽然Recoil宣称的高性能原子式状态管理非常诱人,但不能忽视的是Reocil本身设计相当复杂,为了适用于更复杂的大型场景,Recoil拥有高达数十个APIs,上手成本不低。而且为了规避Context API的问题,Recoil使用了useRef
API来存放状态并在内部管理状态订阅和更新,严格意义上状态也并不算在React Tree中,同样面临着外部状态的Concurrent Mode兼容性问题。
jotai就是在Recoil的基础上解决以上问题的一个新方案。相比于Recoil,jotai有以下两个特点:
- 更原生:jotai的基础接口更接近于原生语法,更简洁。
- 更灵活:派生原子状态能和其他原子状态结合,并且以atom对象本身为唯一标识,不用声明额外的字符串。
声明atom很简单。
import { atom } from 'jotai'
const priceAtom = atom(10)
const messageAtom = atom('hello')
const productAtom = atom({ id: 12, name: 'good stuff' })
声明派生atom也不需要引入额外的概念。
const readOnlyAtom = atom((get) => get(priceAtom) * 2)
const writeOnlyAtom = atom(
null, // it's a convention to pass `null` for the first argument
(get, set, update) => {
// `update` is any single value we receive for updating this atom
set(priceAtom, get(priceAtom) - update.discount)
}
)
const readWriteAtom = atom(
(get) => get(priceAtom) * 2,
(get, set, newPrice) => {
set(priceAtom, newPrice / 2)
// you can set as many atoms as you want at the same time
}
)
消费atom时也不用区分原生atom和派生atom,统一使用useAtom即可。
const [value, updateValue] = useAtom(anAtom)
对比和分类
最后对hooks时代的3种方案进行一个简单的对比和分类吧。
jotai vs zustand
- 名称:日文和德文
- 类比:recoil和redux
- 状态保存的地点:react组件树内部 react外部
- 结构化数据的方式:自底向上(atoms) 自顶向下(object)
- 技术区别:主要区别在于状态模型。zustand是单一状态。jotai是可以合成的原子状态。
jotai vs Recoil
- 开发团队背景:jotai是少量成员组成的开发团队。recoil是背靠facebook。
- 语法基础:jotai聚焦于原始简单的API。recoil有更复杂的面向不同场景的API。
- 技术区别:jotai依赖于atom对象作为唯一标识。recoil依赖字符串。
分类
中心化:Redux,Zustand
原子化:Recoil, jotai
store存放在外部:redux
store存放在内部:Recoil, jotai
小结
目前我比较倾向于将项目中的范围局部状态交给jotai,全局状态交给zustand,IO状态由更专业的工具(useSWR,react-query)处理。当然这也不是唯一方案,只是我自己的习惯。redux依然是项目中使用最广泛的方案,有人喜欢也完全没问题。这篇文章就是提供更多的选择项。
-- EOF --