[React] 如何用 Zustand 构建一个响应式 Enum Store?附 RTKQ 实战与 TS 架构落地

[React] 如何用 Zustand 构建一个响应式 Enum Store?附 RTKQ 实战与 TS 架构落地

本文所有案例与数据为作者自行构建,所有内容均为技术抽象示例,不涉及任何实际商业项目

自从之前尝试了一下 zustand 之后,就发现 zustand 是一个轻量但功能强大的状态管理工具。在我们目前的企业级项目中,它不能完全取代 Redux Toolkit,但却可以很好地作为 enhancer,尤其适用于像 enum 管理这种状态简单但变化频繁的场景

背景介绍

简单的描述一下我们现在有的功能,以及我想要提升的部分

我们的项目是一个大型的 B2B 的项目,因此后端部分的检查配置特别多,这也就导致我们有很多的 enum------真的很多,一千多行,上百个 enums,而这只是在我内部使用的 data definition 文件中的。前端的业务相对而言会更加的复杂,尤其是 UI 需要基于不同的业务场景,将本来的 enum 进行切片,只渲染与当前业务场景相关的 enum

对于前端来说,这就意味着我们需要对目前的 enum 进行更细致的切割,也就导致了代码量一涨又涨,管理起来也非常的困难

举例说明就是,一个学校会有很多的课:语数外理化生、体育、美术等,但是不同专业的学生要选的课肯定是不一样的,比如说理科专业的,那专业课里就不会涉及到写生、艺术赏析这种课程,反之亦然

我们现在的业务场景和这个情况就有点相似,前端方面,就像选专业课的时候,列举的一定是本专业相关的课程;后端方面,它只需要验证提交的课是合法的课程,并不需要判断学生的专业身份

除了静态的 enum 需求,UI 部分还会有另外的挑战,也就是必须要将动态的数据保存下来,同样作为 dropdown 去进行渲染。有些数据对于动态数据的精确度要求比较高,需要做外链检查,因此保存的是 id------大多数情况下做的是 referential integrity 检查;另外的情况下,这些动态的数据会作为对象的属性值保存,因此保存的不是 id,而是值------这种情况下作为 record 保存,不需要在意数据是否过期/被删除

我们目前对此的处理方法,是在 redux state 中保存两份 copy,一份是以值为主的数组,另一份则是以 键值对 的格式进行保存------这么做的主要原因也是为了集中业务处理的逻辑,避免到处使用 useMemo 造成的逻辑散乱

之前一直想着要优化一下 DX 方面,不过最终也没什么更好的处理方法,一直到最近,了解了一下 zustand,发现这个搭配 RTK/RTKQ 能够很好的解决我们现在有的一些问题------根据 How to access Zustand store outside a functional component? 这个 post,我们可以在 react 之外比较轻松的 set/get zustand 数据,这个比起到处乱传 redux state 要简单不少

设计结构

这里我们需要分成两个部分进行思考设计:

  • 静态数据

    静态数据是固定的 enum,这些和 DD 文件中设定好的 enum 一致,我们只是想要一个更好的方法去分类对应的 enums

  • 动态数据

    动态数据来自于后端,我们需要一个弱定义,只需要确定保存的格式为 键值对 即可,也就是用 Record<string, string> 去进行一个宽泛的限制

这里依旧以课程作为一个宽泛的案例进行设定,其中:

  • 静态数据

    这里包含了课程、专业、教授

    这里数据的关联为:

    不同专业需要包含不同的课程;教授可以教不同的课

  • 动态数据

    这里我没有太好的案例,就通过异步获取课程的等级和授课的时间分区

    主要是这地方的数据和静态数据不需要产生太多的关联,因此也没有什么特别好的案例,就先这么设定吧

静态数据

基础的 enum 设定

这里就是比较宽泛的包含所有的 enum,并且用 object 的方式进行存储,用 as const 的方式可以更好的获取定义------这里的数据也的确不用修改

具体的实现如下:

typescript 复制代码
export const enums = {
  courses: {
    ml: "Machine Learning",
    db: "Database Systems",
    ds: "Data Structures",
    stats: "Statistics",
  },
  majors: {
    ai: "Artificial Intelligence",
    se: "Software Engineering",
    da: "Data Analytics",
  },
  professors: {
    p1: "Dr. Ada Lovelace",
    p2: "Dr. Alan Turing",
    p3: "Dr. Andrew Ng",
  },
} as const;

export type EnumMap = Record<string, string>;
export type EnumKey = keyof typeof enums;

重申一下,EnumMap 设置成宽泛的 Record<string, string> 是为了之后动态数据做准备。静态数据不会被修改,动态数据难以被规范,因此这里只需要一个比较宽泛的 键值对 规范即可

enum slice 的设定

enum slice 的设定与 enum 相似,它包含的是数据的切片,当然,这里的 slice 看起来更像是两个属性之间的 mapping:

typescript 复制代码
import { EnumSliceDefinition } from "./type";

export const enumSlices = {
  majorCourses: {
    ai: ["ml", "db", "stats"],
    se: ["db", "ds"],
    da: ["db", "stats"],
  },
  professorCourses: {
    p1: ["db", "stats"],
    p2: ["ds", "ml"],
    p3: ["ml"],
  },
} satisfies {
  majorCourses: EnumSliceDefinition<"courses">;
  professorCourses: EnumSliceDefinition<"courses">;
};

export type EnumSlices = typeof enumSlices;
export type EnumSliceKey = keyof EnumSlices;
export type EnumSliceDefinition<K extends EnumKey> = Record<
  string,
  (keyof (typeof enums)[K])[]
>;
export type SliceId<K extends EnumSliceKey> = keyof EnumSlices[K];
export type SliceValue<K extends EnumSliceKey> =
  EnumSlices[K][SliceId<K>] extends readonly (infer V)[] ? V : never;

satisfies 这么写的优点在于更好的 TS 提示------之前提到了,enum 的数据量非常大,要有上千行/上千条的数据,手动写的话,一旦出现 typo,就会导致数据出现异常

这样实现的效果如下:

这个情况下,对于比较简单,并且只有静态的数据格式的情况下,其实已经可以直接使用了:

至此,静态数据的封装已经完成的差不多,接下来要完成的是动态数据的封装

Zustand 设计

zustand 的设计相对而言比较简单,因为我们的需求和功能都是已经定义好的:

  • 静态数据和动态数据存储方式都是键值对

    为了方便类型断言,动态数据、静态数据可以分开存储

    获取数据时,可以获取 键值对 的格式,也可以获取 values 的格式

  • 可以获取静态数据

  • 可以获取和保存动态数据数据

  • 需要可以根据情况获取 sliced enum

具体的实现如下:

typescript 复制代码
export const useEnumStore = create<EnumState>((set, get) => ({
  enums,
  slices: enumSlices,
  refEnums: {},

  getEnumOptions: (key) => {
    const entries = Object.entries(get().enums[key]);
    return entries.map(([id, label]) => ({ id, label }));
  },

  getEnumValues: (key) => {
    return Object.keys(enums[key]);
  },

  getRefEnumOptions: (key) => {
    const refEnum = get().refEnums[key] ?? {};
    return Object.entries(refEnum).map(([id, label]) => ({ id, label }));
  },

  getRefEnumValues: (key) => {
    const refEnum = get().refEnums[key] ?? {};
    return Object.values(refEnum);
  },

  getSliceValues: (key, id) => {
    return get().slices[key][id] as SliceValue<typeof key>[];
  },

  getSliceOptions: (sliceKey, id) => {
    const values = get().slices[sliceKey][id] as SliceValue<typeof sliceKey>[];

    const enumKeyMap: Partial<Record<EnumSliceKey, EnumKey>> = {
      majorCourses: "courses",
      professorCourses: "courses",
    };

    const enumKey = enumKeyMap[sliceKey];
    const labelMap = enumKey ? get().enums[enumKey] : {};

    return values.map((val) => ({
      id: val,
      label: labelMap?.[val as keyof typeof labelMap] ?? val,
    }));
  },

  getSliceKeysByValue: (key, value) => {
    const slice = get().slices[key];
    return Object.entries(slice)
      .filter(([, list]) => list.includes(value))
      .map(([id]) => id as SliceId<typeof key>);
  },

  setRefEnum: (key, data) => {
    set((state) => ({
      refEnums: {
        ...state.refEnums,
        [key]: data,
      },
    }));
  },
}));

数据的类型格式如下:

typescript 复制代码
export interface EnumState {
  enums: typeof enums;
  slices: typeof enumSlices;
  refEnums: Partial<Record<RefEnumKey, EnumMap>>;

  getEnumOptions: <K extends EnumKey>(
    key: K
  ) => { id: string; label: string }[];

  getEnumValues: <K extends EnumKey>(key: K) => string[];

  getRefEnumOptions: (key: RefEnumKey) => { id: string; label: string }[];

  getRefEnumValues: (key: RefEnumKey) => string[];

  getSliceValues: <K extends EnumSliceKey>(
    key: K,
    id: SliceId<K>
  ) => SliceValue<K>[];

  getSliceOptions: <K extends EnumSliceKey>(
    sliceKey: K,
    id: SliceId<K>
  ) => { id: SliceValue<K>; label: string }[];

  getSliceKeysByValue: <K extends EnumSliceKey>(
    key: K,
    value: SliceValue<K>
  ) => SliceId<K>[];

  setRefEnum: (key: RefEnumKey, data: EnumMap) => void;
}

这里实现的功能有:

  • getEnumOptions

    这是一个将当前静态 enum 返回成 {id: key, label: value} 格式的方法,需要的参数是对应的 enum 的 key

    目前来说这个方法其实没办法很好的用在 hooks 里,我大概试过两三种写法,最大的问题/冲突在于,options 会获取原本的 {key: value} 值转化为 {id: key, label: value} 这个格式------我用的是 Array.map 做的。Array.map 的特性就在于,会返回一个新的数组,所以会导致组件无限重复渲染

    如果使用 state.getState().getEnumOptions(...) 的写法倒是可以避免这个问题,但是这种写法没办法自动追踪/订阅 zustand 的最新数据变化,导致数据更新后,UI 方没办法正确更新

  • getEnumValues

    getEnumOptions

  • setRefEnum

    这个是必须的,毕竟 refEnum 是需要异步获取的,所以我们必须要有合适的方法去添加对应的数据

  • getRefEnumOptions

    同上

  • getRefEnumValues

    同上

  • getSliceValues

    同上

  • getSliceOptions

    同上

  • getSliceKeysByValue

    这其实是一个 util 的方法,可以通过提供的 value 去获取哪些 slice 包含当前的值,

👀:这里的 Object.entries 其实有点重复功能,比较好的处理方法还是和 Object.keys & Object.values 一起抽出来做成一个 util 方法,现在就偷了个懒,直接在 hooks 里和 zustand 里写死了值

zustand & redux 之间的比较

只是针对当前这个无限循环问题来说,zustand 和 redux 一样,都会导致无限循环的问题

本质上来说,双方的追踪机制让一旦 getter/reducer 里面出现的值的引用发生了变化,那么重新渲染就会发生 -> 这时候也会触发 React 的 re-render

回顾一下这个方法的实现:

typescript 复制代码
  getEnumOptions: (key) => {
    const entries = Object.entries(get().enums[key]);
    return entries.map(([id, label]) => ({ id, label }));
  },

本质上来说,这就是应该在 immutable 的 reducer 这里,无限期的创立了新的 reference,这种情况下,不管是 zustand 还是 redux 都没有办法很好的解决无限渲染的问题

如果是在 setter/action 的话就是另一个比较了,目前 zustand 是没有内置支持 immutable 相关的支持,所以如果要实现 immutable change,要么手动操作,要么添加 immer 作为 middleware

相比较而言 redux 内置支持了 immer,这方面是不用考虑的

⚠️:我这里虽然用 getter/reducer,但是二者本质上不是一个概念。reducer 是 redux 核心的一个概念,是更新状态的最后一步,将 action 更新过的状态保存到最终的 store 里,这是一个纯函数,不能有任何的副作用;zustand 的 getter,如其名,只是从 zustand 的状态中获取数据的 util function,并不涉及到 zustand 的数据更新

顺便补充说明一下,为什么现在的实现用不上这些 getter,我还是写了,这是打算之后做 event bus 的时候,可以提供给 yup/zod 去进行一个 subscribe 的实现,当然,目前只是一个 placeholder,是为了 future proof 的实现

我之前也写过一个比较简单的 event bus 的实现:[React 进阶系列] useSyncExternalStore hook,它具体的实现逻辑为:

  • react 侧通过 useSyncExternalStore 监听事件变化

  • redux 中通过 EventEmitter 触发 schema 变化逻辑

  • schema 组件手动订阅并更新

现在虽然能够解决业务难点,代码也不算特别复杂,的确可用,但这个架构的问题在于:

  • 事件分发逻辑散落在多个模块中

  • redux 负责数据源,又要额外承担事件调度,职责边界变得模糊 -> 违反了 SPR

  • schema 更新逻辑难以追踪与维护

而 zustand 本身具备非常轻量的 pub/sub 机制 ------ 它的 store 就是事件源。如果我们能在 redux 完成异步请求之后,仅更新 zustand 中的 refEnum 数据,由 zustand 本身提供的 publisher 去进行事件的 broadcasting,那么:

  • redux 只需专注于业务数据获取与管理

  • zustand 负责 enum 订阅、通知与响应式 schema 构建

  • 组件侧通过自定义 hook 订阅 enum 变化,自动联动 UI -> react 中的组件 component 还是一个 reference,如果 schema 内部可以完成对应的变动,而不是创建一个新的 reference,那么 react 还是可以通过 reference 获取最新的 schema 和 structure

    ⚠️:这个地方还是需要思考一下怎么完成具体的实现,或许本身还是需要通过 useMemo 或者 useCallback 去进行一个 guard,毕竟 react-table 需要 memoize 数据和结构

这种方式不仅将状态与事件解耦,也让 enum 的更新成为一种 被动响应式能力 ,而不是 主动命令式调用

动态数据

动态数据的获取比较简单,我们需要整合的数据

我们目前的 migration 是往 RTKQ 走------RTKQ 提供了对于 api 的 cache,这部分的功能是 zustand 无法代替,手动实现也非常麻烦。所以从异步调用 API 的这个使用案例来说,zustand 别说代替了,就是碰都没办法碰瓷 RTKQ

本地的 dummy 实现如下:

typescript 复制代码
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { useEnumStore } from "../../enum-store/useEnumStore";

export const refEnumApi = createApi({
  reducerPath: "refEnumApi",
  baseQuery: fetchBaseQuery({ baseUrl: "/" }),
  endpoints: (builder) => ({
    getCourseLevels: builder.query<Record<string, string>, void>({
      queryFn: async () => {
        await new Promise((r) => setTimeout(r, 300));
        return {
          data: {
            beginner: "Beginner",
            intermediate: "Intermediate",
            advanced: "Advanced",
          },
        };
      },
      async onQueryStarted(_, { queryFulfilled }) {
        try {
          const { data } = await queryFulfilled;
          useEnumStore.getState().setRefEnum("courseLevels", data);
        } catch (e) {
          console.error("Failed to set courseLevels enum:", e);
        }
      },
    }),
    getTimeSlots: builder.query<Record<string, string>, void>({
      queryFn: async () => {
        await new Promise((r) => setTimeout(r, 300));
        return {
          data: {
            morning: "Morning",
            afternoon: "Afternoon",
            evening: "Evening",
          },
        };
      },
      async onQueryStarted(_, { queryFulfilled }) {
        try {
          const { data } = await queryFulfilled;
          useEnumStore.getState().setRefEnum("timeSlots", data);
        } catch (e) {
          console.error("Failed to set timeSlots enum:", e);
        }
      },
    }),
  }),
});

export const { useGetCourseLevelsQuery, useGetTimeSlotsQuery } = refEnumApi;

这里也没什么特别复杂的逻辑,RTKQ 这部分的支持真的没话说,而且 zustand 方面配置的也很好,intellisense 的支持非常完美,可以最大条件的避免 typo:

hooks

这里实现了几个 hooks 是能够更简单的让 React 能够获取想要的数据,相对而言的实现比较简单,这里就列举获取 slice 和直接获取静态/动态数据的 hooks

获取动态/静态 options

这个也是我尝试了好几个不同的版本,最终敲下来的结果:

typescript 复制代码
import { useMemo } from "react";
import { refEnumKeys } from "../enum-store/enum-config";
import { EnumKey, RefEnumKey } from "../enum-store/type";
import { useEnumStore } from "../enum-store/useEnumStore";

export function useEnumOptions(key: EnumKey | RefEnumKey) {
  const isRef = (refEnumKeys as readonly string[]).includes(key);

  const enums = useEnumStore((s) =>
    isRef ? s.refEnums[key as RefEnumKey] : s.enums[key as EnumKey]
  );

  return useMemo(() => {
    return Object.entries(enums ?? {}).map(([id, label]) => ({ id, label }));
  }, [enums]);
}

像以前提到过的,如果直接使用已经封装好的 getRefEnumOptions,会造成无限循环 或 无法获取最新数据的情况,这种情况下已经是最好的写法了

主要问题还是这个 Object.entries().map() 会造成的无限渲染,所以这里需要用 useMemo 进行 guard

获取 slice options

typescript 复制代码
import { useMemo } from "react";
import { EnumSliceKey, SliceId, SliceValue } from "../enum-store/type";
import { useEnumStore } from "../enum-store/useEnumStore";

export function useEnumSliceOptions<K extends EnumSliceKey>(
  sliceKey: K,
  id: SliceId<K>
): { id: SliceValue<K>; label: string }[] {
  const getSliceOptions = useEnumStore((s) => s.getSliceOptions);

  return useMemo(() => {
    return getSliceOptions(sliceKey, id);
  }, [getSliceOptions, sliceKey, id]);
}

和上面的实现基本一致

调用

最终调用方法如下:

typescript 复制代码
import "./App.css";
import {
  useGetCourseLevelsQuery,
  useGetTimeSlotsQuery,
} from "./store/features/refEnumApi";
import { useEnumOptions, useEnumSliceOptions } from "./hooks/enumHooks";

function App() {
  const courseLevels = useEnumOptions("courseLevels");
  const timeSlots = useEnumOptions("timeSlots");
  const courses = useEnumOptions("courses");
  const aiCourseOptions = useEnumSliceOptions("majorCourses", "ai");

  useGetCourseLevelsQuery();
  useGetTimeSlotsQuery();

  return (
    <div>
      <h3>Course Levels</h3>
      <ul>
        {courseLevels.map((opt) => (
          <li key={opt.id}>
            {opt.id} - {opt.label}
          </li>
        ))}
      </ul>

      <h3>Time Slots</h3>
      <ul>
        {timeSlots.map((opt) => (
          <li key={opt.id}>
            {opt.id} - {opt.label}
          </li>
        ))}
      </ul>

      <h3>Courses</h3>
      <ul>
        {courses.map((opt) => (
          <li key={opt.id}>
            {opt.id} - {opt.label}
          </li>
        ))}
      </ul>

      <h3>AI Courses</h3>
      <ul>
        {aiCourseOptions.map((opt) => (
          <li key={opt.id}>
            {opt.id} - {opt.label}
          </li>
        ))}
      </ul>
    </div>
  );

  return <></>;
}

export default App;

这里的提示依旧很好:

options 和 slice 都能够正确的提示到,最大程度地避免了 typo 的问题------很多时候我们必须要反复的用 Object/keys 去做 typing,就是为了避免这个问题,这里已经最大程度地避免了重复且无意义的 declaration

最终渲染结果如下:

其中短时间内 course level 和 time slot 消失不见的原因是因为页面刷行,RTKQ 重新"拉"数据,导致的短期延时,但是总体来说,渲染的结果是没有问题的,后期想要非常简单的将 options 转成 dropdown 也非常的简单,毕竟已经是固定的 {id, label} 的格式,想要怎么转都很简单

相关推荐
JiangJiang16 分钟前
🚀 Vue人看React useRef:它不只是替代 ref
javascript·react.js·面试
会讲英语的码农1 小时前
什么叫“架构”
考研·架构·硬件架构
AI糊涂是福1 小时前
数字政府与智慧城市区别报告分析
大数据·人工智能·机器学习·架构·智慧城市
AronTing1 小时前
06- 服务网格实战:从 Istio 核心原理到微服务治理升级
java·后端·架构
腾讯云开发者2 小时前
AI 时代,程序员只剩架构师?腾讯云架构师技术沙龙邀你共探破局之道
架构
AronTing2 小时前
05-微服务可观测性体系建设:从日志、监控到链路追踪实战指南
java·后端·架构
洛神灬殇3 小时前
【Redis技术进阶之路】「原理分析系列开篇」探索事件驱动枚型与数据特久化原理实现(文件事件驱动执行控制)
redis·后端·架构
Blossom.1183 小时前
KWDB创作者计划— KWDB技术范式革命:从数据存储到认知进化的架构跃迁
数据库·分布式·oracle·架构·自动化·kwdb·流式计算拓扑
CloudWeGo4 小时前
CloudWeGo 技术沙龙·深圳站回顾:云原生 × AI 时代的微服务架构与技术实践
微服务·云原生·架构
carterwu4 小时前
从简单到深入大文件上传和minio、权限认证
架构