一、问题背景:Context 和 Props 不是不能用,而是用久了会疼
我们维护的是一个中型后台管理系统,技术栈分两部分:新页面用 React 18 + TypeScript + Vite,老页面逐步迁移到 Vue 3 + TypeScript + Vite。两个技术栈并存,状态管理方式也各不相同。
React 侧最初采用 Context + useReducer 管理全局状态,比如用户信息、权限、主题、侧边栏折叠等。局部状态则通过 props 逐层传递。Vue 侧则大量使用 props + emit,偶尔用 provide/inject 跨层级传值。
一开始没问题。但随着页面增多,问题逐渐暴露:
- React Context 的“全量更新”问题:只要 Context value 变化,所有消费该 Context 的组件都会重新渲染。我们的用户信息 Context 和权限 Context 放在同一个 Provider 里,切换主题时会导致权限相关组件也重渲染。
- Props drilling 层级过深:一个表格页面,从页面组件到表格单元格,最多传了 6 层 props。改一个字段名,要改 6 个文件。
- Vue 侧 provide/inject 类型推断弱:注入的 key 是字符串,重构时容易漏改,且 inject 的默认值处理不优雅。
- 性能可观测性差:React Profiler 显示,在 1000 条数据的列表页,一次筛选操作触发的重渲染耗时约 180ms,其中约 60ms 花在 Context 引起的无关组件更新上。
于是我们决定:React 侧引入 Zustand 4.5,Vue 侧引入 Pinia 2.1。选型理由后面细说。
二、环境与版本
- React:18.2.0
- Vue:3.3.4
- TypeScript:5.2.2
- Vite:4.4.9
- Zustand:4.5.0
- Pinia:2.1.6
- 性能测试工具:React DevTools Profiler、Vue DevTools Performance、Chrome Performance 面板
- 测试数据:1000 条用户列表,每行 8 列,支持筛选、排序、分页
三、方案设计:为什么选 Zustand 和 Pinia
React 侧对比:
| 方案 | 包体积 | 学习成本 | 重渲染控制 | 适合场景 |
|---|---|---|---|---|
| Context + useReducer | 0 | 低 | 差 | 低频更新、层级浅 |
| Redux Toolkit | 约 13KB | 中 | 好 | 大型、复杂状态 |
| Zustand | 约 1.2KB | 低 | 好 | 中小型、按需订阅 |
| Jotai | 约 3KB | 中 | 好 | 原子化状态 |
我们选 Zustand 的原因:API 极简,不需要 Provider 包裹,支持选择器(selector)按需订阅,且可以直接在组件外读取/修改状态。对于后台系统这种“全局状态不多,但更新频率中等”的场景,Zustand 的性价比最高。
Vue 侧对比:
| 方案 | 学习成本 | 类型推断 | 模块化 | 适合场景 |
|---|---|---|---|---|
| props + emit | 低 | 好 | 差 | 父子组件 |
| provide/inject | 中 | 弱 | 差 | 跨层级少量数据 |
| Vuex 4 | 中 | 中 | 好 | 老项目 |
| Pinia 2 | 低 | 好 | 好 | 新项目 |
Pinia 是 Vue 官方推荐,TypeScript 推断好,且支持组合式 API 写法。我们直接选了 Pinia。
四、核心实现:迁移步骤与代码
4.1 React 侧:从 Context 到 Zustand
迁移前(Context):
// UserContext.tsx
import { createContext, useContext, useReducer } from 'react';
const UserContext = createContext(null);
export function UserProvider({ children }) {
const [state, dispatch] = useReducer(userReducer, initialState);
return (
{children}
);
}
export function useUser() {
return useContext(UserContext);
}
// 某个深层组件
function UserName() {
const { state } = useUser();
return {state.name};
}
问题:state 任何字段变化,UserName 都会重渲染。
迁移后(Zustand):
// useUserStore.ts
import { create } from 'zustand';
interface UserState {
name: string;
permissions: string[];
theme: 'light' | 'dark';
setName: (name: string) => void;
toggleTheme: () => void;
}
export const useUserStore = create((set) => ({
name: '',
permissions: [],
theme: 'light',
setName: (name) => set({ name }),
toggleTheme: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
}));
// 组件中按需订阅
function UserName() {
const name = useUserStore((s) => s.name);
return {name};
}
function ThemeToggle() {
const theme = useUserStore((s) => s.theme);
const toggleTheme = useUserStore((s) => s.toggleTheme);
return {theme};
}
关键点:useUserStore((s) => s.name) 只订阅 name,theme 变化不会触发 UserName 重渲染。
迁移步骤:
- 安装
zustand@4.5.0。 - 按业务域拆分 store:
useUserStore、usePermissionStore、useTableStore。 - 将 Context 中的 state 和 dispatch 方法迁移到 store 的 state 和 action。
- 删除 Provider,组件内改用
useXxxStore(selector)。 - 对于需要计算的值,用
useMemo或 store 内的派生函数,避免在 selector 中返回新对象。
踩坑:selector 返回新对象导致无限重渲染。
错误写法:
const { name, theme } = useUserStore((s) => ({ name: s.name, theme: s.theme }));
因为每次返回新对象,Zustand 默认用 Object.is 比较,会认为状态变了。正确做法是用 shallow:
import { shallow } from 'zustand/shallow';
const { name, theme } = useUserStore(
(s) => ({ name: s.name, theme: s.theme }),
shallow
);
或者分开订阅:
const name = useUserStore((s) => s.name);
const theme = useUserStore((s) => s.theme);
4.2 Vue 侧:从 props/provide 到 Pinia
迁移前(props + provide):
import { provide, ref } from 'vue';
const user = ref({ name: '' });
const permissions = ref([]);
provide('user', user);
provide('permissions', permissions);
import { inject } from 'vue';
const user = inject('user');
迁移后(Pinia):
// stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useUserStore = defineStore('user', () => {
const name = ref('');
const permissions = ref([]);
const isAdmin = computed(() => permissions.value.includes('admin'));
function setName(newName: string) {
name.value = newName;
}
return { name, permissions, isAdmin, setName };
});
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
{{ userStore.name }}
管理员
迁移步骤:
- 安装
pinia@2.1.6,在main.ts中app.use(createPinia())。 - 按业务域创建 store 文件,使用组合式 API 写法。
- 将 provide/inject 的 key 替换为 store 的引用。
- 将 props 传递的字段改为直接从 store 读取。
- 对于需要持久化的状态,加
pinia-plugin-persistedstate。
踩坑:store 在组件外使用。
在路由守卫或 axios 拦截器中,需要先确保 Pinia 已安装。正确做法:
// main.ts
const pinia = createPinia();
app.use(pinia);
// 在守卫中
import { useUserStore } from '@/stores/user';
router.beforeEach((to) => {
const userStore = useUserStore(); // 此时 pinia 已激活
if (!userStore.name && to.name !== 'login') return { name: 'login' };
});
如果顺序反了,会报 “getActivePinia was called with no active Pinia”。
五、性能变化与效果数据
React 侧:
- 迁移前:1000 条列表筛选,Profiler 显示 commit 阶段耗时约 180ms,其中 Context 引起的无关组件重渲染约 60ms。
- 迁移后:同一操作 commit 阶段耗时约 45ms,无关组件重渲染基本消除。
- 首屏 JS 体积:增加约 1.2KB(gzip 后约 0.6KB),可忽略。
- 代码量:删除了 3 个 Context 文件、约 200 行 Provider 逻辑,组件内 props 传递减少约 35%。
Vue 侧:
- 迁移前:一个跨 4 层组件的表单,修改字段需要改 4 个文件,平均每次改动约 15 分钟。
- 迁移后:直接改 store,平均每次改动约 3 分钟。
- 组件通信代码减少约 40%。
- 运行时性能:Pinia 的响应式基于 Vue 的 reactive,性能与 provide/inject 相当,无明显下降。
可观测性提升:
Zustand 和 Pinia 都支持 DevTools。React 侧用 Redux DevTools 查看 Zustand 的 action 和 state 变化;Vue 侧用 Vue DevTools 的 Pinia 面板。排查状态问题时,不再需要到处打 console.log。
六、总结
从 Context/Props 迁移到 Zustand/Pinia,我们的核心收获不是“状态管理库一定更好”,而是:
- 按需订阅是性能关键。Context 的全量更新在中等规模应用里就是瓶颈,Zustand 的 selector 和 Pinia 的 computed 都能精准控制更新范围。
- 迁移成本可控。我们分两周完成,先迁全局状态,再迁局部跨层级状态。每个 store 独立迁移,不影响其他页面。
- 不要过度设计。如果项目只有两三个全局状态且更新频率低,Context 和 provide/inject 完全够用。引入状态管理库的时机是:出现性能问题、props 层级超过 4 层、或者多人协作需要明确的状态边界。
- 版本锁定很重要。Zustand 4.x 和 5.x 的 API 有差异,Pinia 2.x 和 3.x 也有变化,建议在 package.json 中锁定小版本,避免自动升级带来意外。
如果你也在犹豫是否迁移,建议先用 React Profiler 或 Vue DevTools 测一下当前的重渲染耗时。如果 commit 阶段超过 100ms 且与状态更新相关,那 Zustand/Pinia 值得一试。