Zustand+sliceMode+TS 的一种实践方案
技术zustand, state-manage, TypeScript, DX

Zustand+sliceMode+TS 的一种实践方案

Published On
17分钟阅读cms:002417f8cc
概述:分享一种基于 zustand multi slices + namespace + typescript 的代码组织方案,以及给出个人实践实例

最前

React 本质作为轻量 UI 库,更多的功能补全(比如路由、状态管理等模块...)都需要开发者自行到社区生态下找插件作补全,而至于状态管理方案,来自开发者 Daishi kato 的开源项目 zustand/jotai/valtio 也在近些年进入更多开发者视野 —— 得益于:1.足够轻量;2.简洁的 API 使用风格(早些年 Redux 的模板代码给多少开发者留下 ptsd)。

下面再稍微展开说说这三个库(zustand/jotai/valtio)的部分差异点:

状态管理的模式异同

const [state, setState] = React.useState(-1);

React 始终强调状态值(在组件一个生命周期内)的不可变性,而设计理念与之保持一致的是 zustand + jotai,

jotai-store
import { atom, useAtom } from 'jotai'

const countAtom = atom(0)

const [count, setCount] = useAtom(countAtom)

setCount(count + 1);

而 valtio 基于 proxy 实现,状态的变更形式为 state = newValue;和另一知名状态管理库 mobx 有着更接近风格:

mobx-store.ts

而从个人偏好上(以及社区下载量)来讲,都更愿意保持同官方一致的——在状态不可变基础上的变更风格setState(prevSt => newSt)

而再说到 zustand / jotai 的明显差异,有一句话总结到位:前者的全局状态管理是自顶向下,而后者是自底向上,更形象的说:zustand (以及同理念的 redux)是为我们构建一棵(由 store 引用为原点的)分叉的状态树,而 jotai (以及同理念的 recoil)是一个个离散的原子状态(允许离散在应用的各处定义)

从吾辈的直观感受来讲,如果是个人项目,使用 jotai 会非常的自在舒适,基本和 React 原生 api#useState 相差不大的调用风格。而如果从团队配合开发上来说,如果团队间的开发水平明显不同, 那么 zustand 可能是更好的方案,它已经足够轻量,在此之外,一些必要的“模板”代码,保证了团队开发下的代码约束性。

以上是简要的梳理了几个库的主要差异点,更多更详实信息当然一定以官方文档为准。

最终吾辈选择了 zustand, 使用并总结后输出该文分享。

正文

Your applications global state should be located in a single Zustand store. If you have a large application, Zustand supports splitting the store into slices.

虽然官方文档有上面的一段话,但这也只是推荐而非强制,部分情况下,也被允许以 multi-stores 管理全局状态,zustand 开发作者也在某个 issue 下类似提及。

好了,如果以官方推荐的 single-store 来管理全局状态,一个真实的应用大多数情况下可能都有着庞杂的状态数量,预先的切换到 slice (手动分片)模式是必要的。

相关文档链接:

一份创建切片模式的官方示例代码:

import { create, StateCreator } from 'zustand'

interface BearSlice {
  bears: number
  addBear: () => void
  eatFish: () => void
}

interface FishSlice {
  fishes: number
  addFish: () => void
}

interface SharedSlice {
  addBoth: () => void
  getBoth: () => void
}

const createBearSlice: StateCreator<
  BearSlice & FishSlice,
  [],
  [],
  BearSlice
> = (set) => ({
  bears: 0,
  addBear: () => set((state) => ({ bears: state.bears + 1 })),
  eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
})

const createFishSlice: StateCreator<
  BearSlice & FishSlice,
  [],
  [],
  FishSlice
> = (set) => ({
  fishes: 0,
  addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})

const createSharedSlice: StateCreator<
  BearSlice & FishSlice,
  [],
  [],
  SharedSlice
> = (set, get) => ({
  addBoth: () => {
    // you can reuse previous methods
    get().addBear()
    get().addFish()
    // or do them from scratch
    // set((state) => ({ bears: state.bears + 1, fishes: state.fishes + 1 })
  },
  getBoth: () => get().bears + get().fishes,
})

const useBoundStore = create<BearSlice & FishSlice & SharedSlice>()((...a) => ({
  ...createBearSlice(...a),
  ...createFishSlice(...a),
  ...createSharedSlice(...a),
}))

吾辈一开始想要按着这份官方示例代码作蓝本,平滑迁移自己项目下的状态管理,不过很快发现官方文档以及示例代码都有着(个人主观感受上)一定的局限性,包括不限于以下几点:

  • TS 类型的循环依赖情况:

    • 这种情况发生在:一般的,我们将不同的 xxxSlice 定义在不同的文件下,只简单的以官方示例代码为参考,会出现对 TS 类型循环导入的情况,即在 anameSlice 中导入 BnameSliceType , 在 bnameSlice 中导入 AnameSliceType ,
    • 虽然对于 TS 的类型出现循环导入的情况,并不会导致报错,但是这终究可以规避掉,一种推荐的解决方式:将各种 type 定义放到单独文件下(/_type.ts),统一导出。
  • 状态在示例代码中被合并为“扁平”结构:

如上面的代码高亮:

const store = create((...a) => {
...createBearSlice(...a),
...createFishSlice(...a),
...createSharedSlice(...a),
}

这样的聚合 slice 的模式,最终使多个 slice 下的状态字段扁平放置到同一级,(个人主观并不推崇),

  • 首先,各状态字段扁平放置在同一级,比如在实际开发中,借助 redux 开发者工具想要索引到指定状态,不便快速定位到。

  • 其次,不再允许不同 slice 下有相同的字段名称。anameSlice#foo, bnameSlice#foo 会出现覆盖情况。

推荐的风格,每个 slice 有各自的命名空间(也更靠拢原先使用 redux 的定义习惯),

example
{
  store: {
    sliceA: {
      foo: 1,
    },
    sliceB: {
      foo: "",
      bar: 1,
    }
  }
}

一些基于 zustand 的开源库能够为我们提供 slice 命名空间(也即是嵌套状态)的支持 ,比如 zustand-sliceszustand-lenszustand-namespaces ,而是否考虑采用呢?

吾辈又有考量,第一,zustand 本身就是独立开发者作品,这些配套的“插件”并不能保证积极更新,始终跟上主版本的更新节奏;第二,代码侵入性不可避免。

又从社区讨论中寻找 slice 命名空间的解决方案,发现一份易读实用的示例代码 👍🏻,(下文以“源码 A”代称),如果不想跳转,本站也对该示例代码作了单独摘录,点击这里跳转:zustand slice namespace /demo,就不穿插在本篇正文部分了。

并且该代码也有充分的可用性保证 —— 被用在 🚩 电商 Shopify 的产品源码中,(下文以“源码 B”代称)。

一些必要的函数(或工具类)定义

吾辈最终参考上面的回答,来构建实现项目中 slice namespace 支持,但是上面的示例代码依然存在一定优化空间,吾辈下面列出一些代码段,都是已经在实际项目中验证可用的。

📢 请先大致阅览过上面的源码,才能平滑理解下面的一些代码段的作用场景。

定义 TS 工具类

像源码 A 或很多日常代码,对于 redux / zustand 这样的状态管理库,我们可能选择手动声明函数类型,用来匹配:setState/toggleState/resetState 等基本常见的 action 函数,如下面的代码段:

type.ts
// ...
export interface BarStateDefintion {
  baz: string;
  qux: string;
}

export interface BarStateActions {
  setBaz: (value: string) => void;
  setQux: (value: string) => void;
}

但是我们可以定义 TS 工具类,在之前的发布博文中:「TypeScript 下 Mapped Types 应用场景例」 也推荐了这种实践:

type-tool.ts
// 吾辈的个人偏好,对于布尔值的状态值的变更函数命名风格: toggleState

export type ActionsInit4StateType<T> = {
  readonly [P in keyof T & string as T[P] extends boolean
    ? `toggle${Capitalize<P>}`
    : `set${Capitalize<P>}`]: (value: T[P]) => void;
};

// 调用处
export type BarStateDefintion = {
  baz: string;
  qux: boolean;
}

export type BarStateActions = ActionsInit4StateType<BarStateDefintion>

定义工具函数: useStoreByShallow

一般的,zustand “订阅” 状态的语句如:const someState = useStore(store => store.someState), zustand 默认通过 Object.is 来对状态(更准确的说,应该是:selectorFn 的返回值)是否变更,(进而影响订阅相关状态的组件重渲染)作出判断,不过这种情况,不适用非纯数据的对象、数组等引用类型的数据

所以官方额外提供工具函数: shallow / useShallow ,来专门处理对对象等数据的“浅层比较”,更多信息见官方文档:api#shallow + api#useShallow

为了方便日常调用,我们可以定义:

useStoreByShallow.ts
import { useShallow } from "zustand/react/shallow";
//...

// useStore 就是基于 zustand#create 构建的顶层 store 关联函数。

const useStoreByShallow = <T>(selector: (st: CombinedSlice) => T): T => {
  return useStore(useShallow(selector));
}

// 调用
function SomeComp() {
  const [val1, val2] = useStoreByShallow(st => [st.val1, st.val2])

  return (...)
}

如果有心,去翻看下源码 B,会发现其就未对引用类别的数据作 shallow 比较处理,而潜在非必要重渲染的性能隐患:

以源码 B 中的一段代码为例:

src/components/Modal/Modal.tsx
// ...

export const Modal = () => {
  const [
    {goBack, navigate, open, reset, route},
    {pendingConnector, pendingWallet},
  ] = useStore((state) => [state.modal, state.wallet]);
  // ...

⚠️ 上面的代码主要有两个(可改进)问题:

  1. selectorFn 返回的为数组类型,导致非必要重渲染;
  2. 不推荐使用解构语句,潜在风险:直接读取整个 state.modal、整个 state.wallet (下的所有字段)

可以利用上面定义的 #useStoreByShallow 优化成下面的写法:

const {
  goBack,
  navigate,
  open,
  reset,
  route,
  pendingConnector,
  pendingWallet,
} = useStoreByShallow((state) => ({
  // modal 相关
  goBack: state.modal.goBack,
  navigate: state.modal.navigate,
  open: state.modal.open,
  reset: state.modal.reset,
  route: state.modal.route,
  // wallet 相关
  pendingConnector: state.wallet.pendingConnector,
  pendingWallet: state.wallet.pendingWallet,
}));

但是上面的函数依然有着优化空间,便是下一节的小主题。

提前“代理” state.wallet 、 state.modal

因为使用了slice 的命名空间,所以全局状态呈现为嵌套结构,

方便起见就直接使用源码 B 的状态结构为例,显然的,每次如此调用,很繁杂,

对于嵌套结构的数据拿取,都是长串的 store.someSliceName.someKey ,还是写着难受,看着花眼。

比如更极端的拿取数据的例子:

const { name, avatar } = useStore((st) => ({
  name: st.userModel.user.baseinfo.name,
  avatar: st.userModel.user.baseinfo.avatar,
}));

所以这也可能是为什么官方默认将多个 slice 下的字段扁平化放置处理的缘由……

我们可以定义像下面这样的工具函数来便捷的拿取数据。

useWalletStore.ts
const useWalletStore = <T>(selector: (mutState: WalletStateDefintion) => T):T => {
  return useStoreByShallow(st => {
    const walletProxy = new Proxy({} as WalletStateDefintion, {
      get: (_, prop) => {
        return st.wallet[prop as keyof WalletStateDefintion]
      }
    })
    return selector(walletProxy);
  })
}

// 调用处,现在允许对原本的嵌套状态的字段访问,使用更简洁的调用风格:
const { pendingConnector, pendingWallet } = useWalletStore(state => ({
  pendingConnector: state.pendingConnector,
  pendingWallet: state.pendingWallet,
}));

总之,我们使用 proxy + getter + 闭包特性,实现对 store 下状态数据的延迟访问。

额外的,预先作出字段的读取范围,函数内容无需调整,可以定义对同一 slice 的可访问字段不同约束范围的功能函数:

只需在声明 selectorFn 类型时,如此声明:

// 定义处:
selector: (mutState: Pick<WalletStateDefintion, "key1" | "key2">) => T

// 调用处:
useWalletStore(state => [state.key1, state.key2])

一些写法细节上,也经过几个版本迭代,主要的两个历史版本如下面的代码块记录:

useWalletStore-old1.ts

该历史版本可行,亦可使用 Object.defineProperties 来遍历预先给定的 key 值集合来定义 getter 函数,

use-Object#defineProperties

但是存在局限性,如果要代理的 state#someSlice 下面的状态字段太多,则定义起来麻烦。

另一个历史版本:

useWalletStore-old2.ts

该历史版本初看可行,也使用 proxy 代理对象的属性访问, 但实际因为在 Proxy 构造函数中预先传入整个 st.wallet ,就有被提前全部读取/订阅的可能性(具体是否被订阅需查究 zustand 源码,但是预先规避可能风险,百利无一害),所以最终弃用 new Proxy(st.wallet, ...) 的写法,

我们实际就是主要的希望利用 getter 的特性来延迟读取状态字段,而最终(优化后)写法:我们使用 proxy (无效)代理一个空对象,避开对 st.wallet 的预先整个读取,实际主要利用到“闭包”特性 —— 在实际的访问某个字段时才去读取 st.wallet。

以上

Zustand+sliceMode+TS 的一种实践方案

https://infen.cc/blog/zustand-slice-ts-practice[Copy]
本文作者
Helen4i, TC
创建/发布于
Published On
更新/发布于
Updated On
许可协议
CC BY-NC-SA 4.0

转载或引用本文时请遵守“署名-非商业性使用-相同方式共享 4.0 国际”许可协议,注明出处、不得用于商业用途!分发衍生作品时必须采用相同的许可协议。