一、问题背景:Context 和 Props 不是万能药
我们做的是一套同时包含 React 18 和 Vue 3 子应用的后台系统。React 侧最早用 Context + useReducer 管理全局状态,Vue 侧用 provide/inject + props。小规模时没问题,但随着权限、主题、用户信息、表单草稿这些状态越来越多,问题集中爆发:
- React Context 的“全量广播”:只要 Context value 变了,所有 Consumer 都会重新渲染。我们的
AppContext里同时放了用户信息、主题、侧边栏折叠状态,改一个主题色,整个页面 47 个组件重渲染。 - Props drilling 太深:一个
Table组件要把onRowSelect从页面传到第 5 层子组件,中间组件根本不关心这个 prop,但每次父级更新都要跟着渲染。 - Vue 侧 provide/inject 响应式丢失:用
reactive提供对象后,子组件解构后失去响应性,排查了半天。 - 性能数据:Lighthouse 测首屏 TTI 2.8s,React DevTools Profiler 显示一次主题切换 commit 耗时 340ms。
这时候我决定迁移到专门的状态管理库:React 用 Zustand 4.5.2,Vue 用 Pinia 2.1.7。
二、环境与版本
- React 18.2.0 + TypeScript 5.3 + Vite 5.0
- Vue 3.4.21 + TypeScript 5.3 + Vite 5.0
- 原状态方案:React Context + useReducer,Vue provide/inject + props
- 目标方案:Zustand 4.5.2(React),Pinia 2.1.7(Vue)
- 测试工具:React DevTools Profiler、Vue DevTools、Lighthouse 11.0
选 Zustand 而不是 Redux Toolkit,是因为我们不需要时间旅行调试和 middleware 生态,Zustand 包体积 1.2KB gzip,API 更接近 hooks。Pinia 则是 Vue 官方推荐,和 Vue 3 组合式 API 契合。
三、方案设计:按状态类型拆分 Store
迁移不是把所有 Context 都搬进 Store,而是先分类:
- 服务端状态:用户列表、订单数据 → 继续用 React Query / Vue Query,不放入全局 Store。
- 全局客户端状态:用户信息、主题、权限、侧边栏 → 放入 Store。
- 局部状态:表单输入、弹窗开关 → 留在组件内 useState/ref。
Store 拆分原则:按业务域拆,不按组件拆。React 侧建了 useUserStore、useThemeStore、usePermissionStore;Vue 侧对应 userStore、themeStore、permissionStore。
关键设计:选择器粒度要细。Zustand 的 useStore(state => state.xxx) 只在选中值变化时触发重渲染,这是性能提升的核心。
四、核心实现:代码与迁移步骤
4.1 React 侧:从 Context 到 Zustand
原来的 Context:
// 旧代码:AppContext.tsx
const AppContext = createContext(null);
export function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [collapsed, setCollapsed] = useState(false);
// value 每次都是新对象,所有 Consumer 重渲染
return (
{children}
);
}
迁移到 Zustand:
// 新代码:stores/useAppStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
interface AppState {
user: User | null;
theme: 'light' | 'dark';
collapsed: boolean;
setUser: (u: User | null) => void;
setTheme: (t: 'light' | 'dark') => void;
toggleCollapsed: () => void;
}
export const useAppStore = create()(
subscribeWithSelector((set) => ({
user: null,
theme: 'light',
collapsed: false,
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
toggleCollapsed: () => set((s) => ({ collapsed: !s.collapsed })),
}))
);
组件里按需选择:
// 只订阅 theme,user 变化不会触发这个组件重渲染
const theme = useAppStore((s) => s.theme);
const setTheme = useAppStore((s) => s.setTheme);
迁移步骤:
- 新建
stores目录,按域建文件。 - 把 Context 里的 state 和 action 平移到 Store。
- 全局搜索
useContext(AppContext),替换为对应的useAppStore(selector)。 - 删除 Provider 包裹,减少组件树层级。
- 用
subscribeWithSelector处理需要在 Store 外监听变化的场景,比如主题变化时更新document.documentElement.dataset.theme。
4.2 Vue 侧:从 provide/inject 到 Pinia
原来的 provide/inject:
// 旧代码:App.vue
const appState = reactive({ user: null, theme: 'light' });
provide('appState', appState);
// 子组件解构后失去响应性
const { theme } = inject('appState');
迁移到 Pinia:
// 新代码:stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useUserStore = defineStore('user', () => {
const user = ref(null);
const isAdmin = computed(() => user.value?.role === 'admin');
async function fetchUser() {
const res = await api.getUser();
user.value = res.data;
}
return { user, isAdmin, fetchUser };
});
组件中使用:
import { storeToRefs } from 'pinia';
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
// 用 storeToRefs 保持响应性,直接解构会丢失
const { user, isAdmin } = storeToRefs(userStore);
迁移步骤:
- 安装 Pinia,在
main.ts中app.use(createPinia())。 - 按域建 store,用 setup 语法写。
- 替换
inject为useXxxStore()。 - 注意:需要响应式的 state 用
storeToRefs,action 直接解构没问题。 - 删除
provide调用。
五、踩坑与优化
坑 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 解构丢失响应性。 必须用 storeToRefs,这个和 Vue 的 toRefs 一个道理。
坑 3:Store 里放服务端数据导致缓存逻辑复杂。 我们一开始把订单列表放进 Pinia,结果要自己写 loading、error、缓存失效。后来全部挪回 Vue Query,Store 只留客户端状态。
优化:持久化只持久化必要字段。 用户信息和主题用 persist 中间件存 localStorage,权限列表不存,避免权限变更后本地脏数据。
export const useThemeStore = create(
persist(
(set) => ({ theme: 'light', setTheme: (theme) => set({ theme }) }),
{ name: 'theme-storage', partialize: (s) => ({ theme: s.theme }) }
)
);
六、效果数据
迁移前后用同一套测试用例(10 个页面,含表格、表单、图表):
| 指标 | 迁移前 | 迁移后 |
|---|---|---|
| 主题切换重渲染组件数 | 47 | 6 |
| 主题切换 commit 耗时 | 340ms | 28ms |
| 首屏 TTI | 2.8s | 1.3s |
| 首屏 JS 体积(gzip) | 186KB | 189KB |
| 代码行数(状态相关) | 约 1200 行 | 约 780 行 |
JS 体积略增 3KB,因为引入了 Zustand 和 Pinia,但换来的渲染性能提升明显。代码行数减少是因为删掉了大量 Provider 嵌套和 props 透传。
七、总结
Context 和 provide/inject 适合低频、小范围的全局状态,比如主题、语言。一旦状态更新频繁、消费者众多,就该考虑 Zustand 或 Pinia。迁移的关键不是换 API,而是先分类状态:服务端状态交给 React Query / Vue Query,客户端全局状态才进 Store,局部状态留在组件。选择器粒度决定性能上限,Zustand 的 useShallow 和 Pinia 的 storeToRefs 是必须掌握的两个细节。如果你的项目也出现了“改一个值全页面重渲染”,不妨按这个思路试一次,收益比想象中大。