一、问题背景:Context 不是状态管理库
项目是一个运营后台,React 18.2 负责数据看板,Vue 3.3 负责配置中心,两边共享同一套用户、权限、租户配置。早期为了省事,React 侧用 createContext + useReducer,Vue 侧用 provide/inject 加一个 reactive 对象。
一开始没问题。问题出在业务膨胀之后:
- 全局状态字段从 20 个涨到 120+,包含用户信息、权限树、租户配置、主题、字典缓存、WebSocket 连接状态。
- 一个 Context Provider 包住了整个 App,任何字段变化都会让所有
useContext的组件重渲染。 - 最夸张的是字典缓存:一个搜索框绑定
dictOptions,用户每输入一个字符,Provider value 重新生成,47 个消费组件里 30+ 个无差别重渲染。
用 React DevTools Profiler 抓了一次:输入 "abc" 三个字符,commit 三次,每次渲染耗时 78-92ms,掉帧明显。Vue 侧同理,provide 的 reactive 对象被 12 个组件 inject,改一个字段,12 个全部触发更新。
结论很明确:Context/Provide 是依赖注入工具,不是状态管理方案。它没有选择器、没有细粒度订阅、没有状态分片,一旦状态规模上去,性能必然崩。
二、环境与版本
- React 18.2.0 + TypeScript 5.2 + Vite 4.4
- Vue 3.3.4 + TypeScript 5.2 + Vite 4.4
- 原状态方案:React Context + useReducer;Vue provide/inject + reactive
- 迁移目标:React 用 Zustand 4.4.7;Vue 用 Pinia 2.1.7
- 监控:React DevTools Profiler、Vue Devtools、Chrome Performance
选 Zustand 而不是 Redux Toolkit,原因有三:一是项目已有 Context 的 reducer 逻辑,Zustand 的 store 写法迁移成本最低;二是 Zustand 默认基于 useSyncExternalStore,天然支持选择器订阅;三是包体积,zustand 压缩后约 1.2KB,redux-toolkit + react-redux 约 13KB。Pinia 则是 Vue 官方推荐,和 Vue 3 响应式系统深度集成,没有理由不用。
三、方案设计:分片 + 选择器 + 持久化
核心思路是按业务域分片,而不是一个大 store。我们拆成四个:
userStore:用户信息、token、权限码dictStore:字典缓存,按字典类型 key 存储tenantStore:租户配置、主题socketStore:WebSocket 连接状态、消息队列
每个 store 内部再用选择器暴露细粒度订阅。持久化只做 userStore 和 tenantStore 的部分字段,用 persist 中间件,避免把 socket 连接这种易失状态写进 localStorage。
React 侧 Zustand store 写法:
// stores/dictStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
interface DictState {
cache: Record>;
loading: Record;
fetchDict: (type: string) => Promise;
}
export const useDictStore = create()(
subscribeWithSelector((set, get) => ({
cache: {},
loading: {},
fetchDict: async (type) => {
if (get().cache[type] || get().loading[type]) return;
set((s) => ({ loading: { ...s.loading, [type]: true } }));
const res = await fetch(`/api/dict/${type}`);
const data = await res.json();
set((s) => ({
cache: { ...s.cache, [type]: data },
loading: { ...s.loading, [type]: false },
}));
},
}))
);
组件里用选择器订阅,只关心自己需要的字段:
// components/SearchBox.tsx
import { useDictStore } from '@/stores/dictStore';
export function SearchBox() {
// 只订阅 cache.status 这一个字段
const statusOptions = useDictStore((s) => s.cache.status);
const fetchDict = useDictStore((s) => s.fetchDict);
useEffect(() => {
fetchDict('status');
}, [fetchDict]);
return ;
}
关键点:useDictStore((s) => s.cache.status) 返回的是引用,Zustand 默认用 Object.is 比较,只有 cache.status 变化才触发重渲染,其他字典更新不影响这个组件。
Vue 侧 Pinia store:
// stores/dict.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useDictStore = defineStore('dict', () => {
const cache = ref>>({});
const loading = ref>({});
const getDict = computed(() => (type: string) => cache.value[type] ?? []);
async function fetchDict(type: string) {
if (cache.value[type] || loading.value[type]) return;
loading.value[type] = true;
const res = await fetch(`/api/dict/${type}`);
cache.value[type] = await res.json();
loading.value[type] = false;
}
return { cache, loading, getDict, fetchDict };
});
Vue 组件里用 storeToRefs 解构,保持响应式:
import { storeToRefs } from 'pinia';
import { useDictStore } from '@/stores/dict';
const dictStore = useDictStore();
const { cache } = storeToRefs(dictStore);
// 只依赖 cache.value.status,其他字典更新不触发本组件
const statusOptions = computed(() => cache.value.status ?? []);
四、迁移步骤:先并行,再切换,最后删旧代码
迁移最怕的是"改一半线上炸了"。我采用双跑 + 灰度切换:
- 第一周:搭 store,不改业务。把 Context 里的 state 和 action 原样搬到 Zustand/Pinia,保持接口签名一致。
- 第二周:新组件用新 store,老组件不动。此时两套状态并存,Context 里的数据通过
useEffect同步到 store,保证一致性。 - 第三周:按模块切换。从性能问题最严重的字典、权限模块开始,逐个把
useContext换成useDictStore。每切一个模块,跑一次 Profiler 对比。 - 第四周:删 Context Provider。所有消费点切完后,删除 Provider 和 reducer,清理
provide/inject。
踩坑记录:
- Zustand 选择器返回新对象:
useStore((s) => ({ a: s.a, b: s.b }))每次返回新对象,Object.is永远不等,导致无限重渲染。解决:用useShallow包一层,或者拆成两个选择器。 - Pinia 解构丢响应式:直接
const { cache } = useDictStore()会丢响应式,必须用storeToRefs。这个坑我踩了两次。 - 持久化字段选择:
persist中间件默认持久化整个 store,必须显式指定partialize,否则 socket 状态被写进 localStorage,刷新后连接状态错乱。
五、效果数据
迁移前后用同一台机器、同一份数据跑 Profiler:
| 指标 | Context/Provide | Zustand/Pinia | 变化 |
|---|---|---|---|
| 搜索框输入一次渲染组件数 | 31 | 4 | -87% |
| 单次 commit 耗时 | 78-92ms | 14-20ms | -78% |
| 首屏渲染 | 1.42s | 0.88s | -38% |
| 字典页交互延迟 | 90ms | 18ms | -80% |
| 状态相关代码行数 | 约 1800 | 约 1100 | -39% |
首屏下降主要来自两点:一是删掉了包住整个 App 的 Provider,减少了顶层重渲染;二是字典缓存按需加载,不再在 App 初始化时全量拉取。
六、总结
这次迁移最大的收获不是学会了 Zustand 或 Pinia,而是想清楚了一件事:Context/Provide 解决的是"跨层级传递依赖",不是"管理全局状态"。当状态规模小、更新频率低时,它们够用;一旦状态字段上百、更新频繁,就必须上真正的状态管理库。
如果让我给建议:React 项目状态超过 30 个字段、或出现明显重渲染卡顿,直接上 Zustand;Vue 3 项目直接用 Pinia,没有理由用 provide/inject 管全局状态。迁移时不要一次性重写,双跑灰度、按模块切换,风险最低。