zustand到底应该怎么用
从第一次接触zustand到目前为止已经两年了。因为工作上的一些原因导致不能在生产中直接使用,所以在个人的项目中,都尝试使用zustand。zustand因API少,好上手的原因,不需要创建上下文的优点就没有对其文档进行深入了解。最近时间充裕,打算认真阅读文档,提炼出一个方法论,能根据特定的情形,更好的使用zustand。
对比
在此之前,我更加偏向mobx。同样mobx也是易上手,性能强的一款状态管理库,而且基于proxy的原因,修改状态的过程异常舒适,但是没有万能的银弹,mobx模板代码和调试起来并不优雅和hooks的兴起,让mobx不再是第一选择。
当再次选择的时候发现市面上的状态管理库虽还是redux一家独大,但是除redux外也是百花齐放,hooks式的黑马zustand或者是原子式的代表Recoil都使整个市场更加富有生机。
只需要两点就能劝你使用zustand。第一点:纯hooks形式,使用起来更加现代;第二点:对TS支持的非常好,拥有更好的开发体验。并且zustand是一个渐进式的状态管理库,你可以只使用一个API(create)就能完成90%的任务。
相反,使用class创建store的mobx与最新的react可能有点格格不入,当然如果你希望使用到proxy的话,Valtio可能是你想要的。(写这篇文章之前,专门看了一下Valtio的文档,感觉也是一个特别棒的状态管理库,zustand与之相比手动selector的行为简直是心智负担,但是proxy的通病就是debug的时候很折磨)
深入文档
zustand的官方文档很有欺骗性,开始使用时管理小熊一家的案例让人瞬间感觉到zustand的友好。但是一旦涉及到的状态比较多的时候,会发现还是有很多必要的模板代码,而且其实我们一直使用的都是“语法糖”。
zustand的核心可以这样分为三种使用方法
| API | 作用 |
|---|---|
| 依赖react | |
| create | 创建一个store |
| useShallow | 缓存,避免不必要的渲染,也可以作为使用store的第二参数 |
| createWithEqualityFn | 直接创建一个可对比的store,使用时直接解构 |
| 原生,不依赖react | |
| createStore | 非react下创建store |
| shallow | 快速的比较两个值是否一致 |
| react下使用原生方式 | |
| useStore | react下使用原生形式使用store |
| useStoreWithEqualityFn | react下使用原生形式使用store,并且可自定义检查方法 |
我的理解,核心只有一个useStore,加上检查功能useStoreWithEqualityFn。在非react环境下下创建createStore,需要对比shallow。使用react框架则根据这些产生“语法糖”。
所以,我们只需要react框架中的API和hooks即可。
create
import { create } from 'zustand'
const useStore = create((set) => ({
s1: '',
s2: '',
s3: '',
s4: '',
}))
function Compoent() {
const s1 = useStore((state) => state.s1)
const s2 = useStore((state) => state.s2)
const s3 = useStore((state) => state.s3)
const s4 = useStore((state) => state.s4)
return <div>{s1}</div>
}
为了解决这个问题,官方推出了自动选择器,但是需要自己手工配置
const useStoreBase = create((set) => ({
s1: '',
s2: '',
}))
const useStore = createSelectors(useStoreBase)
function Compoent() {
const s1 = useStore.use.s1()
const s2 = useStore.use.s2()
return <div>{s1}</div>
}
代码就简洁多了。但是,问题是这些状态没有涉及到对象。加入你的状态是下面的样子。
const useStore = create((set) => ({
s1: {
s2:{
s4:2
},
s3:1
}
}))
//这种方法TS类型推断都过不去
function Compoent() {
const s3 = useStore.use.s1.s3() // ✕ 错误写法
const s4 = useStore.use.s1.s2.s4() // ✕ 错误写法
return <div>{s3}</div>
}
function Compoent() {
const s3 = useStore(s=>s.s1.s3) // ✓ 正确写法
const s4 = useStore(s=>s.s1.s2.s4) // ✓ 正确写法
return <div>{s3}</div>
}
这种情况并不常见,常见的是这样的
const useStoreBase = create((set) => ({
obj: {
a:'',
b:'',
c:''
}
}))
const useStore = createSelectors(useStoreBase)
function Compoent1() {
const {a,c} = useStore(s=>({a:s.obj.a,c:s.obj.c}))
return <div>{a}{c}</div>
}
function Compoent2() {
const obj = useStore(s=>s.obj.b)
return <div>{obj.b}</div>
}
这种写法出现的问题是,此时两个组件都更新了。如果想要保证Compoent2不渲染则需要useShallow或者还是使用链式的方式。
function Compoent1() {
const {a,c} = useStore(useShallow(s=>({a:s.obj.a,c:s.obj.c}))
return <div>{a}{c}</div>
}
//或者
function Compoent1() {
const a = useStore(s=>s.obj.a)
const c = useStore(s=>c:s.obj.c)
return <div>{a}{c}</div>
}
createWithEqualityFn
如果需要更精细的对比,比如count大于5的时候就不更新了,这种更精细的对比可以使用createWithEqualityFn,或者不希望每次都使用useShallow。也可以使用createWithEqualityFn。
export const useUserStore = createWithEqualityFn (
(set) => ({
user: { name: 'Alice', age: 25 },
setUser: (u) => set({ user: u }),
}),
shallow //全局默认浅比较
)
export default function Profile() {
// 返回对象,却不需要再写 , shallow
const { user, setUser } = useUserStore((s) => ({
user: s.user,
setUser: s.setUser,
}))
return (
<div>
<p>{user.name}</p>
<button onClick={() => setUser({ name: 'Bob', age: 30 })}>改名</button>
</div>
)
}
在这里createWithEqualityFn第二参数是可以传递一个自定义的函数,可以自己定义对比规则。(其实这个对平时使用来说用处不大,很多时候因为老代码迁移的问题会使用到这个,大部分使用shallow就可以,官方主推useShallow)
typescript
zustand对ts支持也及其友好。主要是在存储阶段,需要将状态全都定义了。
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
type useUserSotreType = {
user:{
name:string,
}
setName:()=>void,
queryUser:()=>Promise<void>
}
const useUserSotre = create<useUserSotreType>()(immer((set)=>{
user:{
name:"AAA"
},
setName:()=>set((s)=>s.user.name = 'BBB'),
queryUser:async()=>{
const res = await fetch('api/queryUser').then(r=>r.json)
set((s)=>{
s.user = res
})
}
}))
应用场景
zustand是一个及其灵活的库,然而对于zustand最重要的就是性能,所以对于使用者来说更重要的是应该知道在不同的条件下会产生什么样的反应。
主要场景
场景一:
如果store中的状态大部分都是字符串,数字,布尔值类型的,或者返回的是对象,但是对象都在同一个组件中,不需要精细控制。那么就使用官方推荐的方式。
const name = useUserStore(s=>s.name)
如果还想稍微偷个懒,可以将官网推荐的自动选择器复制粘贴到自己的项目中(官方地址)
const name = useUserStore.use.name()
这些方法都不会导致重新渲染。
场景二:
如果你需要或必须导出一个对象,有希望避免重新渲染的问题,可以使用useShallow的方式。
const {name,age}=useUserStore(useShallow(s=>({
name:s.name,
age:s.age
})))
这样也可以避免重新渲染。
场景三:
虽然shallow的对比方式已经足够了,但如果你认为shallow并不能满足业务需求,可以使用createWithEqualityFn的方式。这里就不做过多的强调了,因为大概率是用不上的。
其他
以上是推荐用法,还有一些奇奇怪怪的用法
一:zustand允许用getState和setState的方式获取和赋值。但是官方不建议这样使用。
const name = useUserStore.getState().name
此时获取的是快照信息,基础类型不影响渲染情况。
useUserStore.setState(s=>({name:'AAA'})
这样也能赋值。
二:订阅
useEffect(() => {
const unsubscribePositionStore = usePositionStore.subscribe(
({ position }) => {
console.log('new position', { position })
},
)
return () => {
unsubscribePositionStore()
}
}, [])
它可以发起一个事件,当每次状态修改的时候,总会先收到,但是要注意的是,卸载的时候需要将其也卸载。
中间件
zustand的中间件还是比较丰富的,我常用的就两个。
immer
immer不需要多聊了,建议所有react使用者都使用这个哭。这里只说使用方法。
这里需要先安装immer
npm install immer
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
const useUserSotre = create(immer((set)=>{
user:{
name:"AAA"
},
setName:()=>set((s)=>s.user.name = 'BBB')
}))
举个例子,对于不可变数据来说,想修改对象中的某一属性值,需要把老的对象获取下来,深拷贝一份,更改新对象中的值,再通过action将新对象赋值进去。②使用了immer后,可以看到,这里可以直接赋值。
persist
如果希望将状态存到location中,等下次用户访问的时候数据不流失,可以使用persist。而且使用方式极其简单。
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
const useUserSotre = create(persist((set)=>{
user:{
name:"AAA"
},
setName:()=>set((s)=>s.user.name = 'BBB')
},
{
name: 'user',
storage: createJSONStorage(() => sessionStorage),
},
))
只需要多写第二参数,第一个是名称,注意要唯一;第二个是存的地方,可以选sessionStorage或者localStorage
如果两个需要同时存在,persist需要在外层。
方法论
//createSelectors.tsx
import { StoreApi, UseBoundStore } from 'zustand'
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never
const createSelectors = <S extends UseBoundStore<StoreApi<object>>>( _store: S) => {
const store = _store as WithSelectors<typeof _store>
store.use = {}
for (const k of Object.keys(store.getState())) {
;(store.use as any)[k] = () => store((s) => s[k as keyof typeof s])
}
return store
}
export default createSelectors
//store.tsx
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import createSelectors from './createSelectors'
type useUserSotreType = {
user:{
name:string,
age:number
}
setName:()=>void,
queryUser:()=>Promise<void>
}
const useUserSotreBase = create<useUserSotreType>()(immer((set)=>{
user:{
name:"AAA",
age:15
},
setName:()=>set((s)=>s.user.name = 'BBB'),
queryUser:async()=>{
const res = await fetch('api/queryUser').then(r=>r.json)
set((s)=>{
s.user = res
})
}
}))
const useUserSotre = createSelectors(useUserSotreBase)
export default useUserSotre
//page.tsx
import useUserSotre from './useUserSotreBase'
import {useShallow} from 'zustand/shallow'
const UserPage = ()=>{
const {name,age} = useUserSotre(useShallow((s)=>{
name:s.user.name,
age:s.user.age
}))
const queryUser = useUserSotre.use.queryUser()
useEffect(()=>{
queryUser()
},[])
return <div>
<p>{name}</p>
<p>{age}</p>
</div>
}
最后
这篇文章断断续续写了三天,之间查阅了很多资料,因为版本的原因,很多原先的写法是错误的,导致这篇文章改了再改,甚至中间我认为相较来说会不会Valtio可能比zustand更适合我。最后我发现,比起一味的追求效率,不如认真思考它的优势到底在哪,原来根本没有最佳的工具,有的只是一味的妥协。比起redux来说,zustand减少了很多模版代码;比起mobx来说,状态是容易追溯的,比起useReducer来说,它支持中间件和异步;比起停止维护的dva来说,它的潜力是无限的。