zustand

zustand是一个小巧、快速、可扩展的状态管理解决方案。它提供了基于hooks的API。

德语中“ZUSTAND”如何发音?多查几次吧,不要念错了。

image-20231231115306421

image-20231231115342241

1、快速上手

像react-hooks项目那样,先使用npm create vite@latest,创建react+ts的项目,然后对项目的一些文件进行处理。

1.1安装依赖

运行如下的装包命令:

 

1.2创建store

创建store的方法是调用zustand提供的create函数。使用zustand创建出来的store是一个hook函数,因此推荐以useXxx的方式为store命名。语法格式如下:

在src下创建store文件夹,创建index.ts文件,然后写上面的代码。

1.3使用store

1、在组件的tsx文件中,导入store对应的hook:

2、在需要使用数据的组件中,调用useStore函数,通过传入的selector获取当前组件需要的数据:

3、通过selector获取到的数据,可以在组件中进行渲染使用:

在components文件夹中创建setup.tsx文件,写入上面的代码,并将组件导入到App.tsx组件中进行展示。

image-20231231123456363

1.4添加类型提示

1、在vite-env.d.ts中添加store的类型声明:

这个文件中声明的类型是全局可用的,所以如果在某个文件中使用这里面的类型,就不需要导入即可使用。

2、在src/store/index.ts中调用create函数时,使用类型声明:

3、改造完成后,在组件中使用selector选择器获取数据时,就有了TS的类型提示信息。

注意:请注意create<T>()后面额外的(),详细原因请查看microsoft/TypeScript#10571

1.5修改bears的数量

1、在vite-env.d.ts中为BearType添加名为incrementBear的属性,它是一个函数,用来让bears的数量自增+1,示例代码如下:

2、在src/store/index.ts中新增incrementBears函数如下:

3、在Son1组件中,调用useStore并配合selector获取到需要的函数,并绑定为按钮的点击事件处理函数:

1.6重置bears的数量

1、在vite-env.d.ts中为BearType添加名为resetBears的属性,它是一个函数,用来把bears的数量重置为0,示例代码如下:

2、在src/store/index.ts中新增reset函数如下:

3、在Son2组件中,调用useStore并配合selector获取到需要的函数,并进行使用:

 

 

1.7根据step使bears数量自减

1、在vite-env.d.ts中为BearType添加名为decrementBearsByStep的属性,它是一个函数,接收一个可选的step步长值,从而让bears的数量自减,示例代码如下:

2、在src/store/index.ts中新增decrementBearsByStep函数如下:

3、在Son3组件中,调用useStore并配合selector获取到需要的函数,并进行使用:

 

1.8异步修改bears的数量

1、在vite-env.d.ts中为BearType添加名为asyncIncrementBears的属性,它是一个函数,用来延迟1秒后让数值自增+1,示例代码如下:

2、在src/store/index.ts中新增asyncIncrementBears函数如下:

3、在Son1组件中添加1秒后bears+1的按钮,并绑定为按钮的点击事件处理函数:

 

1.9添加fishes相关的共享数据

1、修改vite-env.d.ts中的BearType类型,添加fishes相关的数据和方法:

2、修改@/store/index.ts模块,添加fishes相关的数据和方法:

3、在@/components/目录下新建fishes.tsx模块,在模块中创建名为Fishes的组件:

4、在@/App.tsx中,导入并使用Fishes组件:

 

1.10拆分store的两种方式

随着项目规模的扩大,存储在store中的数据会变得越来越难以维护。此时,可以考虑把单一的全局store进行拆分。在zustand中提供了两种拆分store的方式:

1、Multi-Store:把不同的数据和方法,拆分为多个彼此独立的store

2、Single-Store:把不同的数据和方法,拆分为多个slice切片,最终,把多个slice合并成全局唯一的store。

注意:上面两种拆分Store的方式都可以完美运行,在您自己的项目中,可以依据个人喜好自行决定使用哪种拆分方式。

为了分别为大家演示上述的两种Store拆分方式,我们需要借助于Git对项目进行版本管理:

1、初始化Git仓库,并提交初始版本:

2、基于master分支,分别创建两个分支,用来单独演示每种拆分方式的具体过程:

3、切换到multi-store分支,先为大家演示Multi-Store的拆分过程:

上面的git操作不涉及到仓库,因为从始至终没有push操作,但是却很好的完成了分支的目的,真的是前所未有的体验。很好。

2.以Multi-Store的方式拆分Store

Multi-Store指的是:把不同的数据和方法,拆分为多个彼此独立的store。

2.1拆分Store

1、修改vite-env.d.ts文件,把BearTypeFishType拆分为两个独立的类型声明:

2、在@/store目录下,新建bearStore.ts模块,用来声明bears相关的Store数据。然后把@/store/index.ts中的代码粘贴到bearStore.ts中进行修改(特别注意:要把useStore更名为useBearStore):

3、在@/store目录下,新建fishStore.ts模块,用来声明fishes相关的Store数据。然后把@/store/index.ts中的代码粘贴到fishStore.ts中进行修改(特别注意:要把useStore更名为useFishStore):

4、删除@/store/index.ts模块。

5、改造@/components/setup.tsx模块,把import useStore from "@/store"的模块导入代码改为import useBearStore from "@/store/bearStore",并将所有用到useStore的地方更名为useBearStore

6、改造@/components/Fishes.tsx模块,把import useStore from "@/store"的模块导入代码改为import useFishStore from "@/store/fishStore",并将所有用到useStore的地方更名为useFishStore

运行没有任何问题。这个用起来很简单啊,比pinia还要简单,不需要最终合并为一个文件。

2.2配置数据持久化

zustand内置了数据持久化的persist中间件,对于Multi-Store中的每个Store,我们可以自行决定是否对其进行持久化存储。例如,下面的代码演示了如何对bearStore进行持久化:

注意看右侧local storage里面值的变化,并且浏览器刷新之后,bears状态没有改变,说明值被持久化存储了。

默认情况下,数据会被持久化到localStorage中。如果想自定义存储的位置,可以借助于zustand的createJSONStorage中间件来进行配置。例如,下面的代码演示了如何把数据持久化存储到sessionStorage中:

注意看右侧session storage里面值的变化,并且浏览器刷新之后,值也没有变化,被持久化保存了。

小技巧:

在vscode中,如果鼠标选择一个函数或方法很困难,不知道结束的括号在哪里,可以使用快捷键来操作,鼠标的光标选中一个函数名或将光标放在方法的开头括号处,按两次alt+shift+rightArrow,就可以选中这个函数或方法了,这样准确得多。

2.3在Redux DevTools中调试Store中的数据

前提:浏览器安装并启用了Redux DevTools

1、从zustand/middleware中,按需导入devtools中间件:

2、在create()中调用devtools()中间件:

3、另外,devtools中间件还允许提供一个可选的配置对象,可通过name属性指定store在调试框中显示的名称:

 

image-20231231155639253

 

2.4使用immer简化数据的变更操作

如果是复杂结构的数据,更改起来会非常麻烦,使用immer可以简化数据的操作,复杂数据的纯函数写法我还真的没有写过。

如果文件里面导入了immer,可以使用immer的方式来修改数据,但按照以前的方式来修改数据也是没有问题的,都是兼容的。比如说重置方法:set({bears:0}),如果使用immer的写法,需要写成这样:set(prevState => {prevState.bears = 0}),反而更麻烦,所以具体按照哪种方法来修改数据,要看情况。

1、运行如下命令,安装immer依赖包:

2、按需导入 immer 中间件:

3、在create()中,调用immer()中间件:

4、基于 immer 的语法,简化数据的变更操作。在set(fn)的fn回调函数中,可以直接修改原数据对象。下面的代码是基于 immer 语法修改后的 actions 函数:

效果:

2.5zustand官方对于中间件调用顺序的说明

原文地址请参考:Zustand TypeScript Guide - using-middlewares

https://docs.pmnd.rs/zustand/guides/typescript

以下内容节选自 zustand 官方文档:

Also, we recommend using devtools middleware as last as possible. For example, when you use it with immer as a middleware, it should be immer(devtools(...)) and not devtools(immer(...)). This is becausedevtools mutates the setState and adds a type parameter on it, which could get lost if other middlewares (like immer) also mutate setState before devtools. Hence using devtools at the end makes sure that no middlewares mutate setState before it.

翻译成中文,大概意思是:

此外,我们建议尽可能最后使用 devtools 中间件。例如,当您将它与 immer 一起用作中间件时,它应该是immer(devtools(...))而不是devtools(immer(...))

这是因为 devtools 改变了 setState ,并在其上添加了一个类型参数,如果其它中间件(如immer)也在devtools之前改变了setState,那么这个参数可能会丢失。因此,在最后使用 devtools 可以确保没有中间件在它之前改变 setState

2.6在fishStore中配置以上3个中间件

需要先配置persist中间件,再配置devtools中间件,最后配置immer中间件。最终改造完成的代码如下:

效果:

2.7从Store中抽离Action函数

目前在bearStore和fishStore中,数据和函数定义在一起,随着项目规模的扩大,每个Store中的结构会显得比较混乱。我们可以把Action函数从Store的create()中抽离出来,使代码结构更加清晰。

2.7.1从fishStore中抽离Action函数

1、修改vite-env.d.ts文件中的FishType类型,把它下面所有的Action函数的类型全部注释或删除掉:

2、修改@/store/fishStore.ts模块,把create()中的Action函数单独抽离出来,放到与useFishStore同层级的区域,并且暴露出去让组件使用:

用到了store的setState方法。

3、改造@/components/Fishes.tsx中的代码如下:

 

2.7.2从bearStore中抽离Action函数

1、修改vite-env.d.ts文件中的BearType类型,把它下面所有的Action函数的类型注释或删除掉:

2、修改@/store/bearState.ts模块,把create()中的Action函数单独抽离出来:

3、改造@/components/setup.tsx中的代码如下:

运行效果如下:

 

2.8重置所有Store中的数据

方案1

将所有store的重置函数都执行一遍,就可以重置所有store中的数据了。

效果:

方案2

1、在@/store目录下新建tools/文件夹,并在@/store/tools目录下,新建resetters.ts模块,代码如下:

2、修改@/store/bearStore.ts模块,按照如下4个步骤提供初始数据添加 resetter 函数。核心代码如下:

3、修改@/store/fishStore.ts模块,按照如下4个步骤提供初始数据添加 resetter 函数。核心代码如下:

4、修改@/store/tools/resetters.ts中的代码,向外按需导出一个名为resetAllStore的函数:

5、测试重置所有store的功能

效果:

2.9跨Store访问数据或方法

在react组件中,可以基于 store hook 的 selector 选择器,轻松获取并使用 store 中的数据,例如:

而在实际开发中,我们经常需要在组件之外的地方访问store中的数据,例如:在 axios 的拦截器中访问 store 中的 token 等其它数据。此时,可以使用 store hook 的 getState()方法拿到 store 的数据对象,并访问具体的数据,语法格式如下:

例如,在 axios 的请求拦截器中,为请求头挂载 bears 的数量:

注意:在组件之外访问 store 的 Actions,只需按需导入对应的 Actions 函数即可使用。

2.10添加familyStore相关的功能

2.10.1创建familyStore模块

1、修改vite-env.d.ts模块,新增familyType类型:

2、在@/store目录下,新建familyStore.ts模块如下:

 

2.10.2为familyStore配置中间件

1、配置persist中间件

2、配置devtools中间件

3、配置immer中间件

2.10.3实现family组件的相关功能

1、在@/components目录下,新建family.tsx模块,并创建名为FamilyWrapperFamilyMembersFamilyNames的3个组件:

2、在@/App.tsx根组件中,按需导入并使用FamilyWrapper组件:

3、在@/components/family.tsx中,按需导入useFamilyStore的hook,并使用:

显示效果:

image-20240104100114808

2.10.4修改family中son的名字

1、修改@/store/familyStore.ts模块,新增updateSonName的Action函数:

2、修改@/components/family.tsx模块下的FamilyNames组件,新增修改son的名字的button按钮:

显示效果:

2.10.5基于resetters重置familyStore

1、在@/store/familyStore.ts模块中导入resetters并使用:

2、由于重置所有state的按钮是绑定在fish组件上的,所以点击这个按钮,查看效果:

 

2.10.6向family中添加daughter的名字

1、修改@/vite-env.d.ts文件中的FamilyType的类型定义,新增daughter属性:

2、修改@/store/familyStore.ts模块,新增名为addDaughterName的Action函数:

3、修改@/components/family.tsx模块,按需导入addDaughterName函数,并使用:

查看效果:

 

2.10.7使用useShallow防止组件不必要的渲染

1、在FamilyMembers组件中,添加useEffect的调用,用来监视组件的render渲染:

此时,当组件首次渲染更新渲染时,都会触发useEffect回调函数的执行。

FamilyMembers组件在添加daughter时,是要更新,但是在更新son名字的时候,是没必要更新的。

2、当我们点击FamilyNames组件中的修改son的名字按钮时,并没有为family对象添加任何新成员,但是触发了FamilyMembers组件的更新渲染,这就导致了性能的浪费。此时,我们可以使用useShallow这个zustand hook帮助我们优化渲染的性能:在更新前后,如果selector获取到的数据没有任何变化,则会防止组件的更新渲染,从而提升组件的渲染性能:

把需要进行性能优化的selector包裹在useShallow中即可:

完整代码如下:

修改完成后,再次点击修改son的名字按钮时,不会导致FamilyMembers组件的更新渲染,因为更新前后的members数组没有任何变化。只有点击添加daughter按钮时,才会触发FamilyMembers组件的更新渲染,因为此时的members数组发生了变化。

2.11订阅Store数据的变化

2.11.1 subscribe的语法格式

在zustand中,subscribe(fn)可以用来订阅 Store 数据的变化,并在数据变化后执行 fn 回调函数。subscribe()是Store的一个方法。

在回调函数中,接收两个形参newValueoldValue,其中:

同时,subscribe()还返回一个取消订阅的函数。语法格式如下:

 

2.11.2 subscribe的基本使用

1、修改@/components/family.tsx中的FamilyNames组件,基于subscribe()订阅familyStore数据的变化:

执行效果:

注意:

订阅函数在useEffect中只需要订阅一次即可,所以useEffect的deps为空数组。

2、点击button按钮,取消订阅:

第一步的代码里面,由于没有写组件卸载的代码,所以在这里添加一个button,专门用来取消订阅。使用了useRef来存储“取消函数”。

执行效果:

subscribe的缺点:只能订阅整个 Store 数据的变化,无法订阅 Store 下某个具体数据的变化。

点击“添加daughter”按钮,仍然会触发订阅函数,我的本意可能只是监听son值的变化,但是监听的是整个store,执行效果从上面的动图中可以看到。

要想监听具体数据的变化,需要用到下面的subscribeWithSelector

2.11.3 subscribeWithSelector

基于subscribeWithSelector这个中间件,可以订阅(监听)Store中指定数据的变化。它的使用分为以下两个主要步骤:

1、导入subscribeWithSelector中间件,并在创建 Store 的 hook 中使用此中间件:

image-20240103212009585

语法规则:

2、调用subscribe()函数,订阅具体数据的变化:

例如:

其中options配置对象中的fireImmediately:true表示立即触发一次回调函数的执行。

2.11.4 使用subscribeWithSelector订阅son的变化

1、导入subscribeWithSelector中间件,并在创建 Store 的 hook 中使用此中间件:

2、调用subscribe()函数,订阅具体数据的变化:

执行效果:

2.11.5 基于subscribe实现Father组件背景色的变换

需求:如果小鱼干的数量>= 5,则让 Father 组件的背景色为 lightgreen;否则,让Father组件的背景色为lightgray。

1、使用subscribeWithSelector中间件,更改fishStore.ts模块:

2、改造@/components/setup.tsx模块中的Father组件,结合useStateuseEffect和zustand的subscribe,实现背景色变换的功能:

效果:

3.以Single-Store的方式拆分Store

Single-Store指的是:把不同的数据和方法,拆分为多个slice切片,最终,把多个slice合并成全局唯一的Store。

注意:

“拆分成多个slice切片”,这些切片只是store的组成部分,并不是store。全局只有唯一的一个store。这一点要重点理解。

将项目切换到single-store分支,再进行下面的步骤。

3.1拆分Slice

1、将vite-env.d.ts中的BearType类型注释或删除掉,创建两个新的数据类型,分别是BearSliceTypeFishSliceType

2、在@/store/目录下,新建slices/文件夹,并新建两个slice模块,分别是fishSlice.tsbearSlice.ts。其中fishSlice.ts中的代码如下:

bearSlice.ts中的代码如下:

3、改造@/store/index.ts中的代码,导入多个slice切片,并进行组装:

这样就可以直接使用useStore了,setup.tsx和Fishes.tsx里面的代码不需要进行更改。

效果:

3.2从slice中抽离Action函数

1、修改vite-env.d.ts中的TS类型,把Action相关的类型删除掉:

2、修改@/store/slices/fishSlice.ts模块如下:

3、修改@/store/slices/bearSlice.ts模块如下:

这里其实我有一个疑问,引入的useStore没有问题吗?

没有问题,其实仔细想一想,暴露出去的方法,和useStore的关系是什么?

是暴露出去的方法需要useStore,而useStore不需要这些方法,而且暴露出去的方法是独立的暴露,所以用起来更没有问题。

每个slice文件里面的方法,完全可以定义在index.ts中,这样理解起来更加清楚,是这些方法需要useStore。

4、修改@/components/Fishes.tsx模块中的代码如下:

5、修改@/components/setup.tsx模块中的代码如下:

展示效果没有问题。

3.3配置中间件

3.3.1 persist

3.3.1.1 基础配置

1、在@/store/index.ts中,按需导入persist相关的中间件:

 

2、在调用create()期间,配置 persist 中间件:

效果:

3.3.1.2 自定义要持久化哪些数据

可通过persist中间件的配置对象中的partialize选项,自定义要持久化Store中的哪些数据,它的语法格式如下:

示例代码如下:

切换要持久化存储的数据,查看效果:

只存储fishes:

只存储bears:

 

Object.entries()方法相关知识:

image-20240112094742694

partialize(state){},这个方法里面的state参数是什么?输出看一下:

image-20240308085241858

可以看到是state里面的原始数据,返回值是什么呢?先看官方文档:

https://docs.pmnd.rs/zustand/integrations/persisting-store-data

image-20240308085909190

可以看到,返回的应该是筛选后的对象,默认是返回state,处理的方法在上面已经说明了。

3.3.2 devtools

1、按需导入 devtools 中间件:

2、在调用 create() 时,使用 devtools 中间件,并配置 store 在devtools中显示的名称:

效果:

3.3.3 immer

1、安装 immer

2、在@/store/index.ts模块中,按需导入immer中间件:

并在调用create()期间,配置 immer 中间件:

3、修改@/store/slices/fishSlice.ts,基于 immer 语法改造对应的Action:

 

4、修改@/store/slices/bearSlice.ts,基于 immer 语法改造对应的Action:

效果:

3.3.4 subscribeWithSelector

3.3.4.1 基本配置

subscribeWithSelector 中间件允许我们订阅 store 中指定数据的变化,配置此中间件的第一步是按需导入它:

第二步是在调用 函数期间,使用此中间件即可:

3.3.4.2 根据小鱼干的数量设置Father组件的背景色

需求:如果每只小熊“至少能获得5条小鱼干”,则 Father 组件的背景色为 lightgreen;否则背景色为lightgray

1、在Father组件中,先基于 selector 选择器获取到小熊的数量:

2、再定义 state 状态,用来保存 Father 组件的背景色:

并为Father组件中最外层的 div 绑定样式对象:

 

3、在 useEffect 中,通过subscribe订阅小鱼干数量的变化,并更新bgColor的值。同时,需要把bears数量设置为useEffect的依赖项,当小熊数量变化时,重新计算bgColor的颜色值:

这里为什么小熊的数量要设置为useEffect的依赖项?

因为题目要求是“每只小熊至少...”,要计算的是平均值。实际上监听的是两个值:bears和fishes,任何一个值变化了,平均值都会变化,要实时获取到平均值。

效果:

3.4跨slice访问数据或方法

在zustand中跨slice访问数据的过程很简单,只需要基于全局唯一的 store Hook,调用它的setStategetState即可。

而且,跨slice访问Action方法也很方便,只需要按需导入对应 slice 中的方法进行调用即可。

 

例如,下面的代码演示了如何同时让bears和fishes的数量自增+1:

1、在@/store/目录下新建tools文件夹,并新建名为commonAction.ts的模块:

2、修改@/components/setup.tsx模块中的Son2组件,点击自增bears和fishes按钮,调用刚才定义的Action函数:

效果:

3.5使用 resetters 重置 Store 数据

1、在@/store/tools/目录下新建resetters.ts模块,初始化代码如下:

 

2、修改fishSlice.ts中的代码,导入 resetters 数组,并向数组中添加当前 slice 的 reset 函数:

 

3、修改bearSlice.ts中的代码,导入 resetters 数组,并向数组中添加当前 slice 的 reset 函数:

 

4、修改@/components/setup.tsx模块中的Son2组件,为重置Store按钮绑定点击事件处理函数:

效果:

3.6清除本地存储的内容

通过persist中间件提供的clearStorage()函数,就能方便的清除本地存储的数据。示例代码如下:

效果:

小结:

使用zustand我有一个体会,有一点和vuex或pinia不同的地方,就是store创建之后,不需要在main.tsx里面引入注册,而是哪里使用,就在哪里引入。其实这只是代码组织的不同而已,vuex也可以做到哪里使用就在哪里引入,只不过在main.js里面引入之后,全局都可以使用了,这样会方便很多。