放弃Redux转投Zustand:中后台React项目状态管理迁移与性能优化
发布时间:2026/9/13 6:18:58 作者:尧图编辑部 阅读量:1,286

1. 迁移决策为什么我最终放弃了Redux转投Zustand1.1 Redux体系下让我“又爱又恨”的日常先说一个真实的背景。我手上有一个维护了两年多的中后台项目从React 16一路升到React 18状态管理用的当然是Redux。这套体系在项目初期是非常靠谱的单向数据流、action/reducer分离、devtools时间旅行调试不管谁接手都能顺着action追到状态变化代码审查也特别轻松。但随着业务迭代到几十个页面、几十个reducer以后问题开始变得现实起来。我举个例子新来的同事想加一个“筛选条件变化后自动拉列表”的功能如果是后端返回的数据要进store他得做的事是定义action type常量、写action creator、在reducer里补case、在组件里useDispatch触发、再用useSelector取出来。看起来只是五步但每一步都在不同文件里改一个小需求要来回跳七八个文件。我的项目里还有一个业务list模块action type已经攒了四十多个reducer文件五百多行哪怕只是加一个字段我也会担心某个case里忘记return新state导致深拷贝出问题。Redux本身没做错什么它用极简的约定换来了可预测性但这种封装的代价就是模板代码膨胀。尤其当项目从单人开发变成多人协作时团队里每个人都会“下意识”地在store里加东西最后状态管理变成了一个谁都不敢大动的“大泥球”。真正让我下定决心调研替代方案的是一次性能问题排查一个高频更新的联动控件导致某棵组件子树反复渲染我花了半天时间用React Profiler定位最后发现罪魁祸首是useSelector误用了返回新数组的选择器触发了不必要的re-render。1.2 Zustand能解决什么不能解决什么Zustand这个名字翻译过来就是“状态”它是德国前端大神Paul Henschelreact-three-fiber作者主导的一个React状态管理库。我第一次接触它时最大感受是原来状态管理可以这么简单——不需要Provider包裹、没有action type、不需要写reducer直接定义一个hook就能用。它的API核心只有两个create用来创建storeuseStore用来在组件里消费状态。但你不要被“简单”两个字骗了Zustand的设计其实非常精巧。它底层用了React 18的useSyncExternalStore可以细粒度订阅状态片段。如果我只选择state.count这个基本类型count没变的时候组件就不会重新渲染连React.memo都不用写。这一点恰恰是Redux想要却需要开发者额外注意的优化点。当然Zustand也不是银弹。它没有Redux那种强制单向数据流的约束如果团队里没有严格的代码规范很容易出现组件里随手改store、状态变得不可追踪的情况。它也没有Redux DevTools那样开箱即用的完整调试面板需要额外配中间件没有内置的“时间旅行调试”那么顺手。所以我在迁移之前就给团队立了规矩所有修改必须通过store里定义的action禁止在组件里直接set整个state覆盖成新对象。1.3 什么样的项目适合做这次迁移不是所有项目都适合从Redux迁到Zustand。我的建议是如果项目已经非常稳定状态管理部分几乎没有拆过也没有持续的性能痛点那就别动稳定压倒一切。但如果符合下面几个特征这项迁移的收益会非常明显store文件里大量重复的action type和action creator加需求时一半时间在“搬运模板代码”选择器到处散落新组件想复用某个状态时找不到对应的selector干脆自己写一个新的结果产生多个类似的selector项目状态更新频繁比如实时数据、拖拽画布、在线协作光标经常出现性能瓶颈异步流程简单用thunk/saga感觉“杀鸡用牛刀”团队有迁移意愿且能集中一两天时间处理边界问题我的项目完全符合前四条。而且我用一天时间把核心模块迁移完后的感受很直观删掉了一半以上的状态代码组件渲染性能肉眼可见地提升因为不需要每次都走Provider selector新来的同事说“这个是今天写的还是昨天写的怎么这么能懂”。2. 迁移前奏先盘点再动手避免迁移变重构2.1 组件状态梳理与Store边界划分动手之前最重要的一件事不是写代码而是把现有Redux store里的所有状态全列出来。我建议开一个新窗口把项目里所有的reducer文件按模块列成一个清单然后逐个问三个问题这个状态只在一个组件内使用吗这个状态需要跨组件共享吗这个状态刷新页面后还需要保留吗第一个问题答案如果是“是”那很好办——这根本不需要进全局store直接用组件自己的useState或useReducer就够了。这种状态在迁移过程中可以直接“下沉”到组件内部全局面缩小一大圈。第二个问题答案如果是“是”才需要保留在全局store里。第三个问题答案如果是“是”就说明需要用持久化中间件Zustand有官方persist中间件后面会讲。我实际迁移时发现项目里大概有三成状态属于“看着全局其实就一个地方用”的类型比如某个页面筛选条的值、某个列表的展开收起状态。这些以前为了方便全塞进了Redux迁移时正好还回去。2.2 依赖盘点中间件、持久化与异步方案Redux生态里还有三个东西需要提前想清楚怎么替换分别是异步方案、持久化方案和类型支持。异步方案最常见的是redux-thunk和redux-saga。如果你用的是thunk迁移到Zustand几乎没有负担因为Zustand的action本身就是一个普通的async函数直接在函数体里做异步操作配合set更新状态即可。如果用的是redux-saga那就要花点心思了saga的“监听某个action然后触发一段副作用”模式在Zustand里没有直接对应物常见做法是把saga逻辑改写成action函数里的业务代码或者干脆在组件里用useEffect监听某个状态变化再触发对应函数。我在项目里就是靠后者完成了从saga到Zustand的替换。持久化方案方面Redux通常配合redux-persistZustand则用自带的persist中间件。两者思想一致都是把store里的部分或全部字段序列化到localStorage/sessionStorage。迁移也不复杂关键是提前约定好要持久化的字段白名单。类型支持方面Zustand对TypeScript的支持相当好createT()泛型可以直接推断整个store的类型。我甚至建议写模板的读者如果项目是用JS写的迁移时顺手把store补成TS收益更大。2.3 用一张对比表找到每个Redux模块的Zustand落点熟悉Redux的人都知道它的一个核心模块是由action type常量、action creator、reducer、selector四部分组成的。迁移时最容易犯的错误是“每样都找一个对应物”其实Zustand把前三个合并成了一个东西。我建议先做一张简单的映射表把原有模块对应到Zustand里的写法Redux模块Zustand对应物说明action type常量无需定义函数名本身就是动作少一层间接代码即文档action creatorstore里的action函数直接在create函数里定义可同步可异步reducerswitch casestore里的set调用状态更新点即action逻辑点selectorstore hook里的选择器函数例如useStore(s s.count)combineReducers多个独立storeZustand支持创建多个store不要强行合成一个middleware官方/社区中间件persist、devtools、subscribeWithSelector等异步副作用thunkasync action直接在action里await异步副作用sagauseEffect store action改为在组件生命周期里触发这张表做完以后你会很直观地看到Zustand把原来散落在四个文件里的逻辑收拢成了一个文件里的若干函数减少的代码量不是一点半点。3. 核心迁移实操从Redux到Zustand的逐步改造3.1 创建Zustand store把action/reducer合并成一套API先看一个最经典的Redux计数器迁移。原始Redux写法大概是这样的// actions.js export const increment () ({ type: INCREMENT }); export const decrement () ({ type: DECREMENT }); // reducer.js const initialState { count: 0 }; export default function counterReducer(state initialState, action) { switch (action.type) { case INCREMENT: return { ...state, count: state.count 1 }; case DECREMENT: return { ...state, count: state.count - 1 }; default: return state; } } // store/index.js import { createStore } from redux; import counterReducer from ./reducer; export default createStore(counterReducer); // Counter.jsx import { useDispatch, useSelector } from react-redux; const count useSelector(state state.counter.count); const dispatch useDispatch(); dispatch(increment());迁移到Zustand之后所有内容可以放在一个文件里// store/counterStore.js import { create } from zustand; export const useCounterStore create((set) ({ count: 0, increment: () set((state) ({ count: state.count 1 })), decrement: () set((state) ({ count: state.count - 1 })), })); // Counter.jsx import { useCounterStore } from ./store/counterStore; const count useCounterStore((state) state.count); const increment useCounterStore((state) state.increment); increment();注意几个细节。set有两种用法传对象是直接合并传函数则能拿到当前state并返回部分更新。大部分情况下直接传对象就够了但如果你在同一次更新里需要根据旧值计算新值就必须用函数式写法否则并发场景下会丢更新。另一个细节是Zustand的action直接作为store的属性暴露出来组件里调用时不需要包一层dispatch也天然支持“脱离React环境”来调用——比如事件总线、工具函数、localStorage监听回调里都可以直接用useCounterStore.getState().increment()。3.2 把useSelector和useDispatch替换成store hook迁移过程中组件里的改动是最大的。在Redux里组件要依赖useSelector订阅数据、useDispatch触发action在Zustand里这两个hook合并成了一个useStore选择器。先说对应的基础替换。原来写成// Redux const userId useSelector((state) state.user.id); const loading useSelector((state) state.user.loading); const dispatch useDispatch();迁移后写成// Zustand const userId useUserStore((state) state.user.id); const loading useUserStore((state) state.user.loading); const fetchUser useUserStore((state) state.fetchUser);这里有一个非常重要的点尽量让选择器返回基本类型或稳定引用不要直接返回整个状态对象。因为Zustand内部用Object.is比较前后值如果你写useUserStore((state) state.user)只要user对象引用变了哪怕只是其中一个字段变了所有使用这个选择器的组件都会重新渲染。想避免这一点要么选择精确到字段要么用useShallow做浅比较使用时长这样import { useShallow } from zustand/react/shallow; const { id, name, avatar } useUserStore( useShallow((state) ({ id: state.user.id, name: state.user.name, avatar: state.user.avatar, })) );迁移时我维护的一个原则是组件里尽量只“拿自己需要的字段”不要图省事一下把整个store都解构出来。如果你写const store useUserStore()那是在订阅整个store任何一个字段变了组件都会重渲性能直接退回到React Context的级别。3.3 异步流程迁移thunk、saga如何落地到ZustandRedux应用里最有“存在感”的部分其实是异步流程。迁移时这部分如果没处理好后面会埋雷。先看redux-thunk的典型写法export const fetchUser (id) async (dispatch) { dispatch({ type: FETCH_USER_START }); try { const res await api.getUser(id); dispatch({ type: FETCH_USER_SUCCESS, payload: res.data }); } catch (e) { dispatch({ type: FETCH_USER_FAILURE, payload: e.message }); } };在Zustand里异步action就是一个普通async函数export const useUserStore create((set) ({ user: null, loading: false, error: null, fetchUser: async (id) { set({ loading: true, error: null }); try { const res await api.getUser(id); set({ user: res.data, loading: false }); } catch (e) { set({ error: e.message, loading: false }); } }, }));够直接也够粗暴。你可能会问Redux要求reducer是纯函数所以必须通过dispatch触发更新Zustand没有这个约束直接在async函数里多次调用set也无妨。但是多人开发时我建议仍然遵守一个约定网络请求发生前先set一个loading状态请求结束后统一set数据与loading请求失败时set error。这样大家看代码时节奏一致。如果是redux-saga项目迁移要稍微绕一下。saga常用方式是“监听某个request action”然后做API请求再发起success/failure action。Zustand里没有“监听action”的概念我最后的落地方式是在store里保留fetchUser这样的action函数内部做完整异步流程如果某个模块需要在“状态变化后”触发另一个请求用组件里的useEffect监听对应状态再调用store action。举个例子筛选条件变化后自动请求列表useEffect(() { fetchList(filter); }, [filter, fetchList]);这样做的代码可读性比saga略差毕竟副作用散落在组件里但对中后台项目来说已经完全够用。如果将来项目真的需要复杂的事件流编排比如多步骤联动、可取消等可以考虑引入xstate或者把业务逻辑放到独立的service层而不是堆在状态管理库里。3.4 持久化与中间件的等价替换Redux项目通常配redux-persistZustand则用自带persist中间件。我在项目里需要把用户信息和一些设置项做本地持久化迁移前的Redux代码用了persistReducer和persistStoreReact重新加载后会先检测缓存再渲染应用。Zustand的写法更轻量import { create } from zustand; import { persist, createJSONStorage } from zustand/middleware; export const useSettingsStore create( persist( (set) ({ theme: light, locale: zh-CN, collapsedSidebar: false, toggleTheme: () set((state) ({ theme: state.theme light ? dark : light })), setLocale: (locale) set({ locale }), }), { name: settings, storage: createJSONStorage(() localStorage), partialize: (state) ({ theme: state.theme, locale: state.locale, collapsedSidebar: state.collapsedSidebar, }), } ) );这里partialize的作用很重要它决定了哪些字段要持久化。像临时列表、跨页面的搜索条件、临时UI状态我一般不会持久化不然会留下一堆没人用的localStorage垃圾。createJSONStorage(() localStorage)可以替换成sessionStorage也可以写自己的自定义storage适配器比如接入cookie或内存缓存。如果你还需要Redux DevTools来调试状态Zustand也有官方对应的devtools中间件。开启方式是在persist外层再包一层import { devtools } from zustand/middleware; export const useSettingsStore create( devtools( persist( // ... ), { name: settings-store } ) );这样在Redux DevTools里也能看到状态变化时间线对习惯了Redux调试的人来说过渡成本很低。不过坦白讲我用了两个月之后已经很少依赖DevTools了因为Zustand的状态变化点都很集中直接打console也比在Redux里翻action链来得快。4. 迁移踩坑实录与性能优化要点4.1 常见问题速查表迁移过程中我们团队踩过不少坑有些是Zustand本身的问题有些是“Redux惯性”带来的问题。我把常见问题整理成了一张速查表按严重程度排序症状原因解决方案组件无限重渲染选择器每次都返回新数组/新对象选择精确字段或配合useShallow做浅比较页面首次加载时store数据是空的刷新后才恢复persist中间件默认是异步水合使用onRehydrateStorage回调或skipHydration控制渲染时机一个状态更新导致所有组件全量重渲染在组件里直接const store useStore()订阅了整个store改成按字段订阅让每个组件只关心自己需要的数据修改state时“静默失败”尝试直接改state.count 1而不是调用set所有修改必须通过set或通过store里定义的action函数createStore运行时报“Cannot read properties of undefined”引入路径错误create从‘zustand’导入而不是从React引入路径检查import { create } from zustand组件卸载后async回调里调用setRedux时代不用担心因为dispatch是安全的Zustand里则会报警告用AbortController取消请求或加mounted标记判断状态能变但页面不更新React 18严格模式下开发环境会挂载两次旧版React忘包StrictMode可能有问题确认项目用react-dom/client创建根节点并引入StrictMode这里面最坑的是第一条“无限重渲染”我单独再展开讲讲。假设你有一个store里面有个user对象和一个filter数组组件里写const user useUserStore((state) state.user); // 每次user引用变化都重渲这本身没问题。但如果你写const userName useUserStore((state) state.user.name);user为null时这行代码会直接抛错。所以你可能会“图省事”写const { user } useUserStore();这样虽然不报错但任何字段变化都会让这个组件跟着渲染。到了表单联动这类高频更新场景性能瞬间就暴露了。我的建议是写一个安全选择器比如const userName useUserStore((state) state.user?.name ?? )既避免报错又保证细粒度订阅。4.2 状态更新频繁时的性能优化Zustand默认的性能表现已经不错但如果你拿它做画布、拖拽、实时数据同步这类高频更新还是有几个优化手段值得掌握。第一招是拆分store。Redux时代我们习惯把很多数据都塞进一个大store里用combineReducers合并这样组件通过同一个useSelector从store取数据。Zustand完全支持创建多个独立store比如useUserStore、useListStore、useSettingsStore各管一摊。拆开后不同的store更新互不影响订阅其中一个store的组件不会因为另一个store的数据变化而重渲。迁移时我把原来的一个“全部状态合集”拆成了八个独立store一个store只做一件事代码可读性也上来了。第二招是subscribeWithSelector中间件。Zustand默认暴露的subscribe不能直接监听某个字段的变化只能监听整个state变化。如果你只想在某个字段变化时做一件事比如写日志、上报埋点、触发联动可以用官方中间件subscribeWithSelectorimport { subscribeWithSelector } from zustand/middleware; export const useListStore create( subscribeWithSelector((set) ({ filter: all, data: [], // ... })) ); useListStore.subscribe( (state) state.filter, (filter, prevFilter) { console.log(filter changed, prevFilter, -, filter); // 触发列表重新加载 } );这个API非常像Redux里的“监听某个slice变化”但它不在React组件里不会受组件生命周期影响用于跨模块联动是很好的补充。第三招是配合React.memo或useMemo。Zustand的useStore本身是很细粒度的hooks但如果你的组件里计算派生状态比如过滤后的列表、汇总的统计值每次都从头计算也会有负担。此时用useMemo包裹一下依赖store里的原始数据性能会好很多。如果计算量特别大也可以用useEffect 单独状态缓存结果但一般来说useMemo就够了。4.3 关于迁移节奏与项目工程化的个人经验如果让我给正在犹豫要不要迁移的人一句最实在的建议那就是不要追求“一次性全部迁移成功”也不要先从最复杂的模块开始。我当时先把一个最简单的用户模块从Redux迁到Zustand在分支里跑通观察三天确认没有性能回归后再按模块逐个迁移。每个模块迁移完都要过一遍核心用户路径包括页面刷新、路由切换、表单提交确认状态在跨页面场景下没有丢失。迁移期间还有一个细节就是Redux的store别急着删。我做了个“双轨并行”的方案老的Redux store和新Zustand store同时存在组件优先用Zustand的但未迁移的模块继续用Redux。这样不用等待所有模块迁移完才能发布风险也分散了。等所有模块都切完以后再统一删掉Redux相关代码和依赖。如果你在迁移时还能顺手做两件事那这次迁移的长期收益会超出预期。第一件是把store的TypeScript类型补齐Zustand的泛型推断非常舒服类型写好了之后组件里取状态几乎不用操心类型问题。第二件是给每个store写一个小的README或者注释块说明这个store负责什么业务域、哪些字段需要持久化、哪些action是异步的。听起来像额外工作量但半年后你再回去维护这段代码一定会感谢当时的自己。最后讲一个我踩得比较深又很容易被忽略的坑Zustand的set是支持浅合并的这跟Redux里手动展开state很像但一定不要在action里把整个旧state复制一遍再更新。比如有人会写set({ ...get(), count: newCount })这完全没必要而且有潜在风险——如果get()返回对象引用不稳定可能引起多余的渲染。直接写set({ count: newCount })就够了。整体迁移下来我的体感是代码量大概减少了一半状态相关文件的目录从原来的三个文件夹变成一个大文件夹业务逻辑更集中新人上手的成本明显降低。性能方面列表页交互更跟手了因为不再有不必要的whole-tree re-render。如果你也在为Redux的模板代码和性能头疼不妨抽一两天做个小范围试点迁移一次真实的小型迁移比什么理论分析都有说服力。