一、问题背景:为什么我受够了Props和Context

先说说我的处境。我在维护两个项目:一个是React 16的老后台管理系统(约200个组件),另一个是Vue 2的电商中台(约150个组件)。这两个项目有个通病——组件树深度超过5层时,状态传递变得极其痛苦。

以React项目为例,用户登录信息(userInfo)需要从顶层App传到第五层的UserAvatar组件。传统做法是通过Props层层传递,中间四个组件实际上完全不使用这个数据,但为了传递不得不写`。后来换了Context,确实解决了传递问题,但带来了新的麻烦:Context一旦更新,所有消费该Context的组件都会重新渲染,而且Context的value如果是个对象,每次setState`都会生成新引用,导致性能雪崩。

Vue 2项目更惨,用EventBus传来传去,代码可维护性极差,事件名拼写错误直接静默失败。

二、环境与版本:迁移前的技术栈

React项目:
- React 16.14.0 → React 18.2.0
- react-redux 7.2.9(未使用)
- Zustand 4.4.7(迁移后)
- TypeScript 4.9.5
- Webpack 5.75.0

Vue项目:
- Vue 2.6.14 → Vue 3.3.4
- Vuex 3.6.2 → Pinia 2.1.7
- Vite 4.4.9
- TypeScript 5.1.6

选择Zustand的理由:API极简,不需要包Provider,支持selector精确订阅,避免多余渲染。选择Pinia的理由:Vue 3官方推荐,完整支持TS,模块化设计清晰。

三、方案设计:两种状态管理库的架构对比

3.1 Zustand方案设计

核心思路:将全局状态拆分为独立的store,通过selector订阅特定状态片段。

// store/userStore.ts
import { create } from 'zustand';

interface UserState {
  userInfo: { name: string; role: string };
  login: (info: UserInfo) => void;
  logout: () => void;
}

export const useUserStore = create((set) => ({
  userInfo: { name: '', role: 'guest' },
  login: (info) => set({ userInfo: info }),
  logout: () => set({ userInfo: { name: '', role: 'guest' } }),
}));

关键点:create函数返回一个hook,组件直接调用useUserStore((state) => state.userInfo)即可订阅,只有userInfo变化时才触发重渲染。

3.2 Pinia方案设计

Pinia采用类似Vuex的模块化结构,但去掉了mutation,action直接修改state。

// store/cart.ts
import { defineStore } from 'pinia';

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as CartItem[],
    totalPrice: 0,
  }),
  getters: {
    itemCount: (state) => state.items.length,
  },
  actions: {
    addItem(item: CartItem) {
      this.items.push(item);
      this.totalPrice = this.items.reduce((sum, i) => sum + i.price, 0);
    },
    clearCart() {
      this.items = [];
      this.totalPrice = 0;
    },
  },
});

四、核心实现:逐步迁移代码

4.1 React迁移步骤

第一步:替换Context Provider

原代码:

// 原Context方式
const UserContext = createContext({ userInfo: {}, login: () => {} });

function App() {
  const [userInfo, setUserInfo] = useState({});
  return (



  );
}

迁移后:

// 迁移后只需在需要的地方调用useUserStore
function UserAvatar() {
  const userInfo = useUserStore((state) => state.userInfo);
  return {userInfo.name};
}

第二步:处理异步操作

原代码在Context里通过useEffect + useReducer管理登录请求。迁移后直接在store中处理异步:

export const useUserStore = create((set) => ({
  userInfo: { name: '', role: 'guest' },
  loading: false,
  login: async (credentials) => {
    set({ loading: true });
    try {
      const res = await api.login(credentials);
      set({ userInfo: res.data, loading: false });
    } catch (error) {
      set({ loading: false, error: error.message });
    }
  },
}));

第三步:删除冗余Context代码

迁移后删除了UserContext.tsx文件(约150行),相关组件代码从平均每次传递4层props减少到直接调用store。

4.2 Vue迁移步骤

第一步:安装并挂载Pinia

// main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

const app = createApp(App);
app.use(createPinia());
app.mount('#app');

第二步:替换组件内的props传递

原代码(Vue 2 方式):

export default {
  props: ['userInfo'],
  methods: {
    handleUpdate(data) {
      this.$emit('update-user', data);
    }
  }
}

迁移后:

import { useUserStore } from '@/stores/user';

const userStore = useUserStore();
// 直接使用userStore.userInfo,无需props传递

第三步:处理Vuex的getters和mutations

原来Vuex的this.$store.commit('addItem', item)改为const cartStore = useCartStore(); cartStore.addItem(item)

五、踩坑与优化:迁移过程中遇到的实际问题

5.1 Zustand的selector陷阱

坑: 直接返回对象导致无限循环渲染。

// 错误写法
const userInfo = useUserStore((state) => ({
  name: state.userInfo.name,
  role: state.userInfo.role,
}));
// 每次render都会生成新对象,触发无限循环

// 正确写法
const name = useUserStore((state) => state.userInfo.name);
const role = useUserStore((state) => state.userInfo.role);
// 或者用shallow比较
import { shallow } from 'zustand/shallow';
const userInfo = useUserStore((state) => ({
  name: state.userInfo.name,
  role: state.userInfo.role,
}), shallow);

优化: 使用useShallow hook(Zustand 4.4+)简化浅比较逻辑。

5.2 Pinia的响应式丢失

坑: 在store外部解构state会丢失响应性。

// 错误写法
const { items, totalPrice } = useCartStore();
// items不是响应式的

// 正确写法
const cartStore = useCartStore();
const items = computed(() => cartStore.items);
const totalPrice = computed(() => cartStore.totalPrice);

优化: 使用storeToRefs API保持响应性。

import { storeToRefs } from 'pinia';
const { items, totalPrice } = storeToRefs(useCartStore());

5.3 性能优化:精细化订阅

对高频更新的状态(如鼠标位置、表单输入),Zustand允许创建临时store避免全局渲染:

const useMouseStore = create(() => ({ x: 0, y: 0 }));

function useMousePosition() {
  useEffect(() => {
    const handler = (e) => useMouseStore.setState({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handler);
    return () => window.removeEventListener('mousemove', handler);
  }, []);
  return useMouseStore();
}

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

6.1 React项目数据

使用React Profiler记录关键页面(Dashboard、UserManagement)的渲染时间:

指标 迁移前(Context) 迁移后(Zustand) 提升幅度
首屏渲染时间 2.4s 1.85s 22.9%
组件重渲染次数 37次 12次 67.6%
内存占用 48MB 32MB 33.3%
代码量(store相关) 680行 390行 42.6%

6.2 Vue项目数据

使用Lighthouse和Vue Devtools分析:

指标 迁移前(Vuex) 迁移后(Pinia) 提升幅度
交互响应时间 180ms 124ms 31.1%
组件更新次数 45次 18次 60%
Bundle大小 328KB 291KB 11.3%
首屏可交互时间 3.2s 2.7s 15.6%

6.3 团队开发效率

迁移后代码审查时间减少约40%,因为不再需要检查props传递是否正确。新功能开发时,开发者直接调用store方法,不需要理解组件树结构。

七、总结与建议

这次迁移让我深刻体会到:状态管理库不是万能药,但选对了能极大提升开发体验和性能

  • Zustand适合React:尤其适合中小型项目,API极简,学习成本低,配合TypeScript体验极佳。但要注意selector的引用相等性陷阱。
  • Pinia是Vue 3的标配:相比Vuex,少了mutation的冗余步骤,模块化更清晰。唯一缺点是生态不如Vuex成熟,但官方维护足够稳定。

建议:
1. 不要为了用状态管理库而用,如果组件树少于3层,Props完全够用。
2. 迁移前先梳理全局状态,区分哪些是真正需要共享的,避免把所有状态都塞进store。
3. 迁移过程建议分模块逐步进行,不要一次性全部替换,以便快速回滚。

最后提醒一句:状态管理库永远只是工具,核心还是你如何设计状态边界。过度使用同样会带来性能问题——每次setState都会触发订阅者的更新,所以在store里放什么状态,需要慎重考虑。