做中后台系统久了,一定碰过这种尴尬:用户的角色权限在后台被管理员改掉了,前端页面却还停留在旧权限视图里。要么强迫用户重新登录,要么在每个页面专门塞一个“手动刷新”按钮,还得小心翼翼地记着哪些页面需要联动刷新。后来我在 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 在每个组件里 useEffect,Zustand 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. 自动监听方案完整实现:可复用的 autoRefreshMiddleware
3.1 核心设计:把刷新规则做成数据
做自动监听的第一个设计决策是定义刷新规则的结构。我把它设计成一个数组,每条规则包含三个要素:select函数负责从整个 state 里选出关心的字段,onChange函数在字段变化后被调用,fireImmediately表示 store 创建后是否立即执行一次刷新手势逻辑。
规则数组化有什么好处?一是规则可以被声明式描述,新增一个联动场景就是往里加一条,不需要动业务代码;二是规则可以被集中审查,打开 store 文件,整个系统里“谁依赖谁刷新”一目了然;三是规则本身是纯数据,将来还可以做成配置下发,按权限动态启停某几条规则。
3.2 核心实现:一个 40 行不到的 autoRefreshMiddleware
直接给完整代码,这是这套方案的核心部分:
import { create, type StateCreator, type StoreApi } from "zustand"; type RefreshRule<T> = { /** 从 state 里取出你要关心的字段 */ select: (state: T) => unknown; /** 字段变化后要执行的动作 */ onChange: (nextState: T, prevState: T) => void; /** 是否在 store 创建后立即执行一次,默认 false */ fireImmediately?: boolean; }; function autoRefresh<T>(rules: RefreshRule<T>[]) { return function (config: StateCreator<T, [], []>): StateCreator<T, [], []> { 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: () => Promise<void>; } const useAppStore = create<AppState>()( autoRefresh<AppState>([ { 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 了currentRole,Object.is对比发现角色没变,直接 return。所以天然不会死循环。
实际使用中,权限变更的入口很可能是登录时或者用户信息接口返回后。你不需要在业务代码里写一句“角色变了,去刷新订单”,只需要调用setRole(newRole),中间件自动帮你把刷新动作接上。这就是状态驱动刷新最直观的价值:业务代码只管改状态,刷新是状态的副作用。
3.4 业务场景二:多模块联动刷新与白名单控制
第二个场景是交互式大屏或者多标签工作台里常见的问题:订单模块、库存模块、客户模块各自持有数据,它们都依赖同一个地区参数。运营把排行榜的筛选地区从“华东”切到“华南”,所有模块都要随之重新拉数据。
传统做法是每个模块的容器组件写一个监听地区变化的 useEffect,或者在切地区的函数里手动调用三个模块的刷新方法。前者容易漏组件,后者把刷新时机和页面结构耦合死了。
用 autoRefresh 方案就能把联动规则集中写成白名单:
const useDashboardStore = create<DashboardState>()( autoRefresh<DashboardState>([ { 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 = create<ConfigState>()( 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 createDebouncedRule<T>(rule: RefreshRule<T>, delay = 300) { let timer: ReturnType<typeof 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 autoRefresh<T>(rules: RefreshRule<T>[], options: { debug?: boolean } = {}) { return (config: StateCreator<T, [], []>) => (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 的组合括号有没有写对。类型齐全的写法是create<T>()(autoRefresh(rules)((set, get) => ({...}))),注意create<T>()后面必须跟着一对调用括号,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描述字段,专门记录每条规则是干什么的,比如“地区切换后刷新订单/库存/客户”。上线后出问题,打开日志,控制台直接打印规则描述,不用去翻业务代码就能秒懂当前刷新链路。这个细节,帮我们省掉了很多排查沟通的时间,建议你抄走。