一、背景:当Context和Props开始成为负担

在维护两个中大型前端项目(一个React 16.8老项目,一个Vue 3.2项目)时,我遇到了典型的状态管理瓶颈。

React项目最初使用Context + useReducer管理全局用户信息和权限。但当业务发展到50+个页面,Context的value一变,所有消费该Context的组件都会重渲染——即便它们只用到了其中一小部分数据。我们用React.memo和useCallback做了大量优化,但代码里开始出现“为了减少重渲染而拆分子组件”的奇怪结构。

Vue项目则是一个“Props钻取”重灾区。页面组件需要把loginUser、permissions、theme等十来个属性逐层传递给孙组件、曾孙组件。每次新增一个属性,就要修改至少5个组件的props定义。

两个项目的共同痛点:状态逻辑分散、调试困难(Context状态下很难追踪是谁改了状态)、以及性能瓶颈。

二、环境与版本:迁移前的基线数据

在动手之前,我做了详细的基线测量:

React项目:
- React 16.8.6, react-redux 未使用
- 使用Context + useReducer
- 页面平均组件树深度:7层
- 平均每次交互触发的重渲染组件数:27个(通过Profiler API测量)
- 首屏可交互时间(TTI):3.2s(Lighthouse模拟Moto G4)

Vue项目:
- Vue 3.2.47, vue-router 4.1.6
- 使用Props逐层传递
- 最长Props传递链:6层(Page -> Card -> Content -> List -> Item -> Button)
- 单页面平均Props声明数:18个
- 页面交互延迟(点击到DOM更新):210ms(高刷新率显示器下有明显卡顿)

工具链:React项目用webpack 5.88,Vue项目用Vite 4.4,均开启了ESBuild压缩。

三、方案设计:为什么选了Zustand和Pinia,而不是Redux Toolkit或Vuex

React项目选型时对比了Redux Toolkit 1.9、Zustand 4.5、Jotai 2.6

最终选择了Zustand,原因如下:
- 无需Provider包裹,可以直接在组件外读写状态,对非组件模块(如工具函数、axios拦截器)友好
- 基于useSyncExternalStore,不需要额外处理并发特性(Concurrent Mode)
- 代码模板少,一个create函数搞定,不需要写action/reducer/selector三层

Vue项目选型时对比了Pinia 2.1和Vuex 4.1

Pinia胜出是因为:
- Vue 3官方推荐,天然支持Composition API
- 去掉了Vuex的mutations概念,直接在action里改state,减少模板代码
- 完整的TypeScript类型推导,比如store.$state的推断非常准

四、核心实现:两个项目的迁移步骤与代码

4.1 React项目:从Context到Zustand

先看迁移前的Context代码长什么样:

// 迁移前 - Context版本(简化)
const AppContext = React.createContext(null);

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, {
    userInfo: null,
    permissions: [],
    theme: 'light',
    // ...还有10来个字段
  });
  return (

      {children}

  );
}

// 组件中消费
function UserAvatar() {
  const { state } = useContext(AppContext);
  return ;
}

迁移分三步走:

第一步:创建独立的store文件

// store/userStore.js - 迁移后 Zustand版本
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export const useUserStore = create(
  persist(
    (set, get) => ({
      userInfo: null,
      permissions: [],
      theme: 'light',
      // 新增一个收藏列表,原来放在组件state里,现在提出来
      favorites: [],

      // actions
      setUserInfo: (userInfo) => set({ userInfo }),
      setTheme: (theme) => set({ theme }),
      togglePermission: (perm) => set((state) => ({
        permissions: state.permissions.includes(perm)
          ? state.permissions.filter(p => p !== perm)
          : [...state.permissions, perm]
      })),
      addFavorite: (id) => set((state) => ({
        favorites: [...state.favorites, id]
      })),

      // 带逻辑的action
      login: async (credentials) => {
        const res = await fetch('/api/login', { 
          method: 'POST',
          body: JSON.stringify(credentials) 
        });
        const data = await res.json();
        set({ userInfo: data.user, permissions: data.permissions });
      },
    }),
    {
      name: 'user-storage', // persist到localStorage的key
      partialize: (state) => ({ 
        userInfo: state.userInfo,
        theme: state.theme 
      }), // 只持久化部分字段
    }
  )
);

第二步:替换Context消费者

这个过程中最大的技巧是使用shallow比较函数来避免不必要的重渲染:

// 迁移后 - 在组件中使用Zustand
import { useUserStore } from '../store/userStore';
import { shallow } from 'zustand/shallow';

function UserAvatar() {
  // 用selector只取需要的字段,第二个参数用shallow做浅比较
  const [userInfo, theme] = useUserStore(
    (state) => [state.userInfo, state.theme],
    shallow
  );
  // 之前用Context时,这里每次state变化都会重渲染
  // 现在只有userInfo或theme变化才触发
  return (



  );
}

// 在非组件模块中使用(这是Context做不到的)
// api/request.js
import { useUserStore } from '../store/userStore';

export function request(config) {
  const token = useUserStore.getState().userInfo?.token;
  // 直接读取store,不需要hooks
  return fetch(config.url, {
    headers: { Authorization: `Bearer ${token}` }
  });
}

第三步:删除Context Provider及相关代码

在App.jsx里删除`包裹,同时清理所有useContext(AppContext)`的引用。

4.2 Vue项目:从Props钻取到Pinia

Vue项目迁移前,是一个经典的Props钻取结构。以权限按钮为例:

    删除




const props = defineProps({
  permissions: { type: Array, required: true },
  userRole: { type: String, required: true },
  // ...还有好几个权限相关props
});

迁移到Pinia的步骤:

第一步:定义store

// stores/auth.js - Pinia store
import { defineStore } from 'pinia';

export const useAuthStore = defineStore('auth', {
  state: () => ({
    userInfo: null,
    permissions: [],
    role: 'guest',
    theme: 'light',
    lastLoginTime: null,
  }),

  getters: {
    // 带参数的getter,需要返回一个函数
    hasPermission: (state) => (perm) => {
      return state.permissions.includes(perm) || state.role === 'admin';
    },
    isAdmin: (state) => state.role === 'admin',
  },

  actions: {
    async fetchUserInfo() {
      const { data } = await api.get('/user/info');
      this.userInfo = data;
      this.permissions = data.permissions;
      this.role = data.role;
      this.lastLoginTime = Date.now();
    },
    setTheme(theme) {
      this.theme = theme;
      // 这里可以直接操作state,不需要commit mutation
    }
  }
});

第二步:在组件中使用

    删除




import { useAuthStore } from '@/stores/auth';

const authStore = useAuthStore();

// storeToRefs用于解构时保持响应性
// 但注意getter带参数时不能直接用storeToRefs,需要单独处理
const { permissions, role } = storeToRefs(authStore);

第三步:清理Props链

在Page.vue、Card.vue、Content.vue、List.vue、Item.vue里逐一删除permissions相关的props声明和传递。这一步最容易漏,建议用IDE的全局搜索确认。

五、踩坑与优化记录

React项目踩的坑

坑1:Zustand v4的create导入路径变了

// 错误 - 会报错
import create from 'zustand';
// 正确 - v4需要从'zustand'导入create
import { create } from 'zustand';

这个坑耗时30分钟,因为网上大部分教程是v3的写法。

坑2:selector返回新对象导致无限循环

// 错误 - 每次都会返回新数组,导致组件无限重渲染
const permissions = useUserStore((state) => {
  return state.permissions.filter(p => p.startsWith('view_'));
});

// 正确 - 用useShallow包装
import { useShallow } from 'zustand/react/shallow';
const permissions = useUserStore(
  useShallow((state) => state.permissions.filter(p => p.startsWith('view_')))
);

这个坑出现的频率很高,团队里三个人都踩过。

坑3:与React 18 StrictMode共存时的双调用问题

由于StrictMode在开发模式下会double-invoke,导致store的action被调用两次。解决办法是在store外部做幂等处理:

let fetchPromise = null;
const fetchUser = () => {
  if (!fetchPromise) {
    fetchPromise = api.get('/user').then(data => {
      useUserStore.setState({ userInfo: data });
    });
  }
  return fetchPromise;
};

Vue项目踩的坑

坑1:Pinia store在组件外使用需要先传入pinia实例

// main.js
const pinia = createPinia();
app.use(pinia);

// 在路由守卫里使用store
import { useAuthStore } from '@/stores/auth';

router.beforeEach((to, from, next) => {
  // 错误 - 此时pinia还没有被安装
  // const authStore = useAuthStore();

  // 正确 - 需要传入pinia实例
  const authStore = useAuthStore(pinia);
  next();
});

这个坑在Vue官方文档有写,但很容易忽略。

坑2:getter返回函数时,无法用storeToRefs解构

// 错误 - hasPermission是函数,不能用storeToRefs
const { hasPermission } = storeToRefs(authStore); 
// hasPermission会变成一个ref,但调用hasPermission()会报错

// 正确 - 直接从store上取
const { hasPermission } = authStore;

性能优化:按需订阅store的一部分

Pinia的$subscribe默认是深度监听的,在性能敏感场景可以改为浅监听:

authStore.$subscribe((mutation, state) => {
  // 处理state变化
}, { 
  detached: true, // 组件卸载后仍然订阅
  deep: false, // 改为浅监听,只有直接修改state时才触发
});

六、效果数据:迁移前后的性能对比

经过了大约两周的迁移工作(两个项目并行),我重新跑了相同的性能测试:

React项目效果:
- 组件重渲染数量:从平均27次/交互 降至 9次/交互(下降67%)
- 首屏TTI:3.2s -> 2.1s(主要受益于移除了Context导致的额外渲染)
- 包体积:Zustand仅2.1KB gzipped,相比直接删除的Context+useReducer代码反而小了
- 调试体验:React DevTools里可以清晰地看到每个store的更新来源

Vue项目效果:
- Props传递链:从最长6层 变为 0层直达store
- 单页面平均Props声明数:从18个 降至 4个(仅保留组件自身需要的)
- 交互延迟:210ms -> 95ms(高刷新率显示器下能明显感觉到不再是“肉肉的”)
- 代码量:全局删除了约400行props声明和透传代码

七、总结与建议

如果你正在考虑从Context/Props迁移到状态管理库,我的建议是:

  1. 不要为了用而用。如果组件树小于3层,或者状态只在兄弟组件间共享,直接用Props或简单的事件总线就够了。Context和Props的“痛感”通常在组件树超过5层且状态被多个分支消费时才明显。

  2. Zustand vs Pinia:如果你在React项目里,Zustand的轻量和无Provider设计非常舒服;Vue项目里Pinia已经是事实标准,Vuex可以停更了。两者上手成本都很低,一个下午就能完成初版迁移。

  3. 迁移策略:推荐“渐进式迁移”而非彻底重写。先建好store,然后逐个页面替换。我用了一个小脚本自动扫描所有Context/Props引用,生成迁移清单,效率翻倍。

  4. 性能验证:迁移后一定要用性能工具重新测量(React Profiler、Vue Devtools Performance Tab),不能只凭“感觉变快了”。

最后,状态管理只是一个工具,它不解决所有问题。如果你的状态逻辑本身很混乱,迁移到任何库都会同样混乱。先理清状态边界,再选工具,最后动手,这是我的真实经验。