Zustand 中间件实战:用状态驱动刷新架构告别手动拉数据
先摆个结论Zustand 在 React 状态管理里算是最省心的一档但很多人用了一年还只是停留在useStore取数据、setState改数据这个层面。真正让 Zustand 发挥威力的是它的 Middleware 机制——配合订阅和中间件你可以做出一套完全由状态驱动刷新的架构让页面在数据变化时自动更新业务代码里不用再到处塞刷新逻辑。这篇文章我会从一个真实的监控面板项目说起把 Zustand 中间件自动监听的原理、完整实现、踩坑记录全部摊开讲。适合对 React 和 Zustand 有一定基础、想进阶理解状态驱动刷新架构的开发者也适合正在做数据大屏、中后台管理系统或者实时性要求较高的页面的朋友直接参考。1. 为什么要做状态驱动刷新从手动拉取到自动监听1.1 手动刷新模式的三大痛点做前端开发这么多年我见过太多页面是这么写的进页面useEffect里调接口拉数据然后用户点一个刷新按钮就再拉一次或者某个操作结束后手动调用刷新函数。这种手动模式在简单页面里没太大问题但一旦页面复杂起来就会暴露三个很现实的坑第一个是事件丢失。操作 A 改了数据操作 B 也需要重新拉取但你只在 A 的代码里写了刷新B 那边不知道。结果就是用户看到的数据是错的还找不到原因。第二个是时序错乱。两个接口同时返回后返回的覆盖先返回的或者一个接口失败重试另一个接口已经成功了页面数据互相打架。第三个是组件冗余。你为了确保数据最新会在每个用到数据的组件里都写一遍拉取逻辑导致大量重复代码改一个接口路径要全局搜索替换非常痛苦。这些问题本质上是同一个根源数据的变更没有形成一个统一的信号源页面刷新依赖的是人为调用的偶然事件而不是数据本身变化这个必然事件。1.2 状态驱动刷新的核心思路状态驱动刷新的思路很简单把数据丢进一个全局 store让组件通过订阅拿到数据只要 store 里的数据变了所有用到这份数据的组件自动重新渲染。这句话拆开就是两个关键点第一单向数据流。状态只能通过明确的 action 来修改所有改动都有迹可循不会出现多个地方各改各的、状态失控的情况。第二订阅机制。组件不再是被动等待通知而是主动订阅变化。数据一变订阅者就能收到消息自动触发重新渲染或者重新拉取。用生活化的类比来说手动刷新模式就像你每隔几分钟去看一眼快递有没有到而状态驱动刷新模式是快递员到了给你打电话——你不用盯着到了自然知道。1.3 Zustand 为什么适合干这件事有人会问Redux 也能做状态驱动刷新为什么用 Zustand我个人的感受是三个字轻、准、稳。轻Zustand 核心代码只有 1KB 左右没有 Provider、没有繁琐的模板代码写起来非常直接。准它的订阅是基于Object.is比较的配合useShallow可以做精准的引用相等判断组件不会随便乱渲染。稳Zustand 底层就是一个发布订阅器不依赖 React 的setState机制来通知变更所以在并发渲染、异步逻辑下表现很稳定。另外Zustand 的 Middleware 机制让状态流变得非常优雅。中间件可以在状态变更前、变更后插入逻辑这意味着自动监听可以作为一个通用能力被抽出来而不是散落在每个业务组件里。提示这一节说的自动监听本质上是把subscribe能力和中间件能力结合。Zustand 本身就有subscribeAPI但直接用subscribe在大型项目里容易写得乱用中间件封装一层所有监听逻辑就能统一收口。后面我详细讲怎么收口。2. Zustand Middleware 机制拆解理解中间件的本质2.1 create 的第二个参数与中间件签名Zustand 的create接收两个参数第一个是初始化 state 和 action 的函数第二个就是中间件数组。函数签名长这样import { create } from zustand const useStore create( (set, get) ({ count: 0, increment: () set(state ({ count: state.count 1 })), }), // 中间件数组可选的 [ (config) (set, get, api) config(set, get, api), ] )中间件本质上就是一层包装函数它接收原始的config就是第一个参数那个函数然后返回一个增强版。这个增强版可以拿到三个非常重要的东西set修改状态的方法、get获取当前状态的方法、api包含subscribe、getState、setState的完整对象。理解了中间件的签名你就明白为什么它能做自动监听了——因为中间件手里握着api.subscribe它可以在状态变更时把消息广播给所有需要感知变化的模块。2.2 三种官方中间件的用法与适用场景Zustand 官方提供了几个中间件在写自定义中间件之前先把这些用熟很有必要。persist状态持久化把 store 数据同步到localStorage、sessionStorage或者其他自定义存储。我经常用它存用户偏好设置、搜索历史之类不需要每次都重新请求的数据。import { persist } from zustand/middleware export const useConfigStore create( persist( (set) ({ theme: light, locale: zh-CN, setTheme: (theme) set({ theme }), }), { name: app-config, storage: createJSONStorage(() localStorage), } ) )devtoolsRedux DevTools 调试开发环境接入 Redux DevTools可以直观地看到每一个 action 的调用、状态的快照、时间旅行回放。我强烈建议所有 Zustand 项目都挂上这个中间件排查问题的效率能提升一个档次。import { devtools } from zustand/middleware export const useStore create( devtools((set) ({ ... }), { name: MyStore }) )subscribeWithSelector选择性地监听状态子集这是实现自动监听最常用的一个中间件。它扩展了subscribe方法允许你只监听状态中的某个字段而不是整个 store 一变就触发。import { subscribeWithSelector } from zustand/middleware const useStore create( subscribeWithSelector((set) ({ user: { name: 张三, age: 18 }, list: [], })) ) // 只监听 user.name 的变化 useStore.subscribe( (state) state.user.name, // selector (name, prevName) { console.log(用户名从, prevName, 变成, name) }, )用不用subscribeWithSelector差异很大场景不用的方案用了的方案监听某个字段自己比较新旧 state 的差异直接传 selector内部帮你比较多个字段监听写多个 subscribe一个 subscribe 传不同 selector代码可读性需要嵌套判断逻辑逻辑集中在 selector 里2.3 手写一个中间件的套路官方中间件满足不了需求时就得自己写。写中间件其实有个固定套路接收config返回一个新函数在新函数里做增强。这里我写一个最简单的日志中间件记录每次 set 调用的前后状态变化const logger (config) (set, get, api) config( (...args) { const prevState get() set(...args) const nextState get() console.log([Zustand Logger] prev:, prevState) console.log([Zustand Logger] next:, nextState) }, get, api )这里有个关键点set的第一个参数可能是对象也可能是函数中间件里重写set时要注意把...args完整透传不要自己解析。如果你想在中间件里拿到具体变更了哪些字段最稳妥的方式是调用完原始set后用get()和缓存的前一状态做对比或者利用Object.assign这样的浅拷贝对象来 diff。提示中间件的顺序很重要。Zustand 对set做了封装中间件数组的执行顺序决定了数据流的先后——写在前面的中间件先拿到set调用。比如[logger, persist]和[persist, logger]的表现是不一样的。我的习惯是devtools放最外层subscribeWithSelector紧跟着业务自定义中间件放最里面。3. 自动监听方案的三种实现路线3.1 路线一subscribe getState 手动监听最基础的做法不写任何中间件直接在 store 初始化后调用subscribe来监听状态变化。import { useStore } from ./stores/mainStore // 在 store 文件里 useStore.subscribe((state, prevState) { if (state.timestamp ! prevState.timestamp) { // 数据时间戳变化触发刷新 refreshDashboardData() } })这种方式的优点是简单直接你可以在任何地方注册监听器。但缺点也很明显监听逻辑散落在各处不好管理而且如果不小心在refreshDashboardData里又改了 store 状态就可能触发循环调用。我的建议是一次性的监控逻辑、排查问题时的临时监听可以用subscribe但要放进正式项目架构就别这么干太容易失控。3.2 路线二封装 Middleware 统一处理把监听逻辑收进中间件里是更推荐的做法。下面是一个通用型的watchMiddleware你可以在 store 创建时给它配置要监听什么字段、变化了要执行什么动作。const watchMiddleware (config) (set, get, api) { const store config(set, get, api) // 把监听配置挂到 store 上 api.listen (selector, listener) { const onStoreChange () { const value selector(api.getState()) listener(value) } api.subscribe(onStoreChange) } return store }封装完之后业务 store 是这样用的const useDashboardStore create( (set, get) ({ filterDate: 2025-01-01, data: [], setFilterDate: (date) set({ filterDate: date }), }), [watchMiddleware] ) // 在组件里注册监听 useStore.getState().listen( (state) state.filterDate, () { fetchDashboardData().then((data) { useStore.setState({ data }) }) } )与手动subscribe相比中间件方案把监听逻辑从散落的代码变成可配置的机制。你的团队以后看到listen这个 API 就知道这是一种标准做法而不是翻遍全项目到处找subscribe调用。这里我还要分享一个心得中间件应该做通用能力业务逻辑要交给业务层。监听器里具体做什么由使用方决定中间件只保证监听的注册管道是统一且可靠的。3.3 路线三Hook 内 useEffect 消费如果项目比较小也可以不用中间件直接用 React Hooks 的useEffect配合 Zustand 的subscribe来实现自动刷新function useAutoRefresh(selector, listener) { useEffect(() { const unsub useStore.subscribe(selector, listener) return () unsub() }, [selector, listener]) } // 业务组件里 function Dashboard() { const filterDate useStore(state state.filterDate) const [data, setData] useState([]) useAutoRefresh( (state) state.filterDate, () { fetchDashboardData(filterDate).then(setData) } ) // ...渲染 }这个方案的优点是没有额外抽象符合 React 直觉缺点是每个业务组件都要写一条useAutoRefresh跨组件共享刷新逻辑时仍然会重复。三种路线的对比方案优点缺点适用场景subscribe getState灵活、无侵入监听逻辑散落、易失控临时调试、一次性逻辑Middleware 封装统一收口、可扩展需要理解中间件机制中大型项目、团队协作Hook useEffect符合 React 习惯重复代码多、共享困难小型项目、单一页面4. 完整实操做一个自动刷新的监控面板这一节我带你把上面讲的思路落地到一个真实场景里。假设你要做一个实时监控面板页面上有筛选时间范围、展示通道状态、展示 QoS 指标。这些数据要随着筛选条件变化自动刷新同时后端会推送一些事件前端收到事件后也要驱动面板刷新。4.1 定义 Store 结构先定好完整的状态结构。我习惯把状态分为两类可变筛选条件和服务端数据分开存放在同一个 store 里方便维护。import { create } from zustand import { subscribeWithSelector, devtools } from zustand/middleware const initialState { // 筛选条件 filter: { timeRange: 1h, channelId: ALL, }, // 服务端数据 dashboard: { charts: {}, alarms: [], loading: false, updatedAt: 0, }, } export const useDashboardStore create( devtools( subscribeWithSelector((set, get) ({ ...initialState, setFilter: (filter) set({ filter }), setDashboard: (partial) set((state) ({ dashboard: { ...state.dashboard, ...partial, // 每次更新数据顺手记录时间戳后续监听全靠它 updatedAt: Date.now(), }, })), })), { name: DashboardStore } ) )注意到updatedAt这个字段了吗这是一个非常有用的技巧。后端数据本身可能是多层嵌套的对象监听复杂嵌套对象的时候需要深度比较性能差而且容易误判。加一个updatedAt时间戳所有数据已更新的信号就简化为一个数字的比较。4.2 实现一个 watch 中间件有了 store我们来实现自动监听的核心中间件。这个中间件要做到三件事支持选择器、自动清理旧监听、提供统一注册入口。const watch (config) (set, get, api) { const store config(set, get, api) // 存一下所有的监听器方便后续清理 const listeners new Set() api.watch (selector, listener) { // 使用 subscribeWithSelector 提供的 subscribe 能力 const unsub api.subscribe(selector, (value, prevValue) { listener(value, prevValue) }) listeners.add(unsub) return () { unsub() listeners.delete(unsub) } } return store }注意这里我用了api.subscribe(selector, listener)它要求 store 必须挂上subscribeWithSelector中间件。如果你不想依赖这个中间件可以从get()拿一个特殊版本——在内部用api.subscribe这种原始写法然后手动在回调里做selector的取值和比较。// 不依赖 subscribeWithSelector 的 watch 实现手动做 equals 比较 api.watch (selector, listener) { let prevValue selector(api.getState()) return api.subscribe((state) { const nextValue selector(state) if (!Object.is(nextValue, prevValue)) { listener(nextValue, prevValue) prevValue nextValue } }) }具体选择哪个取决于团队偏好。用subscribeWithSelector更简洁但手写版本能加深你对 Zustand 内部机制的理解我建议都试一遍。4.3 组件里如何消费自动刷新现在到业务组件了。比如我们有一个通道状态组件它要监听filter.channelId和dashboard.updatedAt两者任一变化都触发重新拉取数据。import { useEffect } from react import { useDashboardStore } from ../stores/dashboardStore function ChannelStatusPanel() { const channelId useDashboardStore((state) state.filter.channelId) const charts useDashboardStore((state) state.dashboard.charts) useEffect(() { const unsub1 useDashboardStore.getState().watch( (state) state.filter.channelId, (channelId) { useDashboardStore.getState().setDashboard({ loading: true }) fetchChannelStatus(channelId).then((data) { useDashboardStore.getState().setDashboard({ charts: data, loading: false }) }) } ) const unsub2 useDashboardStore.getState().watch( (state) state.dashboard.updatedAt, () { const currentChannelId useDashboardStore.getState().filter.channelId fetchChannelStatus(currentChannelId).then((data) { useDashboardStore.getState().setDashboard({ charts: data, loading: false }) }) } ) return () { unsub1() unsub2() } }, []) if (!Object.keys(charts).length) { return div加载中.../div } return ( div classNamechannel-status-panel {/* 渲染图表 */} /div ) }看到这里有人会问为什么watch里不用dashboard.charts作为监听字段而用updatedAt因为charts是一个数组和对象的嵌套结构subscribeWithSelector的 selector 比较逻辑默认是对引用做Object.is比较。在 React 并发模式下引用相等性可能因为异步更新出现不可预料的偏差而updatedAt是个数字比较绝对可靠。另外就算某个组件只想响应图表变化用updatedAt做门槛再配合charts的存在性判断效果完全一样且逻辑更清晰。这里有一个需要特别注意的地方在 watch 回调里更新 store 状态时要确保不会无限循环。上面代码中fetchChannelStatus返回数据后调用setDashboard这会更新updatedAt触发 watch 监听器再请求一次接口…… 这就会变成死循环。解决方案是设置一个skip标记让本次更新不触发新的监听回调// setDashboard 支持第三个参数是否通知监听器 setDashboard: (partial, notify true) { set((state) ({ dashboard: { ...state.dashboard, ...partial, ...(notify ? { updatedAt: Date.now() } : {}), }, })) }然后请求回调里使用setDashboard({ charts: data }, false)表示这是数据回填不需要再触发刷新。这个通知开关是避免自动刷新死循环的关键设计你一定要加到自己的 store 里。4.4 批量刷新与防抖优化监控面板的场景还有一个现实问题短时间内多个事件源同时到达每个事件都触发一次刷新接口压力很大页面也会卡顿。我强烈建议加一层合并通知机制。思路很简单设置一个防抖窗口在窗口内如果有多个刷新请求只执行最后一次let refreshTimer null const pendingRefresh {} function scheduleRefresh(key, fetcher) { pendingRefresh[key] fetcher if (refreshTimer) return refreshTimer setTimeout(async () { const entries Object.entries(pendingRefresh) pendingRefresh.length 0 refreshTimer null await Promise.all(entries.map(([, fetcher]) fetcher())) }, 300) }每个watch回调里都调用scheduleRefresh300 毫秒内的所有状态变更会被合并成一次批量请求。界面不会闪烁接口压力也小了一个量级。提示防抖时间 300ms 是我在监控场景下的经验值你可以根据实际接口耗时调整。如果接口平均耗时超过 500ms防抖窗口建议拉大到 800ms如果只是纯前端状态切换100ms 就够了。4.5 生命周期清理与组件卸载我在4.3代码里已经写了return () { unsub1(); unsub2() }这是必须的。如果组件卸载后监听器还活着回调会去操作一个已经不存在的组件轻则报错重则出现微妙的幽灵更新问题。但有个例外有些刷新动作是全局性的不服务于某个具体组件。比如收到 WebSocket 事件后刷新整个面板这类监听注册在模块加载时不需要在组件卸载时清理。针对这种情况我建议单独放一个bootstrapListeners函数在主应用启动时执行一次export function bootstrapListeners() { useDashboardStore.getState().watch( (state) state.dashboard.alarms.length, () { // 后端推送的告警数变了刷新相关图表 scheduleRefresh(alarm-chart, loadAlarmChart) } ) }在主入口或者最外层 App 组件里调用bootstrapListeners()它的生命周期和应用一样长不需要手动卸载。5. 常见问题与排查技巧实录5.1 监听不触发引用类型变化的坑最常见的坑是selector 返回的是对象或数组比如(state) state.dashboard.charts然后你在外面修改了 charts 里某个字段但没替换引用。Zustand 的subscribeWithSelector比较的是引用Object.is对比发现引用没变就不会触发监听。排查方法很简单检查你的更新是不是用set创建了新的引用。Zustand 没有强制 immutable但不替换引用监听器就感知不到变化。这个问题的根治方案就是我在 4.3 节说的——监听简单的原始值时间戳、计数不要监听复杂嵌套对象。如果你必须监听复杂对象建议用useShallow这种浅比较工具来替换 selector 的默认引比较逻辑import { useShallow } from zustand/react/shallow // 注意subscribeWithSelector 的比较逻辑里可以用 shallow 包装 useStore.subscribe( (state) useShallow((s) s.dashboard.charts), (charts) { // ... } )不过还是那句话能监听原始值就别监听复杂对象。5.2 无限循环刷新请求回填触发再次监听这个我在 4.3 已经给过解决方案核心是请求回填不触发通知。再加一个兜底措施在setDashboard里加一层判断如果新旧数据完全一样就不再更新时间戳setDashboard: (partial, notify true) set((state) { const nextDashboard { ...state.dashboard, ...partial } const dataChanged !shallowEqual(nextDashboard, state.dashboard) return { dashboard: { ...nextDashboard, updatedAt: dataChanged || notify ? Date.now() : state.dashboard.updatedAt, }, } })配合上节的时间戳思路这个 store 基本不会出现循环刷新。5.3 性能恶化全量订阅了整个 store有人图省事直接监听整个 storeuseStore.subscribe((state) { // 整个 store 一变就执行 })结果全局一有动静所有监听都跑一遍页面卡顿、网络请求爆炸难排查还难优化。正确的做法是最小粒度的选择订阅。花 10 分钟梳理一下你的页面到底关心哪些状态给每个状态字段设置一个轻量 selector。除了防抖合并我还会在 selector 上面加一层useCallback或者useMemo确保 selector 引用稳定避免watch在 useEffect 里重复注册。5.4 React 18 并发渲染下的隐形坑React 18 并发模式中同一时刻可能有多个渲染任务在排队。Zustand 的useStorehook 本身是并发安全的但你如果在组件的 render 阶段里调用getState()并直接执行副作用可能会拿到过期状态。我的经验法则是渲染期间只能读取不能触发副作用。所有刷新动作都要放在副作用effect、事件回调、接口回调里。写自动监听逻辑时不要因为在回调里调用了getState()获取最新值就认为它必然对应最新的 React 渲染最好用selector主动选择确保当前渲染周期的数据一致性。5.5 SSR 场景与 persist 中间件的配合如果你是用 Next.js 这类 SSR 框架要注意persist中间件在服务端和客户端的表现差异。服务端没有localStorage你直接实例化 undo 就会报错而persist中间件在服务端渲染时会返回初始状态水合时再拿存储恢复这里如果不同步会导致闪烁甚至报错。实践中两个关键点第一persist的存储需要做环境判断import { persist, createJSONStorage } from zustand/middleware create( persist((set) ({ ... }), { name: my-storage, storage: createJSONStorage(() typeof window ! undefined ? localStorage : (null) ), }) )因为服务端没有 localStorage第二种写法会挂。第二用skipHydration控制水合时机。如果你的监听逻辑依赖持久化数据必须等待水合完成后再注册监听否则初始值不对后续的自动刷新逻辑可能建立在错误基础上。const store create( persist((set) ({ ... }), { name: my-storage, skipHydration: true }) ) // 在客户端等待水合完成后再挂监听器 store.persist.onFinishHydration(() { bootstrapListeners() })6. 工具选型与进阶实践建议6.1 中间件组合顺序的公式我总结了一个团队通用的组合顺序你用它可以应对绝大多数场景devtools → persist → subscribeWithSelector → 业务自定义中间件具体拆开解释一下devtools放最外层它只负责调试信息不能影响业务逻辑。persist第二层它负责跨会话持久化要保证在业务逻辑执行前状态已经恢复。subscribeWithSelector第三层它为后续所有业务中间件和订阅代码提供选择器支持。业务自定义中间件放最里面它们直接操作set/get/api离状态本体最近行为也最可控。每次加新中间件都问自己一句这个中间件是增强能力还是改变行为增强的放外层改变行为的放内层。这样组合逻辑不会乱。6.2 自动监听与请求层的配合状态驱动刷新的下游通常是接口请求层。我的实践是只在 store 层做状态变更请求全部走独立的 service 层store 的 action 只负责编排。拿上面监控面板的完整流程举例子// services/dashboard.js export async function fetchChannelStatus(channelId) { const res await request.get(/api/channel-status, { params: { channelId } }) return res.data } // stores/dashboardStore.js 中 loadChannelStatus: async () { const { filter } get() useDashboardStore.getState().setDashboard({ loading: true }, false) try { const data await fetchChannelStatus(filter.channelId) useDashboardStore.getState().setDashboard({ charts: data, loading: false }, false) } catch (err) { useDashboardStore.getState().setDashboard({ error: err.message, loading: false }, false) } }然后监听器只需挂一个入口store.watch( (s) s.dashboard.updatedAt, () useDashboardStore.getState().loadChannelStatus() )这样做的好处是业务组件不需要知道请求细节它只需关心数据已经变了我重新渲染请求失败的异常处理、loading 状态也统一收口在 action 里。6.3 方案的局限性与取舍建议自动监听不是银弹它也有适用边界。如果你的页面是纯展示页面数据进来了就绘制如果你的业务是强交互性的表单填写那自动监听反而可能引发不可控的重新渲染。我建议的取舍标准订阅状态变化频率和数据实时性需求。低频分钟级、强实时性的场景监控大屏、实时报表、协作文档非常适合自动监听方案高频秒级以上、弱实时性的场景普通管理表单、静态详情页用普通的 setState 手动刷新就够了别为了炫技增加复杂度。还有一点不管什么方案都要先写清楚状态边界。哪些状态是全局共享的、哪些是组件局部的、哪些需要持久化这个边界理不清中间件再强也救不了你。我自己从手动刷新转型到 Zustand 中间件自动监听最大的体会不是代码量变少了而是心更安了——我不再需要一遍遍检查所有该刷新的地方是不是都调了刷新函数因为整个数据流变成了一条笔直的单向管道只要数据源头动了下游一定会跟着动。最后再分享一个小技巧去线上环境调试这类方案时把devtools中间件的面板开起来配合时间旅行回放你能清晰地看到每一次状态变更对应的监听响应几乎所有 bug 都能在五分钟之内定位到源头。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →