当前位置: 首页 > news >正文

[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 的方式可以更好的获取定义——这里的数据也的确不用修改

具体的实现如下:

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:

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

具体的实现如下:

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,
      },
    }));
  },
}));

数据的类型格式如下:

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

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

  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 实现如下:

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

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

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

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]);
}

和上面的实现基本一致

调用

最终调用方法如下:

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} 的格式,想要怎么转都很简单

http://www.dtcms.com/a/130114.html

相关文章:

  • 波束形成(BF)从算法仿真到工程源码实现-第七节-关于波束10个基本概念
  • Jenkins 发送钉钉消息
  • 前端jest(vitest)单元测试快速手上
  • Redis基础知识:
  • 解释:指数加权移动平均(EWMA)
  • C++ 编程指南36 - 使用Pimpl模式实现稳定的ABI接口
  • 链接世界:计算机网络的核心与前沿
  • 使用SSH解决在IDEA中Push出现403的问题
  • 基于电子等排体的3D分子生成模型 ShEPhERD - 评测
  • 从代码学习深度学习 - 多头注意力 PyTorch 版
  • 【2025软考高级架构师】——项目管理(3)
  • 【毕设】Python构建基于TMDB电影推荐系统
  • L2范数与权重衰退
  • 烟花爆竹储存作业安全要求
  • nodejs构建项目
  • 前端开发中的问题排查与定位:HTML、CSS、JavaScript(报错的解决方式)
  • 高效的内容搜索工具推荐
  • 【工程开发】LLMC准确高效的LLM压缩工具(一)
  • MIPI协议介绍
  • (四十七)Dart 中的 `identical` 函数与 `const` 关键字
  • GM DC Monitor v2.0 数据中心监控预警平台-CMDB使用教程(第十篇)
  • 【图像处理基石】什么是通透感?
  • cropperjs 2.0裁剪图片后转base64提示“Tainted canvases may not be exported”跨域问题的解决办法。
  • 0x03.Redis 通常应用于哪些场景?
  • 【从0到1搞懂大模型】transformer先导:seq2seq、注意力机制、残差网络等(6)
  • C++ 数据结构之图:从理论到实践
  • React(1)基础入门
  • 【模拟电路】PIN光电二极管和APD雪崩光电二极管
  • I/O进程5
  • fio的资料