一、背景:当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迁移到状态管理库,我的建议是:
-
不要为了用而用。如果组件树小于3层,或者状态只在兄弟组件间共享,直接用Props或简单的事件总线就够了。Context和Props的“痛感”通常在组件树超过5层且状态被多个分支消费时才明显。
-
Zustand vs Pinia:如果你在React项目里,Zustand的轻量和无Provider设计非常舒服;Vue项目里Pinia已经是事实标准,Vuex可以停更了。两者上手成本都很低,一个下午就能完成初版迁移。
-
迁移策略:推荐“渐进式迁移”而非彻底重写。先建好store,然后逐个页面替换。我用了一个小脚本自动扫描所有Context/Props引用,生成迁移清单,效率翻倍。
-
性能验证:迁移后一定要用性能工具重新测量(React Profiler、Vue Devtools Performance Tab),不能只凭“感觉变快了”。
最后,状态管理只是一个工具,它不解决所有问题。如果你的状态逻辑本身很混乱,迁移到任何库都会同样混乱。先理清状态边界,再选工具,最后动手,这是我的真实经验。