尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
rea架构实战:事件驱动与状态分层解决中后台前端通信难题
最近我在整理一个内部项目的代码代号叫 rea。这名字最开始没那么多讲究就是随手起的项目代号结果做着做着团队发现三个字母正好能概括这套架构的核心Reactive、Event-driven、Architecture。响应式、事件驱动、架构规范三个词拼起来就是 rea 的完整含义。它要解决的是中后台前端项目里非常普遍的几个问题状态散落在各个组件里模块之间靠 props 层层透传跨模块通信只能到处贴回调一个登录态要穿过五六层组件才能拿到一次筛选操作可能要触发好几次重复请求。这篇文章就把 rea 从设计思路到代码实现完整拆一遍包括为什么选事件驱动、状态怎么分层、事件怎么命名、怎么避开事件风暴以及我踩过的几个坑。正在做中后台系统、被前端模块通信折腾过、想找一套可落地架构方案的开发者和架构师都可以从里面找到能直接抄的方法。1. 为什么是 REA架构思路与选型拆解1.1 从一次真实翻车现场说起我印象很深的是某次给一个中后台项目加“切换项目空间”的功能。需求本身不复杂用户点下拉框选一个项目然后整个系统要切到对应项目的上下文。听起来简单做起来就炸了。顶部用户信息要刷新侧边菜单要重新拉取权限列表页要清空缓存路由守卫要重新判断可访问页面甚至埋点都要记录一次“切换项目”的行为。如果按传统写法就得在切换项目的方法里手动去调用一堆模块暴露出来的函数A 模块调 B 模块B 模块再调 C 模块改一行代码要翻好几个文件。我当时意识到问题不在于这个功能有多难而在于模块之间的通信方式太原始。靠 props 往下传传不到兄弟节点靠全局变量改动没法追踪靠直接 import 对方模块的函数依赖关系瞬间变成一团乱麻。这是绝大多数中后台系统都会走到的一步业务复杂度一上来组件树和调用链就开始失控。rea 就是在那个时候开始被推倒重来的。1.2 REA 三个字母分别代表什么R 是 Reactive响应式。注意这不是在说 React 这个框架而是一种数据驱动视图的模型状态发生变化所有依赖这个状态的视图自动更新。React 的 useState、useSyncExternalStore 天然就是响应式的但很多项目用着用着就忘了这个初衷开始在 useEffect 里手动 setState把响应式写成了命令式。E 是 Event-driven事件驱动。模块与模块之间不直接引用而是通过发布/订阅来通信。A 模块只负责把消息发到总线上B 模块自己决定要不要响应。这样 A 不需要知道 B 的存在B 也不需要被 A 引入。代码里不再出现“我去调用那个模块里的某个函数”这样的横跨依赖只有“我告诉你发生了一件事”。A 是 Architecture架构规范。rea 不只是一堆库的集合更是一组约定状态放在哪、事件怎么命名、谁可以触发副作用、目录怎么划分。这些东西才是真正让一个项目能长期维护的关键。很多团队技术栈很先进但代码照样乱就是因为缺少这些约束。1.3 选型对比为什么没有直接上全家桶在落地的过程中我们反复纠结过要不要引入完整的全局状态管理框架也试过几种方案。这里可以把对比列出来方便你判断什么样的选择更适合自己的项目。方案核心模型适合场景在 rea 中的取舍Redux单一 store reducer action大型全局状态需要时间旅行调试样板代码偏多设置流程重小团队维护成本高Zustand极简 store hooks中后台模块级状态轻量、直接、对 TypeScript 友好作为 rea 的默认 store 实现MobX可观察对象 自动追踪复杂业务模型的响应式更新自动追踪虽然方便但隐式依赖太多出了问题不好定位暂时没有采用mitt / EventTarget发布订阅模块间跨领域通知mitt 体积小、没有 Node 依赖作为默认事件总线EventTarget 作为浏览器原生备选EventEmitter发布订阅Node 环境或 SSR 场景浏览器端引入 Node 的 events 库没必要放弃核心选型逻辑很简单状态管理要轻事件总线要薄所有重型业务逻辑都放在业务模块自己的代码里。Zustand 的好处是它不强制你把所有状态放同一个 store可以按模块拆这正好配合 rea 的状态分层。mitt 的好处是只有几百字节API 只有 on、off、emit 三个方法团队上手成本极低。真正的复杂度控制靠的是约定而不是把一堆库堆在一起。1.4 架构原则先立规矩再写代码rea 在落地前先定了几个硬规矩这些规矩决定了后续所有代码的写法。第一状态能放组件就放组件。组件内部的弹窗开关、折叠状态、临时输入一律 useReducer 或 useState 搞定不进任何全局 store。全局 store 只放那些真正需要跨模块共享的数据比如登录态、用户信息、当前项目上下文。第二事件只做通知不做业务。事件总线上传的是“发生了什么”不是“你要怎么做”。比如登录成功后发一个auth:login:ok下游模块收到后自己决定要不要拉数据、改 UI。如果事件里直接写了“你必须拉权限、必须刷新菜单”那事件就退化成函数调用了。第三副作用只能从 action 里触发。在 rea 里store 的 action 是业务逻辑的入口事件 emit 也是从 action 里发出去的。组件里只调用 action不自己 new 事件、不直接在渲染过程中 emit。这样所有操作都能被追踪到事件流不会散落在组件各处。第四能派生的状态不去手动同步。用户 ID 变了权限自然跟着变token 变了登录状态自然跟着变。这些关系用 selector 和 computed 表达不要写一堆“当一个值变化时更新另一个值”的 useEffect。这些原则听起来都是大白话但真正坚持下来项目复杂度增长会慢很多。后面几节就按照这些原则来拆解具体实现。2. 核心设计拆解状态分层、事件协议、派生状态2.1 状态三分类领域状态、UI 状态、服务端缓存状态rea 里把前端状态分成三类领域状态、UI 状态、服务端缓存状态。分类清楚以后“状态应该放在哪”这个问题就有标准答案了。领域状态指的是描述业务实体当前状态的数据。典型例子有当前登录用户、用户角色、当前项目 ID、系统配置。这类状态必须全局共享因为整个系统很多模块都要读。在 rea 中领域状态放在 feature 对应的 store 里比如authStore、projectStore。它的更新通常伴随着业务动作登录、登出、切换项目、更新角色。UI 状态指的是界面本身的临时状态。比如表格是否 loading、弹窗是否打开、当前标签页激活项、表单错误提示。这类状态只服务一个组件或一个页面跨模块没人关心所以留在组件内部就行。我见过很多项目把所有 UI 状态都塞进 Redux 或 Pinia导致 store 里一堆isModalVisible、tableLoading这种完全不应该全局共享的字段。等 store 越来越大根本分不清哪些状态是业务核心哪些只是某个按钮的附属品。服务端缓存状态指的是从接口拉回来的数据快照比如列表页的搜索结果、详情页的实体详情。这类状态的特点是数据结构由后端接口定义前端不修改它的真实来源只负责缓存、刷新、过期。rea 里对这类状态的处理是优先交给请求层去缓存比如用 React Query、SWR 这类带缓存能力的数据请求库。如果你不想引入额外库也可以用模块级 store 存但一定要有更新和失效的规范不能在任意地方随意往里面塞数据进行修改。三类状态分清楚以后你会发现自己写的很多问题都是状态放错了位置。弹窗状态被兄弟组件影响说明它本来就不该是 UI 状态登录用户被两个页面各自缓存一份说明它没有做成领域状态列表数据在三个模块里各自拉了一遍说明它缺少统一缓存。2.2 事件协议命名、payload 与错误事件事件总线是 rea 的通知层但如果没有协议约束事件总线会变成另一个垃圾场。我们最初吃过亏A 同学写了个loginSuccessB 同学写了个auth-login-okC 同学又写了个LOGIN_OK三种风格都表达了同一件事但对不上最后只能看代码找人问。rea 里的事件协议有三条硬性要求。第一条事件名统一使用领域:动作:状态三段式。比如登录成功就是auth:login:ok登录失败就是auth:login:error切换项目成功就是project:switch:ok退出登录就是auth:logout:ok。这个格式的好处是按领域可以快速归类按状态可以区分成功和失败路径字符串语义清晰不容易撞名。领域用单数动作用动词状态只用三种ok、error、cancel。不要写auth:user:login:success这种五段式越长越容易拼错。第二条事件名必须集中管理成常量枚举而不是到处写字符串字面量。在 rea 中所有事件名和 payload 类型都放在shared/bus/events.ts一个文件里别的模块永远引用这里的常量。这样编辑器能自动提示重构时能全局搜索到所有引用也避免了脏字符串。下面是一段简化版的事件类型定义// shared/bus/events.ts export type EventMap { auth:login:ok: { userId: string; token: string; source: password | refresh | impersonate; loginAt: number; }; auth:login:error: { code: number; message: string; traceId?: string; }; auth:logout:ok: { userId: string; logoutAt: number; reason?: manual | token-expired; }; project:switch:ok: { projectId: string; previousProjectId: string; }; ui:sidebar:collapse: { collapsed: boolean; }; }; export type EventName keyof EventMap; export type EventPayloadN extends EventName EventMap[N];第三条错误事件不能附带一个裸 message 字符串就完事。因为下游模块收到错误事件后需要区分“是 token 过期还是网络超时”“要不要弹出提示还是静默重试”。所以错误 payload 至少要有code和message最好还有traceId用于排查链路。code可以由前后端约定但前端兜底要有一套401 代表未登录403 代表没权限500 代表服务端异常NETWORK_ERROR代表请求根本没到后端。事件协议确定后跨模块通信就变成了一件很确定的事。新模块想订阅某个事件只需要去events.ts里找到对应事件名然后写监听函数不需要关心是哪个模块发出来的也不需要引入对方的任何代码。2.3 派生状态用 selector 消除手动同步响应式架构最容易写坏的地方就是用副作用去同步状态。比如 A 状态变了我需要在 B 状态里同步一份于是写useEffect(() { setB(getA()); }, [a]);这种代码在 rea 里是禁忌。因为一旦同步关系变多你会在 useEffect 里不断叠加依赖最终导致循环更新、重复渲染、状态不一致。正确思路是让 B 成为一个派生值由 A 通过 selector 计算出来。A 更新后所有依赖 A 的派生值自动重新计算UI 自动刷新不需要手动同步。在 Zustand 中可以用 selector 直接取派生结果。下面以登录状态为例import { useAuthStore } from /features/auth/authStore; // 派生是否已登录 export const useIsAuthenticated () { return useAuthStore((state) state.status authenticated); }; // 派生当前用户是否能访问某个权限点 export const useCanAccess (permission: string) { return useAuthStore((state) state.permissions.includes(permission)); };两个 selector 都在读取同一个 store 里的不同字段。当status或permissions变化时只有真正依赖这些值的组件会触发重新渲染其他无关组件完全不受影响。这就是响应式的好处数据流向是单向的视图由状态派生不需要手动调用更新函数。这里有一个非常容易踩的坑selector 直接返回新对象会导致无限重新渲染。比如// 错误示范每次 selector 执行都返回新的对象引用 const user useAuthStore((state) ({ id: state.userId, name: state.userName, }));React 对比 selector 返回值时用的是Object.is每次执行都生成一个新对象结果永远不相等于是组件被反复渲染甚至可能造成死循环。解决办法是用useShallow或者把 selector 拆成多个基本类型的返回值。Zustand 官方提供的useShallow可以浅比较对象里的字段更适合需要一次取多个字段的场景。rea 的约定是selector 尽量返回基础类型必须返回对象时用useShallow包裹。3. 实操过程与核心环节实现3.1 工程目录结构按 feature 而不是按技术类型划分rea 的目录结构决定了代码归属也决定了模块之间的物理边界。早期项目喜欢按components、pages、utils、hooks这种技术类型分目录结果业务模块被拆得七零八落一个登录功能要跨五个目录找代码。rea 采用按业务能力划分的方式也就是 feature-based 结构。下面是一个简化版的目录树rea-demo/ ├── src/ │ ├── app/ │ │ ├── App.tsx │ │ └── router.tsx │ ├── features/ │ │ ├── auth/ │ │ │ ├── authStore.ts │ │ │ ├── components/ │ │ │ │ ├── LoginForm.tsx │ │ │ │ └── UserAvatar.tsx │ │ │ ├── api.ts │ │ │ └── hooks.ts │ │ ├── project/ │ │ │ ├── projectStore.ts │ │ │ ├── components/ │ │ │ │ └── ProjectSwitcher.tsx │ │ │ ├── api.ts │ │ │ └── hooks.ts │ │ └── dashboard/ │ │ ├── components/ │ │ └── hooks.ts │ └── shared/ │ ├── bus/ │ │ ├── index.ts │ │ └── events.ts │ ├── hooks/ │ │ ├── useBusEvent.ts │ │ └── useShallow.ts │ └── ui/ │ ├── index.ts │ └── components.tsx每个 feature 目录里store、api、组件、hooks 都放在一起。这样想找登录相关代码直接进features/auth就行。shared目录放的是跨 feature 复用的基础能力重点是事件总线和通用 hooks。这里注意shared里不能放业务逻辑比如“用户权限”只能放features/auth不能放shared否则边界又模糊了。这种结构的好处是新增一个业务模块时直接新建一个 feature 目录里面自带 store、api、组件老模块完全不用动。即使某天删除整个 feature也只需要删一个目录。模块之间的依赖方向是单向的feature可以依赖shared但feature之间不能互相 import除非通过对外暴露的接口。跨模块通信走事件总线不直接 import 对方的 store 或组件。3.2 实现一个轻量级可类型推断的事件总线事件总线的实现有两种选择一种是用 mitt另一种是用浏览器原生 EventTarget。我先说 mitt 的版本因为它最简洁。先安装 mittnpm install mitt然后封装一个带类型的总线把events.ts里的 EventMap 传进去// shared/bus/index.ts import mitt from mitt; import type { EventMap } from ./events; const bus mittEventMap(); export function useBus() { return bus; } export function emitEventName extends keyof EventMap( eventName: Name, payload: EventMap[Name] ) { bus.emit(eventName, payload); } export function onEventName extends keyof EventMap( eventName: Name, handler: (payload: EventMap[Name]) void ) { bus.on(eventName, handler); // 返回一个取消订阅函数方便在 useEffect 里直接使用 return () { bus.off(eventName, handler); }; }这样就得到了一个完整的事件总线。关键点在于mittEventMap让事件名和 payload 一一对应写代码时能享受到完整的类型提示。比如谁要是写emitEvent(auth:login:ok, { userId: 1 })TypeScript 会立刻报错因为auth:login:ok的 payload 还需要token和loginAt。这种约束比任何 code review 都有效。再看另一个方案如果你实在不想引入第三方库可以直接用浏览器自带能力// shared/bus/index.ts 使用 EventTarget 的实现 type HandlerT (payload: T) void; class TypedBus extends EventTarget { emitName extends keyof EventMap(eventName: Name, payload: EventMap[Name]) { this.dispatchEvent(new CustomEvent(eventName, { detail: payload })); } onName extends keyof EventMap(eventName: Name, handler: HandlerEventMap[Name]) { this.addEventListener(eventName, (e) { handler((e as CustomEventEventMap[Name]).detail); }); } offName extends keyof EventMap(eventName: Name, handler: HandlerEventMap[Name]) { this.removeEventListener(eventName, (e) { handler((e as CustomEventEventMap[Name]).detail); }); } }EventTarget 的缺点也很明显removeEventListener要求传入的回调函数引用必须和addEventListener时完全一致但上面的封装里on方法内部用箭头函数包了一层导致off永远移除不掉。所以如果没有特殊原因我建议直接用 mitt它省掉了这个坑。如果你坚持原生至少要在on内部保存原始 handler 和包装后的 handler 之间的映射实现会比较繁琐没必要。在实际业务中我们通常不在组件里直接调bus.on而是封装一个useBusEventhook// shared/hooks/useBusEvent.ts import { useEffect } from react; import { onEvent } from /shared/bus; import type { EventMap } from /shared/bus/events; export function useBusEventName extends keyof EventMap( eventName: Name, handler: (payload: EventMap[Name]) void ) { useEffect(() { const unsubscribe onEvent(eventName, handler); return unsubscribe; // 事件名和 handler 都放进依赖避免闭包过期 }, [eventName, handler]); }使用的时候handler 不要直接传匿名箭头函数而是用useCallback包一下否则每次渲染都会重新订阅清理函数会频繁触发。3.3 接入响应式状态登录态模块的完整实现现在把事件总线和 store 串起来完成一个完整的登录模块。以一个典型中后台为例登录接口返回用户 ID、token 和权限列表登录成功以后其他模块通过事件感知到这一变化。先定义 auth store用 Zustand 实现// features/auth/authStore.ts import { create } from zustand; import { emitEvent } from /shared/bus; import { loginApi, type LoginResult } from ./api; type AuthStatus idle | pending | authenticated | unauthenticated; interface AuthState { status: AuthStatus; userId: string | null; token: string | null; permissions: string[]; login: (username: string, password: string) Promisevoid; logout: () void; setContextFromRefresh: (result: LoginResult) void; } export const useAuthStore createAuthState((set, get) ({ status: idle, userId: null, token: null, permissions: [], login: async (username, password) { // 登录开始时先进入 pending if (get().status pending) return; set({ status: pending }); try { const result await loginApi({ username, password }); set({ status: authenticated, userId: result.userId, token: result.token, permissions: result.permissions, }); // 只发通知不带业务处理指令 emitEvent(auth:login:ok, { userId: result.userId, token: result.token, source: password, loginAt: Date.now(), }); } catch (err) { const message err instanceof Error ? err.message : 登录失败; set({ status: unauthenticated }); emitEvent(auth:login:error, { code: 0, message, }); } }, logout: () { const { userId } get(); set({ status: unauthenticated, userId: null, token: null, permissions: [] }); if (userId) { emitEvent(auth:logout:ok, { userId, logoutAt: Date.now(), reason: manual, }); } }, setContextFromRefresh: (result) { set({ status: authenticated, userId: result.userId, token: result.token, permissions: result.permissions, }); }, }));这里有三个设计细节值得展开。第一个是pending状态的防重入。如果用户连续点两次登录按钮第一次请求还没返回第二次又进来会导致两次请求并发后一次覆盖前一次的状态。if (get().status pending) return;可以避免这个问题。外面的按钮也可以根据status pending禁用双保险。第二个是业务逻辑集中在 store 的 action 里组件只负责调用login方法。这样任何组件里都能触发登录但登录的逻辑只有一份。事件也是在 action 里 emit而不是在组件里 emit。这样做的好处是登录动作的来源和时序清楚不会出现“组件 A 里手动 emit 了个登录成功但 store 没更新”这种情况。第三个是事件里只放必要信息不放权限列表。有人可能会问既然事件是通知为什么不把 permissions 也放在 payload 里这样监听者不是省得再去查 store 吗这里要忍住。权限列表属于领域状态应该由 auth store 统一管理如果事件里带一份、store 里又有一份两份数据出现不一致时你根本不知道以谁为准。所以事件只发 ID、token 这类标识权限这种需要频繁变化的完整数据通过 store 的 selector 获取。3.4 跨模块通信实战登录后刷新权限和菜单现在假设系统里有三个模块需要在登录成功后做自己的事权限模块要拉取当前用户可访问的资源菜单模块要根据权限生成侧边栏菜单埋点模块要记录登录来源。如果没有事件总线登录组件要么手动 import 这三个模块的函数要么在业务代码里硬编码调用。有了auth:login:ok事件以后三个模块可以各自独立订阅。权限模块的监听逻辑可以这样写// features/permission/listeners.ts import { useBusEvent } from /shared/hooks/useBusEvent; import { useAuthStore } from /features/auth/authStore; import { fetchUserResources } from ./api; export function usePermissionSyncOnLogin() { const token useAuthStore((state) state.token); useBusEvent(auth:login:ok, async ({ userId }) { // 监听登录成功拉取用户资源 const resources await fetchUserResources(userId, token ?? ); // 更新权限模块自己的 store usePermissionStore.getState().setResources(resources); }); }菜单模块的监听逻辑// features/menu/listeners.ts import { useBusEvent } from /shared/hooks/useBusEvent; import { usePermissionStore } from /features/permission/permissionStore; import { generateMenuByPermissions } from ./menuUtils; export function useMenuSyncOnLogin() { useBusEvent(auth:login:ok, () { // 登录成功后从权限 store 读取已更新的 permissions生成菜单 const permissions usePermissionStore.getState().resources; const menu generateMenuByPermissions(permissions); useMenuStore.getState().setMenu(menu); }); }埋点模块的监听逻辑就更轻了只记录一个行为// shared/analytics/listeners.ts import { useBusEvent } from /shared/hooks/useBusEvent; import { trackEvent } from ./tracker; export function useLoginTrack() { useBusEvent(auth:login:ok, ({ source, userId }) { trackEvent(login_success, { source, userId }); }); }注意看这三个监听器之间没有任何依赖关系。菜单模块不知道权限模块的存在埋点模块也不知道菜单模块的存在。它们都只是听到了同一个消息各自处理自己的事。如果以后想加一个“登录成功后打开工作台首页”的模块只需要再注册一个监听器不用改任何现有代码。这就是事件驱动解耦带来的直接好处。为了防止模块监听器没有被挂载通常会在 feature 的入口组件里统一调用这些useXxxSyncOnLogin方法// app/FeatureListeners.tsx export function FeatureListeners() { usePermissionSyncOnLogin(); useMenuSyncOnLogin(); useLoginTrack(); return null; }然后在 App 根组件里渲染一次整个事件订阅关系就建立起来了。很多项目把监听器写在某一个按页面加载的组件里结果用户切到别的页面时监听失效这种集中注册的方式可以避免漏挂载。3.5 派生状态在跨模块中的使用方式跨模块时会出现一个 store 更新后另一个 store 需要派生更新。比如登录成功后权限 store 拿到资源列表菜单 store 根据权限生成菜单。这个过程在上面的代码里是先写权限 store再从权限 store 里取数据来生成菜单。看起来是串行的如果权限模块拉取资源失败菜单模块就永远不会执行。rea 对这种协同场景有一种更稳妥的做法菜单 store 不从事件 payload 拿数据而是从权限 store 的 selector 里实时读取。具体来说菜单模块可以在组件渲染时根据resources派生出菜单不需要在登录事件里手动“等”另一个模块更新完成。// features/menu/menuStore.ts import { usePermissionStore } from /features/permission/permissionStore; export function useMenuItems() { const resources usePermissionStore((state) state.resources); // 使用计算逻辑派生菜单 const menu useMemo(() generateMenuByPermissions(resources), [resources]); return menu; }这样权限资源和菜单永远保持一致性即使权限更新的时序晚于事件通知UI 也会自动修正。事件在这里的角色从“分配任务”变成了“提醒刷新”真正的数据流还是走响应式 store。这是 rea 里很重要的一条原则事件负责触发更新数据流负责承载数据。4. 常见问题与排查技巧实录4.1 事件风暴和重复请求事件驱动架构最经典的问题就是事件风暴。rea 刚上线时我们遇到过登录成功后一个事件触发了六个模块同时调接口其中三个接口还是同一个数据源直接导致后端压力翻了三倍。原因很简单监听者对同一个业务数据没有共享缓存。拿列表数据来说权限模块要拉一次资源列表菜单模块其实也要拉一次资源列表它们各自写自己的 api 调用自然各自拉一次。解决思路有两个层面。第一个层面事件不能承载数据但可以引导大家去共享 store。把列表数据放到权限 store 里缓存起来菜单模块从同一个 store 读取不重复请求。API 层做请求去重同一个请求在短时间内只发一次后续监听者拿到的是同一个 promise。第二个层面对确实需要独立请求的模块要在事件载荷里加上requestId或者traceId方便在监控里看到同一个事件被多少个下游消费、每个下游花了多久。下面的代码是一个简单的去重函数可以直接放在请求层// shared/api/dedupe.ts const inflightMap new Mapstring, Promiseunknown(); export function dedupeRequestT(key: string, factory: () PromiseT): PromiseT { const pending inflightMap.get(key) as PromiseT | undefined; if (pending) return pending; const promise factory().finally(() { inflightMap.delete(key); }); inflightMap.set(key, promise); return promise; }使用时接口层只要传一个唯一的 key比如project/switch/${projectId}/resources就能保证同一时刻只发一次请求。4.2 事件死循环和重复触发另一个让人头疼的问题是死循环。最典型的一种死循环是这样的A 模块监听eventX在回调里更新了 storestore 的订阅回调又 emit 了一次eventX于是第二次事件触发 A 模块再次更新 store永远停不下来。rea 的做法是在架构上直接禁止 store 订阅里 emit 事件。你可以在 store 的 action 里 emit但绝不可以在subscribe回调里 emit。因为subscribe是响应式的另一条链路它和事件链路互相叠加很容易形成闭环。如果一定要在 store 更新后通知外部也尽量在 action 里主动 emit而不是依赖 store 的subscribe。比如登录 action 里在set({ status: authenticated })之后立即 emit这样事件只会在 action 执行时发出一次不会因为某个组件修改了其他字段而重复触发。再补充一个防御性技巧在接收事件、更新 store 前先判断数据是否真的变化。比如菜单监听登录事件如果 permissions 和前一次相同就直接 return。Zustand 的 set 本身也会做浅比较但如果你 set 的是数组或对象的新引用浅比较挡不住所以业务判断还要写。useBusEvent(auth:login:ok, ({ userId }) { const current useMenuStore.getState().currentUserId; if (current userId) return; // 否则才更新 rebuildMenuByUserId(userId); });4.3 组件卸载后事件回调仍然执行这个坑我们踩得最多具体表现是从列表页跳到详情页列表页已经卸载了但列表页注册的 bus 回调还在执行并且试图 setState控制台报出“Cant perform a React state update on an unmounted component”的警告。根源在于bus.on注册回调不会随着组件卸载自动移除React 只负责清理 useEffect 里的副作用。如果你在 useEffect 里调用了bus.on却没有在 cleanup 里调bus.off组件卸载后回调一定还会执行。正确写法应该是这样的useEffect(() { const handler (payload: EventMap[project:switch:ok]) { // 更新本地状态 }; return onEvent(project:switch:ok, handler); // onEvent 返回的是一个取消订阅函数useEffect 会在组件卸载时调用它 }, []);这里尤其要注意handler必须是被onEvent注册的那个引用。如果onEvent内部对 handler 做了一层包装那off时要传同一份包装后的引用。前面提到的useBusEventhook 把这层复杂度封装好了你在业务组件里只需要保证传入useBusEvent的 handler 是用useCallback稳定的即可。否则每次渲染都传入新引用事件会反复订阅再卸载订阅逻辑上可能没错但性能不好。4.4 常见问题速查表症状可能原因定位方法解决方案同一个接口被请求多次多个监听者各自调用同名接口在请求层打印调用栈检查调用来源用请求去重函数合并并发请求事件触发顺序不确定多个监听者订阅同一个事件执行顺序依赖注册顺序在事件总线中给每个事件记录订阅列表约定不依赖监听顺序数据通过 store 读取页面卡顿事件高频触发滚轮、resize、输入事件被直接转发到总线打印事件频率统计每分钟事件数对高频事件做节流/throttle合并为一次通知组件卸载后仍执行回调没有清理 onEvent 的订阅在回调里打点看组件卸载后是否有日志使用 useBusEvent hook确保 useEffect cleanup事件名拼写错误导致监听失效字符串字面量分散各处全局搜索事件名比对大小写只用 events.ts 里的常量枚举store 更新后 UI 不刷新selector 返回了新对象引用检查 selector 是否每次返回新数组或对象用 useShallow 或拆分 selector4.5 一个定位事件问题的实用技巧事件驱动架构排查起来比普通函数调用链难因为你不知道一个事件到底有没有触发、由谁触发、被谁消费。我们后来在事件总线上加了一层非常简单的调试中间件解决了大部分定位问题。在 mitt 外面包一层 emit 日志开发环境打印事件名和 payload生产环境关闭。代码类似这样// shared/bus/index.ts 带调试日志的版本 import mitt from mitt; import type { EventMap } from ./events; const bus mittEventMap(); const debug import.meta.env.DEV; export function emitEventName extends keyof EventMap( eventName: Name, payload: EventMap[Name] ) { if (debug) { console.log([bus:emit] ${eventName}, payload); } bus.emit(eventName, payload); } export function onEventName extends keyof EventMap( eventName: Name, handler: (payload: EventMap[Name]) void ) { const wrapped (data: EventMap[Name]) { if (debug) { console.log([bus:on] ${eventName}, data); } handler(data); }; bus.on(eventName, wrapped as (payload: EventMap[Name]) void); return () bus.off(eventName, wrapped as (payload: EventMap[Name]) void); }这个包装要小心用 mitt 的on注册wrapped取消订阅时也要传wrapped否则移除不掉。有了这个日志你就能在控制台按时间线看到事件从哪一秒开始发出哪个模块订阅了它payload 是否符合预期。我们在一次排查重复请求问题时就是靠这个日志发现菜单模块和权限模块在同一个事件里发了同一个请求然后才加了请求去重。5. 工程化配套rea 的可落地扩展5.1 事件类型登记一处管理处处受益前面已经提过所有事件全部集中到events.ts。这个文件在 rea 里不只是类型定义它本身就是一份“事件契约”文档。新同事入职想了解系统里有哪些全局通知直接打开这个文件就能看个大概。为了让这个文件更好用可以再加两个约定。第一个事件名的常量枚举和类型定义放一起其他模块导入时同时拿到名称和 payload 类型。第二个每个事件下方写一行注释说明这个事件的触发场景和主要消费者形成一份轻量的文档。// shared/bus/events.ts export type EventMap { /** * 用户登录成功 * 触发authStore.login * 消费permission/menu/analytics */ auth:login:ok: { userId: string; token: string; source: password | refresh | impersonate; loginAt: number; }; // ... };这样集中管理后后续加新事件只需要在这个文件里加一行类型定义然后全局注册。代码评审时也只需要重点看这个文件的变更所有跨模块通信的改动一览无余。5.2 单元测试策略事件总线和 store 分开测rea 的架构让测试变得很直接。事件总线是纯发布订阅可以独立测试store 的 action 可以模拟 API 返回值断言状态和事件是否触发。用 Vitest 举一个 store 测试的例子import { describe, it, expect, vi, beforeEach } from vitest; import { useAuthStore } from /features/auth/authStore; import { loginApi } from /features/auth/api; import { bus } from /shared/bus; vi.mock(/features/auth/api, () ({ loginApi: vi.fn(), })); describe(authStore.login, () { beforeEach(() { useAuthStore.setState({ status: idle, userId: null, token: null, permissions: [], }); }); it(登录成功后更新状态并发出 auth:login:ok, async () { const emitSpy vi.spyOn(bus, emit); vi.mocked(loginApi).mockResolvedValue({ userId: u1, token: token-1, permissions: [dashboard:view], }); await useAuthStore.getState().login(admin, 123456); const state useAuthStore.getState(); expect(state.status).toBe(authenticated); expect(state.userId).toBe(u1); expect(emitSpy).toHaveBeenCalledWith(auth:login:ok, expect.objectContaining({ userId: u1, })); }); });测试的重点是action 是否把状态改对了、事件是否按预期发出。这是整个架构行为的最小单元。下游模块的监听逻辑则可以通过直接bus.emit来模拟事件断言模块自身是否执行了对应逻辑。事件总线和 store 解耦之后测试的隔离性很好不会互相污染。5.3 性能统计与可观测性事件总线越用越多以后有必要统计每个事件的触发频率和耗时。我们在 rea 的事件总线上加了一个统计中间件用performance.now()记录每个事件从 emit 到所有监听者执行完的耗时。这里给出一个简化的统计实现// shared/bus/metrics.ts const metrics new Mapstring, { count: number; totalTime: number }(); export function recordEventDuration(eventName: string, duration: number) { const current metrics.get(eventName) || { count: 0, totalTime: 0 }; metrics.set(eventName, { count: current.count 1, totalTime: current.totalTime duration, }); } export function getEventMetrics() { return [...metrics.entries()].map(([name, value]) ({ name, count: value.count, avgDuration: value.totalTime / value.count, })); }然后在 emit 时包一层const start performance.now(); bus.emit(eventName, payload); recordEventDuration(eventName, performance.now() - start);统计出来的数据可以接入埋点系统也可以只放在内存里通过一个 debug 面板查看。如果某个事件每秒触发几百次那大概率是用法不对需要节流或者重新设计事件粒度。比如 “当前输入框内容变化”这种高频事件不应该每次 keystroke 都 emit 一个全局事件应该用组件内部状态或者至少做 300ms 节流。5.4 从 rea 到团队规范rea 最终沉淀成了一套团队内部的架构规范而不是一个只能某个项目用的黑盒。规范文档里除了状态分层、事件命名还列了明确禁止的做法禁止在subscribe里 emit 事件禁止用字符串字面量作为事件名禁止组件直接 import 另一个 feature 的 store 并修改禁止在事件 payload 中塞完整领域对象。这套规范还影响到了脚手架。我们做了一个基于 Vite 的自定义模板新项目创建时自动生成features/shared/app目录、事件总线、store 示例和 README。新同学进来以后先看 README 里的架构说明再跑一个登录模块的 demo基本上一天以内就能按规范写代码。脚手架里还配了一条 ESLint 规则不允许 import 路径中出现跨 feature 的深层引用比如features/menu/store被features/dashboard直接引。跨模块通信只允许走 bus这个约束让架构边界在 CI 阶段就被守住而不是靠 reviewer 肉眼检查。最后分享一个我实践下来最大的体会rea 不是让你去发明一套新框架而是把已经存在的好实践收拢成一套可执行的规矩。事件驱动解决了解耦但如果不限制事件的触发位置和命名方式系统会变成一张蜘蛛网状态分层解决了混乱但如果不坚持“能放组件就不放 store”store 会变成垃圾桶。我会在每个事件名上加上领域和状态前缀用常量收拢在每一次 set 之前先判断值是否真的变了。如果你也在搭类似的架构建议不要急着设计很复杂的事件流先从一个登录模块开始把 store、bus、类型这三样跑通再逐步加监听者。这个模式对后端转前端的同事特别友好事件语义清晰数据流也容易解释后续加新模块时非常稳。
RELATED

相关推荐

大模型本地部署实战指南:从硬件选型到性能调优

大模型本地部署实战指南:从硬件选型到性能调优

你有没有遇到过这种时刻:手里的对话式AI突然响应很慢,或者某天早上打开后台发现API额度又超了,而内部数据根本不敢传到别人服务器上。我当时决定把大模型本地部署到自己机器上,倒不是追逐什么技术潮流,纯粹是项目需要—…

📅 2026/10/18 19:54:25
从权重转换到推理优化:OpenVINO落地MiniCPM-o-4.5全模态模型

从权重转换到推理优化:OpenVINO落地MiniCPM-o-4.5全模态模型

这几天在本地折腾全模态模型部署,一开始还是按老套路来:先把 PyTorch 权重拉下来,写个 Python 脚本跑通,再慢慢抠显存优化。等到真把 MiniCPM-o-4.5 和 OpenVINO 拼在一起,才发现端侧部署这件事比想象中省心&#xff0…

📅 2026/10/18 19:54:25
微信小程序个人日程安排系统毕业设计资源包实战指南

微信小程序个人日程安排系统毕业设计资源包实战指南

简介:面向计算机专业学生,这是一份微信小程序个人日程安排系统的毕业设计完整项目,采用微信小程序开发工具搭配Java后端与MySQL数据库,实现了个人中心、用户管理、重要日管理、工作日程管理、会面管理、用餐管理、日程管理及系统管…

📅 2026/10/18 19:54:25
MORE NEWS

更多资讯

📰

水稻害虫目标检测数据集清洗与增强实战指南

简介:本资源是面向农业AI开发者与植保科研人员的水稻害虫目标检测专用数据集,聚焦智能监测、精准施药与绿色防控等实际生产痛点。数据集覆盖12类典型水稻害虫,含训练/验证/测试三阶段共1481张实景田间图像(jpg)及对应Y…

📰

Teamcenter管理员实战:TC11/12/13版本差异、环境初始化与权限管理

简介:面向Teamcenter系统管理员的完整操作手册(覆盖11、12、13版本),专为PLM系统运维与实施人员编写,解决系统配置、流程定制、数据管理及日常维护等问题。手册从管理员职责讲起,详细梳理服务器端配置操作&…

📰

AI行业动态的四大硬核信号:技术栈、合规、交付与资本

1. 这份“AI技术资讯”到底该看什么、怎么用——一个从业十年的观察视角很多人点开标题叫《AI技术资讯丨人工智能应用行业动态02月14日》的推送,第一反应是:又是一篇信息过载的“热词拼盘”。扫一眼“多模态”“Agent”“Sora迭代”“端侧推理加速”&…

📰

Agent记忆系统写入时刻的四个关键决策:为什么存和取不是瓶颈

1. 为什么"存"和"取"从来不是记忆系统的真正瓶颈做Agent记忆系统的人,十有八九会把精力砸在两个地方:向量库选哪个、检索召回率怎么调。我早期也是这样,花了两周对比不同向量数据库的写入吞吐和查询延迟,又花…

📰

顺风车小程序源码包V1.1.9:竞价撮合与城市调度核心拆解

简介:滴滴顺风车小程序V1.1.9完整源码包基于出行场景开发,面向需要快速搭建或二次开发顺风车平台的开发者、个人站长及中小团队。资源整合微信小程序前端与PHP后端,覆盖乘客与车主匹配、实时竞价、一键叫车、行程发布接单等核心环节&#xff…

📰

技术文档写作规范:如何避免虚构项目误导读者

我无法根据当前输入生成符合要求的博文。原因如下:项目标题“Octop”缺乏明确指向:该词在主流技术生态中无广泛公认的开源项目、工具、库或框架对应。经核查 PyPI、GitHub、MIT 开源仓库、Ruff 官方文档及 Python 生态主流资源,不存在名为oct…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬