做中后台系统久了一定碰过这种尴尬用户的角色权限在后台被管理员改掉了前端页面却还停留在旧权限视图里。要么强迫用户重新登录要么在每个页面专门塞一个“手动刷新”按钮还得小心翼翼地记着哪些页面需要联动刷新。后来我在 React 项目里把状态管理切到 Zustand顺手把“状态驱动刷新”这套自动监听方案沉淀了下来用一个自定义 Middleware 统一接管“哪些状态变了、哪些数据要重新拉”。权限切换、语言切换、地区切换这类脏活再也不用散落在各个页面里手写 eventBus 或者层层传参了。这篇文章就把这套方案从原理到落地完整拆一遍。1. 状态驱动刷新到底解决什么问题1.1 传统手动刷新方案的三个痛点先聊一个真实的业务场景后台管理系统里运营同学把某个用户的角色从“普通运营”升级成了“区域管理员”。这时候前端要发生什么用户的菜单得更新订单列表要按新的数据范围重新拉取首页的工作台统计要刷新甚至某些按钮的可见性都要立刻变化。大多数项目的传统做法是登录后或者角色变更后写一个refresh()然后在路由切换、页面 mount 的时候手动调用各业务模块的接口。听起来不复杂但实际项目里很容易踩进三个坑。第一刷新逻辑散落。每个页面都要判断“我这个页面到底需不需要因为角色变化而刷新”时间一长刷新逻辑就会复制粘贴得到处都是。A 页面记得接了B 页面忘了这周上线前测试发现漏了一个下周改需求又漏一个。第二事件总线是隐式依赖。有人喜欢用一个window.eventBus.emit(role:changed)然后各页面on(role:changed)去拉数据。问题在于事件总线的发布和订阅是两端独立的找一个“到底谁在监听这个事件”得全局搜索漏发漏收都很难排查。业务链路越长这种隐式依赖越让人头大。第三组件树状态传递太重。如果你的状态放在某个 Page 组件的 state 里角色一变就要通过 props 一层层往下传刷新函数或者干脆用一个 Context 包住整个系统。层级浅还行层级一深子组件想触发一次刷新就得顺着 props 链路找半天。这三个痛点背后其实是同一个问题刷新时机和数据源绑定的太死。页面要被动等别人告诉自己“你该刷新了”而不是主动感知“我依赖的状态变了我自己刷新”。1.2 状态驱动刷新把“刷新”变成状态的副作用状态驱动刷新的思路很直白修一个类比你就明白了。传统刷新方案像是你在厨房等一壶水烧开但你自己不知道水什么时候开只能时不时跑过去看一眼。状态驱动刷新则是给水壶装了一个感应器水一开感应器自动把火关了。对应到前端工程里核心思路是这样把“业务数据该刷新了”定义成某个关键状态变化的副作用。角色变了订单数据跟着重新拉地区切换了库存和报表跟着重新拉语言环境变了整个 UI 文案跟着重新渲染。这套方案选 Zustand 来做原因是很自然的Zustand 本身就是轻量可控的全局状态库而且它的 Middleware 机制能让我在状态变化时插入统一的、跨组件的响应逻辑。对比用 React Hook 在每个组件里 useEffectZustand Middleware 把监听逻辑提升到了 store 层不受组件生命周期影响也不用重复写订阅代码。这个思路的关键点是状态本身是数据状态变化是信号页面刷新是信号驱动的动作。把这三件事在 store 层统一描述刷新的问题就有了一个收敛的解法。2. Zustand Middleware 基础这套方案的三个概念支柱2.1 Zustand 的 set、get、subscribe 三件套要用 Middleware 做自动监听先得把 Zustand 最基础的三个能力搞透。第一是set用来修改状态既可以直接传一个部分对象set({ role: admin })也可以传函数set(state ({ count: state.count 1 }))。第二是get用来读当前状态。第三是api.subscribe用来监听状态变化。一个容易忽略的细节是Zustand 在 v4 之后的subscribe监听回调是能拿到两个参数的(nextState, prevState)。也就是说你在任何地方调用store.subscribe都能拿到变化前后的两个完整状态对象。subscribeWithSelector这个官方 Middleware 则是进一步增强允许你订阅一个具体的 selector比如state.role并且监听回调里拿到的是 selector 前后两次的值。这两个能力合在一起其实已经凑齐了“自动监听”的底子。剩下的问题就是怎么让监听逻辑可以被复用、被统一管理而不是在每个业务文件里复制粘贴订阅代码。这时候就轮到 Middleware 上场了。2.2 Middleware 到底怎么工作很多人看到 Middleware 就觉得玄乎其实剥开看就是一个高阶函数包装。官方文档里最常见的例子是 log 中间件const log (config) (set, get, api) config( (...args) { console.log(变更前, get()); set(...args); console.log(变更后, get()); }, get, api );这个函数接收一个config也就是你写 store 的那个初始化函数然后返回一个新的config。Zustand 在 create 的时候执行这个新config并且把set换成了你包装过的版本。这样你在业务代码里调用set时会先走到中间件的包装逻辑里可以在真正修改状态之前或之后插入自己的代码。对于我们要做的自动刷新方案Middleware 的作用就是在 store 初始化时注册一批监听规则之后任何set引发的状态变化都会被这些规则检查。规则发现关键字段变了就触发对应的刷新动作。这里还要区分一下两种实现思路。一种是上面这种“包装 set”的思路状态一改中间件立刻同步执行检查逻辑。另一种是“订阅注册”的思路store 初始化完成之后通过api.subscribe注册一批监听器。两种思路各有利弊包装 set 能拿到精确的变更触发点但要求所有改状态的操作都必须走set订阅注册更接近 Zustand 的原生机制并且天然能拿到nextState和prevState代码也更简洁。实际操作中订阅注册的方式我用的更多。2.3 为什么不用 useEffect 组件级监听有人可能会问我直接在组件里写useEffect(() { subscribe(...) }, [role])不就行了确实能行但有几个很现实的问题。首先useEffect 的依赖数组监听的是当前组件的渲染可见值如果多个组件要响应同一个状态变化每个组件都要写一份 useEffect。这个还好更麻烦的是如果你的刷新逻辑不在组件树里比如一个独立的请求模块、一个工具函数、一个路由守卫useEffect 就有点使不上劲。其次组件卸载了监听就没了。很多时候这确实是想要的效果但有些全局刷新逻辑我们希望它是“常驻”的不依赖某个页面挂载。比如权限变更后要刷新菜单配置菜单配置不在任何页面内页面卸载了逻辑也得执行。Middleware 方案把监听逻辑放在 store 定义层和组件彻底解耦。它只问一个问题状态变了没有变了就执行规则。不看组件脸色也不需要找挂载点。全局性、复用性、推导性都更好。3. 自动监听方案完整实现可复用的 autoRefreshMiddleware3.1 核心设计把刷新规则做成数据做自动监听的第一个设计决策是定义刷新规则的结构。我把它设计成一个数组每条规则包含三个要素select函数负责从整个 state 里选出关心的字段onChange函数在字段变化后被调用fireImmediately表示 store 创建后是否立即执行一次刷新手势逻辑。规则数组化有什么好处一是规则可以被声明式描述新增一个联动场景就是往里加一条不需要动业务代码二是规则可以被集中审查打开 store 文件整个系统里“谁依赖谁刷新”一目了然三是规则本身是纯数据将来还可以做成配置下发按权限动态启停某几条规则。3.2 核心实现一个 40 行不到的 autoRefreshMiddleware直接给完整代码这是这套方案的核心部分import { create, type StateCreator, type StoreApi } from zustand; type RefreshRuleT { /** 从 state 里取出你要关心的字段 */ select: (state: T) unknown; /** 字段变化后要执行的动作 */ onChange: (nextState: T, prevState: T) void; /** 是否在 store 创建后立即执行一次默认 false */ fireImmediately?: boolean; }; function autoRefreshT(rules: RefreshRuleT[]) { return function (config: StateCreatorT, [], []): StateCreatorT, [], [] { return function (set, get, api) { // 先执行原有的 store 初始化 const state config(set, get, api); // 注册刷新规则 for (const rule of rules) { const fireImmediately rule.fireImmediately ?? false; api.subscribe((nextState, prevState) { const nextSelected rule.select(nextState); const prevSelected rule.select(prevState); if (Object.is(nextSelected, prevSelected)) return; rule.onChange(nextState, prevState); }); if (fireImmediately) { const current api.getState(); rule.onChange(current, current); } } return state; }; }; }注意几个细节。第一个细节是Object.is比较这一步相当于把整个应用的所有状态变化都筛一遍但只有select片段发生变化时才会触发动作。它保证了onChange不会被无关的状态改动反复触发比如你改了一个loading字段订单数据的规则不会莫名被触发。第二个细节是api.subscribe的时机。这段代码执行在 store 初始化之后set函数已经可以正常使用所以任何业务代码调用set修改状态都会被订阅回调感知到。并且 Zustand 的 subscribe 本身就是同步触发的状态一变规则立刻执行没有异步延迟的模糊感。3.3 业务场景一权限变更后自动刷新订单列表理论讲完看一个能直接用的例子。假设系统里有一个全局 store负责当前登录用户的角色和订单列表interface AppState { currentRole: string; setRole: (role: string) void; orderList: string[]; loadOrders: () Promisevoid; } const useAppStore createAppState()( autoRefreshAppState([ { select: (s) s.currentRole, onChange: (next) { void next.loadOrders(); }, }, ])((set, get) ({ currentRole: guest, orderList: [], setRole: (role) set({ currentRole: role }), loadOrders: async () { const { currentRole } get(); // 这里省略实际接口请求按角色拿不同范围的数据 const orderList await fetchOrdersByRole(currentRole); set({ orderList }); }, })) );这里最关键的逻辑是onChange里调用的是next.loadOrders()而loadOrders最后会执行set({ orderList })。这个orderList的变化并不会让订阅回调再次执行因为规则只 select 了currentRoleObject.is对比发现角色没变直接 return。所以天然不会死循环。实际使用中权限变更的入口很可能是登录时或者用户信息接口返回后。你不需要在业务代码里写一句“角色变了去刷新订单”只需要调用setRole(newRole)中间件自动帮你把刷新动作接上。这就是状态驱动刷新最直观的价值业务代码只管改状态刷新是状态的副作用。3.4 业务场景二多模块联动刷新与白名单控制第二个场景是交互式大屏或者多标签工作台里常见的问题订单模块、库存模块、客户模块各自持有数据它们都依赖同一个地区参数。运营把排行榜的筛选地区从“华东”切到“华南”所有模块都要随之重新拉数据。传统做法是每个模块的容器组件写一个监听地区变化的 useEffect或者在切地区的函数里手动调用三个模块的刷新方法。前者容易漏组件后者把刷新时机和页面结构耦合死了。用 autoRefresh 方案就能把联动规则集中写成白名单const useDashboardStore createDashboardState()( autoRefreshDashboardState([ { select: (s) s.region, onChange: (next) { // 白名单里写明哪些模块要联动刷新 const whiteList [orders, inventory, customers]; whiteList.forEach((module) next.refreshModule(module)); }, }, ])((set, get) ({ region: 华东, setRegion: (region) set({ region }), refreshModule: async (module) { // 按模块名分派请求更新对应模块的数据 const data await fetchDashboardModule(module, get().region); set({ [module Data]: data }); }, })) );这套写法的好处是以后新增一个也要跟着地区变化的模块只要往白名单里加一个名字就行。模块的刷新逻辑和数据存储都在 store 内统一管理组件只负责调用setRegion并展示数据。组件不知道也不关心其他模块会不会跟着刷新逻辑收敛到了 store 层排查问题的时候只需要看一处。3.5 与官方 Middleware 组合persist 同步场景如果你想把自动化刷新扩展到跨标签页同步可以把它和官方persistMiddleware 组合使用。比如用户设置存在 localStorage另一个标签页里改了配置当前标签页需要感知到配置变化并刷新数据。组合方式是这样的const useConfigStore createConfigState()( persist( (set, get) ({ language: zh-CN, region: 华东, setLanguage: (language) set({ language }), }), { name: user-config } ) );配合storage事件监听另一个标签页更新了 localStorage这边把 store 的状态重新 hydrate而 region 变化又会命中 autoRefresh 的规则自动触发数据刷新。这种场景里Middleware 的监听逻辑和持久化逻辑是正交的可以叠加组合互不干扰。4. 进阶调优防抖、去重与防死循环三板斧4.1 高频状态变化下的防抖策略第一个要面对的问题是系统狼来了。如果你的刷新规则监听的是一个频率很高的字段比如拖拽调整的筛选条件、实时输入的搜索关键词每次变化都直接拉接口会把后端打爆。这种场景必须引入防抖。在规则层做防抖思路是在 autoRefreshMiddleware 内部包一层 debounce。对onChange动作执行防抖而不是对规则本身防抖因为防抖需要的是“最后一次变化停止后再执行动作”而select负责判断字段是否真的变了两者职责必须分开。给规则结构加两个可选字段debounce毫秒数和maxWait最大等待时间。实现起来也不复杂用闭包缓存 timer 和上一次状态function createDebouncedRuleT(rule: RefreshRuleT, delay 300) { let timer: ReturnTypetypeof setTimeout | undefined; return { select: rule.select, onChange: (next: T, prev: T) { clearTimeout(timer); timer setTimeout(() rule.onChange(next, prev), delay); }, fireImmediately: rule.fireImmediately, }; }我在实际项目里的经验是防抖值不追求“标准答案”要看你操作的粒度。下拉切换地区这种低频操作防抖 0 或 100ms 就够输入框实时搜索300~500ms 体感比较合适拖动画布或者滚动触底的加载甚至可以到 800ms。不要把防抖做成固定的全局值给每条规则单独配置数据流的语义会更精确。4.2 去重与脏数据防护别让重复刷新折磨用户高频状态下还有另一个坑重复刷新。哪怕加了防抖用户连续快速切换三次地区最后一次防抖过了前面两次的请求可能还没返回接口返回顺序乱掉最后一个响应覆盖了前面的数据界面上出现“显示华南数据但标题写着华东”的脏状态。解决方案通常有两种。一是请求层面的竞态处理在每个刷新函数内部用一个自增版本号或者把 AbortController 传给 fetch组件卸载或者新请求发出时取消旧请求。二是状态层面的去重给关键字段加一个revision版本号Middleware 里在触发动作前比较版本号只有版本号是新的时候才真正执行。实际业务里这两种手段不是单选题。比如订单列表这块数据我一般会让loadOrders函数内部带一个请求自增标记保证响应顺序不会错乱而跨模块的联调刷新用版本号判断能避免多个模块重复触发。你可以把版本号也放进 store规则里 select 它这样每次刷新动作都会被自然管理。4.3 避免刷新死循环的两种典型情况死循环是这套方案里最需要警惕的坑因为报错不会提示你只会表现为接口无限请求或者页面卡死。最典型的死循环模式是onChange 里又 set 了 select 所选的字段本身。比如你 select 了s.region然后在 onChange 里写了set({ region: normalizeRegion(region) })。normalizeRegion返回的新值每次都是新引用Object.is 发现前后不同又触发一次 onChange又 set 一次于是变成无限循环。要防住这个问题核心准则就一句话onChange 里不要写同一个 key set 逻辑如果一定要修正先判断修正后值是否和当前值相等相等就直接 return。保险起见还可以在 autoRefreshMiddleware 内加一次节流护栏同一规则在 500ms 内只允许触发一次。把这层护栏写进基础中间件能掩盖很多业务代码的潜在问题。4.4 用 devtools 和日志观察刷新链路方案上线后免不了要排查问题。我强烈建议你在 autoRefreshMiddleware 里加一个debug开关开启后把每次触发规则的变化源、前后值都打印出来function autoRefreshT(rules: RefreshRuleT[], options: { debug?: boolean } {}) { return (config: StateCreatorT, [], []) (set, get, api) { const state config(set, get, api); for (const rule of rules) { api.subscribe((nextState, prevState) { const nextSelected rule.select(nextState); const prevSelected rule.select(prevState); if (Object.is(nextSelected, prevSelected)) return; if (options.debug) { console.info([autoRefresh] rule fired, { nextSelected, prevSelected, nextState, prevState, }); } rule.onChange(nextState, prevState); }); } return state; }; }再搭配官方devtoolsMiddleware你就能在 Redux DevTools 里看清每次 set 的来源和产生的刷新动作。这套组合拳基本覆盖了绝大部分刷新型问题的排查场景。5. 常见问题与排查技巧实录5.1 中间件不生效先检查两件事有朋友照我的代码抄回去发现 set 之后规则根本没触发。我排查过几次这类问题基本都出在两件事上。第一create 的组合括号有没有写对。类型齐全的写法是createT()(autoRefresh(rules)((set, get) ({...})))注意createT()后面必须跟着一对调用括号T 是整棵状态树。如果你把 T 写成了某个局部类型或者中间件的括号少了一层TypeScript 编译期可能不报错但运行时 Middleware 根本没进到包装逻辑里。第二规则数组是不是真的传进去了。最常见的情况是写了一个 autoRefresh([]) 的空数组然后在外层页面用 useEffect 再手动注册订阅。这不是中间件的锅是你自己把规则绕开了。正确的做法是让中间件从创建那一刻起就持有完整 rules不要在外部二次注册。5.2 刷新时序对不上中间件同步触发 vs React 批量更新另一个高频问题是时序错乱。比如权限变更时用户希望先看到 loading 再出现新数据但实际表现经常是数据突然跳变loading 闪现不出来。原因在 Zustand 的订阅机制和 React 18 批量更新的交互。Zustand 的 subscribe 回调本身是同步执行的而 React 的 setState 会做批处理合并渲染。你在中间件里先set({ role: admin })紧接着set({ loading: true })再触发loadOrders()异步请求React 可能把前几个 set 合并成一次渲染loading 瞬间就被覆盖掉了。解决思路把加载态的变更延到下一个宏任务或者用queueMicrotask/setTimeout(0)包一下刷新动作。实际项目里我一般优先保证数据正确性loading 交给组件加载态组件自行判断不在全局刷新的路径里纠结。5.3 多页面重复注册导致的重复请求还有一个很隐蔽的坑我踩过一次。项目里某个模块的数据页需要响应地区变化我最初图省事在页面的 useEffect 里直接写store.subscribe(selector, cb)没注意到这个页面被同时打开了两个实例标签页、弹窗里的内嵌页于是订阅注册了两次一次地区切换触发出两次同样的请求重复拉数据。这类问题的根源依旧是“把监听逻辑放回了组件层”。如果所有监听都在 Middleware 层注册store 创建时只注册一次就不会出现多个页面实例各自注册的问题。组件再需要局部响应也应该在组件里使用一个统一的 hooks 封装比如useRefreshRule(rules)内部管理订阅的注册与清理避免裸调 subscribe。5.4 订阅的清理与内存泄漏最后说一个所有订阅方案都会踩的边store.subscribe的返回值是一个取消订阅函数。你在中间件里注册订阅时store 生命周期和页面一样长这个订阅本来就是常驻的不需要清理。但如果你在组件里使用了任何手动 subscribe务必在 useEffect cleanup 里调用返回的 unsubscribe 函数。一个很典型的隐患是页面切走又切回来useEffect 重新执行订阅叠加规则执行的次数跟着页面切换次数线性增长。表现就是页面越用越卡接口请求越来越多。遇到这种情况先检查是不是 subscribe 没解绑八九不离十。写完这套方案之后我实际的感受这套方案在我项目里跑了大半年中间迭代过好几轮。最大的体会是状态驱动刷新不是银弹它的收益建立在两个前置条件上——第一你愿意把刷新时机这类“横切关注点”收敛到 store 层去管理而不是继续散落在各个组件第二你的团队能接受“状态变化本身就是一个可编程信号”的编程心智而不是把 set 单纯当成改数据的工具。另外提醒一句不要为了用中间件而用中间件。如果项目里只有一两个联动刷新场景直接在业务代码里调用刷新函数可能更直白。当联动的模块开始变多、分布变广、规则开始像蛛网一样交错的时候再引入这套自动监听的中间件方案收益才是最高峰。最后分享一个小技巧。我后来在 autoRefreshMiddleware 的规则里增加了一个desc描述字段专门记录每条规则是干什么的比如“地区切换后刷新订单/库存/客户”。上线后出问题打开日志控制台直接打印规则描述不用去翻业务代码就能秒懂当前刷新链路。这个细节帮我们省掉了很多排查沟通的时间建议你抄走。
