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