一、问题背景:为什么我受够了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里放什么状态,需要慎重考虑。