一、为什么我们要从Context/Props里“逃”出来?

去年Q3接手一个B端后台管理系统,技术栈是React 18 + TypeScript。当时项目已经迭代了半年,状态管理主要靠React Context + useReducer + Props层层传递。说几个让人崩溃的细节:

  • 一个“用户编辑”弹窗,需要从根组件传递currentUserupdateUserrolesListpermissionsMap等7个props,经过4层中间组件才能到达弹窗组件。
  • 任意一个状态更新,比如切换一个开关,会触发Context下所有消费组件的重渲染。React Devtools Profiler显示,一次开关切换触发了42个组件重新渲染,其中37个组件实际上与这个状态无关。
  • Vue 3版本的项目(另一个团队维护)则是Vuex 3 + 组件内data,每次修改一个深层嵌套的对象,必须手动Vue.set或者用展开运算符复制,否则视图不更新。

结论:当项目规模超过20个组件、状态变量超过20个时,Context/Props模式会显著拖慢开发效率和运行时性能。我们需要更专业的状态管理方案。

二、环境与版本:我们选型的基准

技术栈 版本 说明
React 18.2.0 使用 create-react-app 构建
Vue 3.3.4 Vite 5.0 构建
Zustand 4.5.0 轻量级React状态库
Pinia 2.1.7 Vue3官方推荐状态库
浏览器 Chrome 120 用于性能测试(Lighthouse模拟Moto G4)

选型标准:**包体积 void;
permissions: string[];
}>({} as any);

// Provider包裹整个App

// 在深层组件中使用
function UserProfile() {
const { user, updateUser } = useContext(UserContext);
// 这里user变化,即使UserProfile不依赖user,也会重渲染
}

**问题**:Context的值一变化,所有消费组件都重渲染,即使它们只使用了`updateUser`函数。

### 4.2 迁移后的Zustand代码

```tsx
// stores/userStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface UserStore {
  user: User | null;
  permissions: string[];
  updateUser: (user: User) => void;
  fetchPermissions: () => Promise;
}

export const useUserStore = create()(
  devtools(
    (set) => ({
      user: null,
      permissions: [],
      updateUser: (user) => set({ user }),
      fetchPermissions: async () => {
        const res = await api.get('/permissions');
        set({ permissions: res.data });
      },
    }),
    { name: 'UserStore' }
  )
);

// 在组件中使用 - 只有真正依赖user的组件才会重渲染
function UserProfile() {
  const user = useUserStore((state) => state.user);
  const updateUser = useUserStore((state) => state.updateUser);
  // 只有user变化时才重渲染,updateUser引用稳定
}

关键点:zustand通过选择器(selector)实现了细粒度的订阅。只有选择器返回的值变化时,组件才会重渲染。这比Context的“一刀切”好太多。

五、从Vuex到Pinia(Vue侧)

5.1 迁移前的Vuex代码(问题代码)

// store/modules/user.js
export default {
  state: {
    user: null,
    permissions: [],
  },
  mutations: {
    SET_USER(state, user) {
      state.user = user; // 需要手动处理响应式
    },
  },
  actions: {
    fetchUser({ commit }, id) {
      commit('SET_USER', { id, name: 'Alice' });
    },
  },
};

问题:Vuex的mutation和action分离,代码跳转困难。每次修改嵌套对象必须用Vue.set或深拷贝。

5.2 迁移后的Pinia代码

// stores/userStore.ts
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as User | null,
    permissions: [] as string[],
  }),
  getters: {
    isAdmin: (state) => state.permissions.includes('admin'),
  },
  actions: {
    async fetchUser(id: number) {
      // 直接修改state,自动响应式
      this.user = { id, name: 'Alice' };
    },
    updatePermission(perm: string) {
      this.permissions.push(perm); // 不再需要Vue.set
    },
  },
});

Pinia去掉了mutation,action直接修改state,配合TypeScript推导,开发体验提升明显。而且Pinia支持Devtools时间旅行调试,这在Vuex时代需要额外配置。

六、踩坑与优化:两个不得不说的“坑”

坑1:Zustand的“幽灵状态”

迁移初期,我们遇到了一个诡异问题:某个store中的状态在组件中读取为undefined,但Devtools中显示有值。排查了两小时,发现是selector返回了新的对象引用

// ❌ 错误写法:每次返回新对象
const userInfo = useUserStore((state) => ({
  name: state.user?.name,
  role: state.user?.role,
}));

// ✅ 正确写法:使用shallow比较
import { shallow } from 'zustand/shallow';
const [name, role] = useUserStore(
  (state) => [state.user?.name, state.user?.role],
  shallow
);

原因:Zustand默认使用Object.is比较selector返回值。如果返回一个新构建的对象,每次都会认为值变化,导致无限重渲染。

坑2:Pinia的action依赖死循环

Vue侧,我们在一个action里调用了另一个store的action,结果导致了循环调用:

// userStore.ts
async fetchUser() {
  const orderStore = useOrderStore();
  await orderStore.fetchOrders(); // 这里间接调用了fetchUser
}

// orderStore.ts
async fetchOrders() {
  const userStore = useUserStore();
  if (!userStore.user) {
    await userStore.fetchUser(); // 又调回来了
  }
}

解决:重构为事件总线模式,或者用watch监听状态变化触发副作用,而不是在action里交叉调用。最终我们用了Pinia的$subscribe方法:

// 在App.vue中监听
userStore.$subscribe((mutation, state) => {
  if (state.user) {
    orderStore.fetchOrders();
  }
});

七、效果数据:从300ms到114ms

迁移完成后,我们做了一组性能对比测试(使用Chrome Performance面板,模拟Moto G4,Fast 3G网络):

场景 迁移前 (Context/Props) 迁移后 (Zustand/Pinia) 提升
点击“切换主题”按钮 42次重渲染,耗时312ms 3次重渲染,耗时48ms 84.6%
打开用户编辑弹窗 传递7层props,耗时89ms 直接读取store,耗时22ms 75.3%
页面路由切换(Vue) 需要手动重置Vuex state Pinia自动重置,0ms负担 优化开发体验
包体积增加 0KB(原方案无额外库) Zustand 4.2KB + Pinia 5.1KB 可接受

最直观的感受:之前点击一个按钮,页面会“卡”一下才响应;现在点击后几乎瞬间更新。开发调试时,Zustand的Devtools可以直接看到哪个组件订阅了哪个状态,排查问题效率提升50%。

八、总结与建议

  1. 不要过早引入状态管理库:小项目(<10个组件)用Context/Vuex够用。
  2. 迁移前先做状态分层:分清全局、页面、局部状态,避免“为了用store而用store”。
  3. Zustand更适合React:因为它基于hooks,与React的渲染模型天然契合。Pinia是Vue3的首选,比Vuex轻且类型推导好。
  4. 注意选择器的引用稳定性:避免在Zustand中返回新对象,使用shallow或拆分selector。
  5. Vue侧避免store交叉调用:用$subscribewatch替代。

最后,没有银弹。状态管理库解决的是“跨组件通信”和“渲染性能”问题,但它也带来了包体积和额外的概念。如果你的项目还处在“父子组件传参”阶段,先别急着上store——用你的直觉判断:这个状态需要被三个以上不相关的组件共享吗? 如果否,用Props或emit;如果是,再考虑迁移。

(完)