一、问题背景:Context 和 Props 在中期项目里有多痛
我们维护的是一个后台管理系统,前端由两个子应用组成:React 18.2 负责数据看板与配置中心,Vue 3.3 负责表单流程与权限管理。项目初期,状态管理方案很“原生”:
- React 侧:全局状态用
Context + useReducer,局部状态用 Props 逐层传递。 - Vue 侧:跨层级通信用
provide/inject,兄弟组件通信用 Props + emit。
一开始没问题,直到组件树深度超过 5 层、全局状态超过 12 个切片后,问题集中爆发:
- React Context 的“全量重渲染”:只要 Context value 变化,所有
useContext的组件都会 re-render。我们的AppContext里同时放着用户信息、主题、权限、通知,改一个通知未读数,整个页面都跟着渲染。 - Vue provide/inject 的响应式陷阱:inject 的值如果直接解构,会丢失响应性;如果不解构,模板里写
user.name又容易在 undefined 时炸掉。 - Props 钻透(prop drilling):一个
handleSubmit从页面组件传到第 4 层子组件,中间两层完全不关心这个函数,却被迫声明并传递。 - 调试困难:状态变更没有时间旅行,没有 devtools 快照,排查“谁改了状态”全靠
console.log。
我们决定迁移,目标很明确:减少无效渲染、降低组件耦合、提升可调试性。
二、环境与版本
- React:18.2.0
- Vue:3.3.4
- 构建工具:Vite 4.4.9
- 包管理:pnpm 8.6.12
- 原方案:React Context + useReducer;Vue provide/inject + Props
- 新方案:Zustand 4.4.1(React);Pinia 2.1.6(Vue)
- 性能测试工具:React DevTools Profiler、Vue DevTools、Chrome Performance 面板
- 测试页面:权限配置页(约 120 个表单项,嵌套 4 层组件)
选择 Zustand 和 Pinia 的理由:
- Zustand:无 Provider 包裹,API 极简,支持选择器(selector)细粒度订阅,包体积约 1.2KB(gzip)。
- Pinia:Vue 官方推荐,TypeScript 支持好,支持 setup 语法,devtools 集成完善,包体积约 1.5KB(gzip)。
我们没有选 Redux Toolkit 和 Vuex,原因是:Redux Toolkit 模板代码仍偏多,Vuex 在 Vue 3 中已进入维护模式,Pinia 更符合组合式 API 的心智模型。
三、方案设计:迁移策略与边界
迁移不是“一刀切”。我们定了三条规则:
- 全局共享状态 → 状态管理库。例如用户信息、权限、主题、通知。
- 跨 3 层以上且频繁更新的状态 → 状态管理库。
- 纯 UI 局部状态(如弹窗开关、输入框临时值)→ 继续用
useState/ref。
React 侧设计:
- 按业务域拆 store:
useUserStore、usePermissionStore、useNotificationStore。 - 所有组件通过
useStore(selector)订阅,避免全量订阅。 - 异步逻辑放在 store 的 action 中,不写在组件里。
Vue 侧设计:
- 按业务域拆 store:
useUserStore、usePermissionStore、useFormStore。 - 使用
storeToRefs保持响应性。 - 表单草稿状态放入 Pinia,利用
$subscribe做自动保存。
四、核心实现:代码与迁移步骤
4.1 React:从 Context 到 Zustand
迁移前(Context + useReducer):
// AppContext.jsx
import { createContext, useReducer, useContext } from 'react';
const AppContext = createContext();
const initialState = {
user: null,
notifications: [],
theme: 'light',
};
function reducer(state, action) {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload };
case 'ADD_NOTIFICATION':
return { ...state, notifications: [...state.notifications, action.payload] };
case 'SET_THEME':
return { ...state, theme: action.payload };
default:
return state;
}
}
export function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
{children}
);
}
export function useApp() {
return useContext(AppContext);
}
组件里使用:
function NotificationBadge() {
const { state } = useApp();
return {state.notifications.length};
}
问题:state 整个对象变化时,NotificationBadge 也会因为 user 或 theme 变化而重渲染。
迁移后(Zustand):
// stores/useAppStore.js
import { create } from 'zustand';
export const useAppStore = create((set, get) => ({
user: null,
notifications: [],
theme: 'light',
setUser: (user) => set({ user }),
addNotification: (n) =>
set((state) => ({ notifications: [...state.notifications, n] })),
setTheme: (theme) => set({ theme }),
// 异步 action
fetchUser: async () => {
const res = await fetch('/api/user');
const user = await res.json();
set({ user });
},
}));
组件里使用选择器:
import { useAppStore } from './stores/useAppStore';
function NotificationBadge() {
const count = useAppStore((s) => s.notifications.length);
return {count};
}
function ThemeToggle() {
const theme = useAppStore((s) => s.theme);
const setTheme = useAppStore((s) => s.setTheme);
return (
setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme}
);
}
关键点:useAppStore((s) => s.notifications.length) 只在长度变化时触发重渲染,user 或 theme 变化不影响它。
迁移步骤(React 侧):
- 安装:
pnpm add zustand@4.4.1 - 按业务域创建 store 文件,把 Context 中的 state 和 dispatch 逻辑搬进 store。
- 删除
AppProvider,在main.jsx中不再包裹 Provider。 - 逐组件替换
useApp()为useAppStore(selector),优先替换高频更新组件。 - 用 React DevTools Profiler 对比迁移前后的渲染次数。
4.2 Vue:从 provide/inject 到 Pinia
迁移前(provide/inject + Props):
import { provide, ref } from 'vue';
const user = ref(null);
const permissions = ref([]);
provide('user', user);
provide('permissions', permissions);
import { inject } from 'vue';
const user = inject('user');
{{ user?.name }}
问题:inject 的值如果被解构会丢失响应性;user 为 null 时模板需要可选链;跨组件修改状态没有统一入口。
迁移后(Pinia):
// stores/useUserStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useUserStore = defineStore('user', () => {
const user = ref(null);
const permissions = ref([]);
const isAdmin = computed(() =>
permissions.value.includes('admin')
);
async function fetchUser() {
const res = await fetch('/api/user');
user.value = await res.json();
}
function setPermissions(list) {
permissions.value = list;
}
return { user, permissions, isAdmin, fetchUser, setPermissions };
});
组件里使用:
import { storeToRefs } from 'pinia';
import { useUserStore } from '@/stores/useUserStore';
const userStore = useUserStore();
const { user, isAdmin } = storeToRefs(userStore);
userStore.fetchUser();
{{ user?.name }}
管理员
迁移步骤(Vue 侧):
- 安装:
pnpm add pinia@2.1.6 - 在
main.js中注册:app.use(createPinia()) - 按业务域创建 store,把 provide/inject 的 ref 和修改逻辑搬进 store。
- 删除
provide调用,组件改为useXxxStore()+storeToRefs。 - 用 Vue DevTools 的 Timeline 对比迁移前后的组件更新次数。
五、踩坑与优化
坑 1:Zustand 选择器返回新对象导致无限重渲染。
错误写法:
const { user, theme } = useAppStore((s) => ({ user: s.user, theme: s.theme }));
每次返回新对象,浅比较失败,组件无限渲染。解决:用 useShallow 或分开写两个选择器。
import { useShallow } from 'zustand/react/shallow';
const { user, theme } = useAppStore(
useShallow((s) => ({ user: s.user, theme: s.theme }))
);
坑 2:Pinia 解构丢失响应性。
错误写法:
const { user } = useUserStore(); // user 不是响应式的
解决:必须用 storeToRefs。
坑 3:异步 action 中的竞态。
在 fetchUser 中,如果连续调用两次,后返回的可能覆盖先返回的。我们在 store 里加了请求 ID:
let requestId = 0;
async function fetchUser() {
const id = ++requestId;
const res = await fetch('/api/user');
const data = await res.json();
if (id === requestId) user.value = data;
}
优化:持久化。 Zustand 用 persist 中间件,Pinia 用 pinia-plugin-persistedstate,把主题和用户偏好存入 localStorage,减少首屏请求。
六、效果数据
在权限配置页(120 个表单项,4 层嵌套)上,用 React DevTools Profiler 和 Vue DevTools 测量:
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| 首屏渲染时间 | 1.8s | 1.2s | -33% |
| 复杂表单页无效重渲染次数 | 约 180 次/操作 | 约 50 次/操作 | -72% |
| 状态管理相关代码行数 | 约 620 行 | 约 400 行 | -35% |
| 包体积(gzip) | 0(原生) | +1.2KB / +1.5KB | 可接受 |
| 状态调试耗时(主观) | 高 | 低 | 明显改善 |
首屏渲染提升主要来自:去掉了 Context Provider 的包裹层级,减少了全量重渲染。无效重渲染下降主要来自:选择器订阅和 storeToRefs 的细粒度更新。
七、总结
从 Context/Props 迁移到 Zustand/Pinia,不是“为了用库而用库”,而是当组件树变深、全局状态变多、性能开始报警时,原生方案的心智负担和渲染开销会超过引入库的成本。
我们的建议:
- 小项目、状态少于 5 个切片,Context 和 provide/inject 够用,不必过早引入。
- 中型以上项目,优先按业务域拆 store,不要建一个巨大的全局 store。
- React 侧务必用选择器,Vue 侧务必用
storeToRefs,否则性能优势发挥不出来。 - 迁移可以渐进式,先迁高频更新组件,用 Profiler 验证收益后再全面铺开。
这次迁移后,我们的状态管理代码更集中、调试更轻松,性能数据也达到了预期。如果你正在经历类似的痛点,希望这篇记录能帮你少踩几个坑。