一、问题背景: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。我们拆成四个:

  1. userStore:用户信息、token、权限码
  2. dictStore:字典缓存,按字典类型 key 存储
  3. tenantStore:租户配置、主题
  4. socketStore:WebSocket 连接状态、消息队列

每个 store 内部再用选择器暴露细粒度订阅。持久化只做 userStoretenantStore 的部分字段,用 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 ?? []);

四、迁移步骤:先并行,再切换,最后删旧代码

迁移最怕的是"改一半线上炸了"。我采用双跑 + 灰度切换

  1. 第一周:搭 store,不改业务。把 Context 里的 state 和 action 原样搬到 Zustand/Pinia,保持接口签名一致。
  2. 第二周:新组件用新 store,老组件不动。此时两套状态并存,Context 里的数据通过 useEffect 同步到 store,保证一致性。
  3. 第三周:按模块切换。从性能问题最严重的字典、权限模块开始,逐个把 useContext 换成 useDictStore。每切一个模块,跑一次 Profiler 对比。
  4. 第四周:删 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 管全局状态。迁移时不要一次性重写,双跑灰度、按模块切换,风险最低。