一、问题背景:当Context成为性能瓶颈

上季度接手了一个维护了两年的中后台项目,技术栈是React 18.3 + Vue 3.4混合开发(历史原因)。其中一个核心页面“订单管理”包含了订单列表、筛选器、详情抽屉、操作日志时间线四个大模块。

最初架构遵循“就近原则”,用户信息用UserContext,权限状态用AuthContext,购物车数据则通过5层Props层层传递。在数据量小的时候一切正常,但当订单列表扩展到500条数据,且每次操作都要更新权限标签时,问题暴露:

  • 打开订单详情抽屉:320ms(其中React渲染占180ms,Context重新消费占90ms)
  • 切换订单状态后,整个页面所有组件全部重新渲染(因为AuthContext.Provider包裹了根组件)
  • Vue侧更头疼,Props传递的响应式对象在深层嵌套时,每次修改都会触发父组件 -> 子组件 -> 孙组件的链式更新,Vue Devtools显示的更新链路长达7层。

React.memocomputed优化无果后,决定全面迁移到状态管理库。

二、环境与版本:统一基础设施

迁移前先统一版本,减少不可控因素:

// package.json 关键依赖
{
  "react": "^18.3.1",
  "react-dom": "^18.3.1",
  "zustand": "^5.2.3",
  "vue": "^3.5.13",
  "pinia": "^2.3.1",
  "vite": "^5.4.0",
  "typescript": "^5.6.0"
}

选型理由:
- React侧:Zustand 5比Redux Toolkit轻量(打包后约1.2KB vs 11KB),且无需Provider包裹,支持createuseStore的selector精确订阅。
- Vue侧:Pinia 2完全兼容Vue 3.5的reactiveref,且支持setup store语法,比Vuex 4更符合组合式API习惯。

三、方案设计:渐进式迁移而非重写

不推荐一次性把所有的Context/Props都替换掉。我们分三步走:

  1. 先迁移“跨模块共享”状态:用户登录信息、权限标签、全局主题。
  2. 再迁移“高频更新”状态:购物车、订单列表筛选条件(因为这两个是性能瓶颈)。
  3. 保留Props传递“纯展示”数据:组件内部UI状态(如弹窗开关)继续用useStateref

核心设计原则:
- React侧:每个store独立文件,使用create + selector,避免组件订阅整个store。
- Vue侧:使用setup store,返回refcomputed,配合storeToRefs使用。

四、核心实现:React侧Zustand 5迁移

先看迁移前的Context代码(痛点示例):

// 迁移前:Context导致所有子组件订阅
const UserContext = createContext(null);
function App() {
  const [user, setUser] = useState(null);
  // 每次setUser,所有消费UserContext的组件都重渲染
  return (





  );
}
// OrderList内部:const { user } = useContext(UserContext); // 每次user变化,OrderList重渲染

迁移后,使用Zustand 5的useShallow和独立selector:

// stores/userStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface UserState {
  userInfo: { id: number; name: string; role: string } | null;
  permissions: string[];
  setUser: (user: UserState['userInfo']) => void;
  updatePermission: (perm: string) => void;
}

export const useUserStore = create()(
  persist(
    (set) => ({
      userInfo: null,
      permissions: [],
      setUser: (userInfo) => set({ userInfo }),
      updatePermission: (perm) =>
        set((state) => ({
          permissions: state.permissions.includes(perm)
            ? state.permissions
            : [...state.permissions, perm],
        })),
    }),
    {
      name: 'user-storage', // 持久化到localStorage
      partialize: (state) => ({ userInfo: state.userInfo }), // 只持久化userInfo
    }
  )
);

// 组件中使用:精确订阅,避免无关渲染
import { useShallow } from 'zustand/react/shallow';

function OrderList() {
  // 只订阅userInfo,不订阅permissions
  const userInfo = useUserStore((state) => state.userInfo);
  // 订阅多个字段时,使用useShallow避免浅比较失效
  const [permissions, updatePermission] = useUserStore(
    useShallow((state) => [state.permissions, state.updatePermission])
  );

  if (!userInfo) return 请先登录;
  return (

      操作人:{userInfo.name}
      {permissions.includes('order:delete') && 删除订单}

  );
}

关键优化点:Zustand 5的selector默认使用Object.is比较,如果直接返回新对象(如[...state.permissions]),会导致无限渲染。必须使用useShallow包装,它内部会做浅层比较。

五、核心实现:Vue侧Pinia 2迁移

Vue侧原本的Props drilling代码(痛点示例):

// 父组件
const filterParams = reactive({ status: 'all', dateRange: [] });
const userRole = computed(() => props.userRole); // 从更上层传下来

迁移到Pinia 2的setup store

// stores/orderStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useOrderStore = defineStore('order', () => {
  // 状态
  const filterParams = ref({ status: 'all', dateRange: [] as string[] });
  const orderList = ref([]);

  // getters
  const filteredOrders = computed(() => {
    return orderList.value.filter((order) => {
      if (filterParams.value.status !== 'all' && order.status !== filterParams.value.status) return false;
      return true;
    });
  });

  // actions
  function updateFilter(params: Partial) {
    filterParams.value = { ...filterParams.value, ...params };
  }

  async function fetchOrders() {
    // 模拟请求
    orderList.value = await api.getOrders(filterParams.value);
  }

  return { filterParams, orderList, filteredOrders, updateFilter, fetchOrders };
});

组件中使用:

import { useOrderStore } from '@/stores/orderStore';
import { storeToRefs } from 'pinia';

const store = useOrderStore();
// 解构时用storeToRefs保持响应性,直接解构会失去响应性
const { filterParams, filteredOrders } = storeToRefs(store);

// 调用action时直接使用store
onMounted(() => {
  store.fetchOrders();
});

重要提示:Pinia的store本身是reactive对象,直接从store解构属性会丢失响应性。必须使用storeToRefs。另外,setup store返回的ref会被自动解包,所以模板中写store.filterParams而不是store.filterParams.value

六、踩坑与优化:真实环境中的意外

坑1:React 18的useSyncExternalStore与Zustand 5的兼容性
Zustand 5默认使用useSyncExternalStore,但在React 18.3的严格模式下,如果selector返回不稳定引用(如state.items.map(...)),会出现“getSnapshot should be cached”警告。解决:在selector内使用useMemo或直接返回primitive值。

坑2:Vue的ref在Pinia setup store中的自动解包陷阱
setup store中,如果返回一个reactive对象,模板中访问属性时不需要.value;但如果返回一个ref,模板中需要.value(除非在store内部已经解包)。我们统一规范:所有返回的state用ref,getters用computed,模板中全部通过store.xxx访问(Pinia自动解包),但storeToRefs解构出来的ref`中需要.value`。

坑3:迁移过程中的双写问题
为了平稳过渡,我们同时保留了Context和Store,通过一个isMigrated标志控制。结果发现双写导致状态不一致(用户改了Context但Store没更新)。最终决定:一次性开关,删除所有Context代码,不留后路。

优化效果数据(基于Chrome Performance tab + React DevTools Profiler):

指标 迁移前(Context/Props) 迁移后(Zustand/Pinia) 提升
订单详情打开时间 320ms 78ms -75.6%
切换订单状态重渲染组件数 42个 9个 -78.6%
内存占用(快照) 48.2MB 35.1MB -27.2%
Vue侧筛选操作响应 210ms 65ms -69.0%

七、总结与建议

  1. 不要过度使用状态管理库:如果状态只在单个组件内使用,useState/ref足够。我们的原则是:至少被3个非父子关系的组件共享,才放入全局store。
  2. React侧优先用selector订阅:Zustand的优势在于精确订阅,但要注意useShallow的用法,否则性能反而更差。
  3. Vue侧优先用storeToRefs:避免解构丢失响应性,这是Pinia新手最常见的错误。
  4. 版本锁定:React 19与Zustand 5配合良好,但Zustand 4的create API在React 19下会有警告。Vue 3.5 + Pinia 2.3是稳定组合,不要盲目升级到Pinia 3(目前还是beta)。

如果项目是大型应用(超过50个页面),建议直接上Redux Toolkit或Zustand + Immer;如果是中小型应用,Context + useReducer也够用。关键是识别出真正的性能瓶颈——不要因为“大家都在用”就盲目引入状态管理库,我们迁移是因为有明确的数据支撑(120ms到380ms的劣化)。

最后,附上完整的迁移Demo仓库(已脱敏):github.com/yourname/state-migration-demo,里面包含了React和Vue两个版本的前后对比代码。欢迎star交流。