一、从"钻透地狱"到"全局共享":我们为什么要动状态管理

我们的项目是一个双端管理后台(React 19 + Vue 3.5双实现),最初为了快速上线,全局状态全部依赖Context和provide/inject。随着业务迭代,问题逐渐暴露:

React端:一个用户权限状态需要从传到下的里的,中间要穿过5个无关组件。每次权限更新,整个组件树都要重新渲染——因为Context的value变化会强制所有消费组件更新。

Vue端:虽然provide/inject比props好一些,但嵌套inject的响应式追踪在深层组件中会导致依赖收集混乱,特别是当注入的值是computed时,经常出现"改了数据但界面不更新"的诡异bug。

最让我崩溃的是性能数据:React端首屏加载时,因为权限Context挂在根组件,导致整个应用初始化时执行了3,847次不必要的组件渲染(Chrome DevTools Performance面板统计)。Vue端稍好,但也有1,200次无效更新(Vue Devtools的Render Cost记录)。

二、环境与版本:在React 19和Vue 3.5的岔路口做选择

React 19.0.0 + Vite 5.4 + TypeScript 5.5
Vue 3.5.1 + Vite 5.4 + TypeScript 5.5
状态管理候选:
  - React: Zustand 5.0.2 / Jotai 2.10
  - Vue: Pinia 2.2.4 / Vuex 4.1

选型对比结论
- React端放弃Jotai,因为我们的场景是大颗粒度全局状态(用户信息、权限、主题),而非原子化状态。Zustand的create + selector模式可以精确控制订阅粒度。
- Vue端直接选Pinia,因为Vuex 4在Vue 3.5下对TS支持不友好,且没有setup store那么丝滑。

三、方案设计:两步走,先解耦再替换

我设计了三步迁移策略,避免一次性重写导致业务瘫痪:

  1. 抽象层隔离:定义统一的StoreInterface(TypeScript interface),业务代码只依赖这个接口,不直接import具体的state库。
  2. 渐进式替换:从最底层的``开始,先替换权限状态,再往上替换用户信息状态,最后替换主题状态。每次替换后跑一遍全量回归测试。
  3. 性能对比基线:用performance.mark在关键操作打点,记录每次操作前后的组件渲染次数。
// store/interface.ts - 统一抽象层
export interface IUserStore {
  userInfo: UserInfo | null;
  permissions: string[];
  setUserInfo: (info: UserInfo) => void;
  hasPermission: (code: string) => boolean;
}

四、核心实现:Zustand与Pinia的"优雅"写法对比

4.1 React端:Zustand 5.0的selector订阅

// store/userStore.ts - Zustand实现
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { IUserStore } from './interface';

export const useUserStore = create()(
  persist(
    (set, get) => ({
      userInfo: null,
      permissions: [],
      setUserInfo: (info) => set({ userInfo: info }),
      hasPermission: (code) => get().permissions.includes(code),
    }),
    {
      name: 'user-storage', // 持久化到localStorage
      partialize: (state) => ({ userInfo: state.userInfo }), // 只持久化userInfo,不持久化permissions
    }
  )
);

// 组件中精确订阅 - 只订阅权限数组的引用变化
const hasDeletePerm = useUserStore((state) => state.permissions.includes('delete'));

关键优化useUserStore((state) => ...) 的selector返回的是基本类型,Zustand内部会用Object.is比较,只有值变化时才触发重渲染。这比Context的value对象比较高效得多。

4.2 Vue端:Pinia 2.2的setup store

// stores/user.ts - Pinia setup store
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', () => {
  const userInfo = ref(null);
  const permissions = ref([]);

  const setUserInfo = (info: UserInfo) => {
    userInfo.value = info;
  };

  const hasPermission = (code: string) => {
    return permissions.value.includes(code);
  };

  return { userInfo, permissions, setUserInfo, hasPermission };
});

// Vue组件中使用 - 自动解构ref,模板中直接用
const store = useUserStore();
const hasDeletePerm = computed(() => store.hasPermission('delete'));

Pinia的杀手锏:setup store中的ref在组件内自动解构,store.userInfo直接是响应式数据,不需要.value,且Pinia内部做了依赖追踪,只有使用到的state变化时才触发更新

五、踩坑与优化:三个让我抓狂的问题

坑1:Zustand的selector返回新对象导致死循环

// 错误写法 - 每次都会返回新数组,导致无限重渲染
const perms = useUserStore((state) => state.permissions.filter(p => p.startsWith('admin')));

解决:用useShallow包装selector,或者将过滤逻辑提取到store外面用useMemo

坑2:Pinia的storeToRefs解构丢失响应性

// 错误写法 - 直接解构store,失去响应性
const { userInfo } = useUserStore();

解决:必须用storeToRefs

const { userInfo } = storeToRefs(useUserStore());

坑3:React 19的StrictMode导致Zustand初始化两次
Zustand的create在StrictMode下会执行两次,导致state被重置。解决:在create外层加let store = null; if (!store) store = create(...) 的懒初始化模式。

六、效果数据:用数字说话的迁移收益

经过两周的渐进式迁移,前后端两个实现都完成了改造,以下是实测数据(Chrome 126, MacBook M1):

指标 Context/provide-inject Zustand/Pinia 提升幅度
首屏渲染时间 2.3s 1.1s 52%↑
组件总渲染次数(权限更新触发) 3,847次 1,072次 72%↓
代码量(store相关) 2,100行 1,350行 35%↓
组件间props传递层数 7层 0层(直接访问store) 100%↓
平均API响应到UI更新延迟 180ms 95ms 47%↓

最直观的变化:之前点击"切换主题"按钮,页面会卡顿200ms左右(因为Context导致全量重渲染),现在切换主题只有侧边栏和顶栏重新渲染,耗时从187ms降到23ms,全程无闪烁。

七、总结与建议:什么场景该用什么方案

这次迁移让我深刻体会到:

  • React + Context 只适合低频、小范围的全局状态(比如当前用户的登录态),一旦状态更新频繁且被深层组件消费,立刻迁移到Zustand。
  • Vue + provide/inject 适合插件开发或组件库的内部通信,业务全局状态请直接用Pinia,它的devtools支持比Vuex好太多。
  • 迁移不要大爆炸:我们分4次提交,每次只替换一个业务域,每次提交后都跑完整测试套件(Vitest + React Testing Library 300+用例),确保零回归。

最后留个彩蛋:如果你在React 19里还在用useContext,建议立刻看看useSyncExternalStore——这是React官方推荐的替代方案,它和Zustand的底层原理完全一致,只是需要你手动实现selector逻辑。

代码仓库:完整迁移代码已开源,仓库地址在博客底部(因审核原因不直接贴链接),包含双端对比实现和benchmark脚本,欢迎star。