一、问题背景:Context 不是状态管理库
我们是一个中后台系统,前端同时存在 React 18.2 和 Vue 3.3 两套子应用(历史原因,别问)。状态层最初很“朴素”:
- React 侧:
UserContext+ThemeContext+PermissionContext,外加 20 多个 Props 层层透传。 - Vue 侧:
provide/inject+reactive全局单例。
问题在 2023 年 Q4 集中爆发:
- Context 值一变,所有消费组件全量重渲染。
UserContext里放了一个{ user, permissions, settings }对象,改个主题色会导致 80+ 组件重渲染。 - Props 透传链路长达 6 层,中间组件被迫接收自己根本不用的 props,类型定义越来越脏。
- 跨框架状态无法共享。登录态在 React 里改了,Vue 子应用要刷新页面才能同步。
我们决定迁移到专门的状态管理库。选型不是拍脑袋,下面是真实对比。
二、环境与版本
- React 18.2.0 + TypeScript 5.2.2 + Vite 4.4.9
- Vue 3.3.4 + TypeScript 5.2.2 + Vite 4.4.9
- 候选库:
- React:Zustand 4.4.1、Jotai 2.4.2、Redux Toolkit 1.9.5
- Vue:Pinia 2.1.6
- 测试设备:MacBook Pro M1 16G,Chrome 118,禁用缓存,Performance 面板采样 5 次取中位数。
三、方案设计:为什么最终选 Zustand + Pinia
React 侧我们做了 3 个维度的对比:
| 维度 | Context | Redux Toolkit | Jotai | Zustand |
|---|---|---|---|---|
| 重渲染粒度 | 全量 | 需 useSelector 精细选择 | 原子级 | selector 级 |
| 样板代码 | 少 | 多(slice/thunk) | 少 | 极少 |
| 包体积(gzip) | 0 | 12.6KB | 4.1KB | 1.2KB |
| 异步处理 | 手写 | createAsyncThunk | 手写 | 直接 async |
| 学习成本 | 低 | 高 | 中 | 低 |
Jotai 的原子模型很优雅,但我们的状态大多是“领域对象”(user、permission、dict),不是细粒度原子,用 Jotai 反而要拆得很碎。Redux Toolkit 太重,团队已经受够了 action type。最终选 Zustand 4.4.1:selector 订阅、无 Provider 包裹、1.2KB。
Vue 侧没有悬念,Pinia 2.1.6 是官方推荐,且支持 setup 语法,和 Composition API 心智一致。
四、核心实现
4.1 React:从 Context 迁移到 Zustand
迁移前的 Context 写法:
// before: UserContext.tsx
const UserContext = createContext(null);
export function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [permissions, setPermissions] = useState([]);
const [theme, setTheme] = useState('light');
// 注意:value 每次渲染都是新对象,导致所有消费者重渲染
return (
{children}
);
}
迁移后的 Zustand store:
// after: useUserStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
interface UserState {
user: User | null;
permissions: string[];
theme: 'light' | 'dark';
setUser: (u: User | null) => void;
setTheme: (t: 'light' | 'dark') => void;
hasPermission: (code: string) => boolean;
}
export const useUserStore = create()(
subscribeWithSelector((set, get) => ({
user: null,
permissions: [],
theme: 'light',
setUser: (user) => set({ user, permissions: user?.permissions ?? [] }),
setTheme: (theme) => set({ theme }),
hasPermission: (code) => get().permissions.includes(code),
}))
);
组件里按需订阅,关键是 selector 要返回原始值,不要返回新对象:
// 正确:只订阅 theme,theme 不变就不重渲染
const theme = useUserStore((s) => s.theme);
const setTheme = useUserStore((s) => s.setTheme);
// 错误示范:每次返回新数组,等于没优化
// const perms = useUserStore((s) => s.permissions.filter(Boolean));
需要返回派生对象时,用 useShallow:
import { useShallow } from 'zustand/react/shallow';
const { user, theme } = useUserStore(
useShallow((s) => ({ user: s.user, theme: s.theme }))
);
4.2 Vue:从 provide/inject 迁移到 Pinia
迁移前的全局单例:
// before: globalStore.ts
import { reactive } from 'vue';
export const globalStore = reactive({
user: null as User | null,
theme: 'light' as 'light' | 'dark',
});
迁移后的 Pinia store:
// after: stores/user.ts
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
export const useUserStore = defineStore('user', () => {
const user = ref(null);
const theme = ref('light');
const permissions = computed(() => user.value?.permissions ?? []);
async function login(payload: LoginParams) {
const res = await api.login(payload);
user.value = res.data;
theme.value = res.data.theme;
}
function hasPermission(code: string) {
return permissions.value.includes(code);
}
return { user, theme, permissions, login, hasPermission };
});
Vue 侧要注意用 storeToRefs 解构,否则丢失响应性:
import { storeToRefs } from 'pinia';
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
const { user, theme } = storeToRefs(userStore); // 保持响应式
const { hasPermission } = userStore; // 方法直接解构
4.3 迁移步骤(我们实际执行的顺序)
- 冻结新功能 2 天,先写 3 个 store 的 PoC,跑通登录态和主题切换。
- 建立 store 目录规范:
src/stores/{domain}.ts,一个领域一个 store,禁止跨领域直接调用,需要组合时用useXxxStore()在 action 内调用。 - 逐个 Context 替换,每替换一个就跑一次单测 + E2E(Playwright)。
- 删除 Props 透传:用 codemod 找出 6 层以上的透传链,优先处理
theme、user、permission三类。 - 清理 Context Provider,最后删掉
App.tsx里嵌套的 5 层 Provider。 - 加 ESLint 规则:禁止在组件外直接
useUserStore.getState()修改状态,必须走 action。
整个迁移用了 9 个工作日,37 个状态点,改动 142 个文件。
五、踩坑与优化
坑 1:Zustand selector 返回新对象导致无限重渲染。
最初写 useUserStore((s) => ({ user: s.user })),每次返回新对象,React 认为变了,触发重渲染,配合 useEffect 直接死循环。解决:用 useShallow 或拆成多个单值 selector。
坑 2:Pinia 解构丢失响应性。
const { user } = useUserStore() 拿到的是一次性快照,user 变了视图不更新。必须 storeToRefs。这个坑我们组三个人都踩过,后来直接写进 Code Review checklist。
坑 3:跨框架状态同步。
React 和 Vue 子应用通过 iframe 通信,登录态用 postMessage 同步。后来改成主应用持有 token,子应用通过 window.__SHARED_STATE__ 读取 + storage 事件监听,减少了 300ms 的同步延迟。
坑 4:Zustand 持久化导致首屏闪烁。
用 persist 中间件存 localStorage,首屏会先渲染默认值再渲染持久化值。解决:加 skipHydration: false 并配合 onRehydrateStorage 设置 loading 态。
坑 5:Pinia 在 SSR 下的状态污染。
虽然我们是 SPA,但预渲染时 Pinia 单例会跨请求共享。解决:每个请求 createPinia() 新实例。
优化手段:
- Zustand 开启 subscribeWithSelector,配合 useEffect 做细粒度副作用。
- Pinia 的 computed 缓存派生状态,避免在模板里重复计算。
- 用 React DevTools Profiler 和 Vue DevTools 的 Performance 面板定位剩余重渲染。
六、效果数据
迁移前后对比(5 次采样中位数):
| 指标 | Context/Props | Zustand + Pinia | 变化 |
|---|---|---|---|
| 首屏渲染 (FCP) | 1.8s | 1.1s | -38.9% |
| 主题切换重渲染组件数 | 82 | 31 | -62.2% |
| 登录态更新重渲染组件数 | 96 | 12 | -87.5% |
| 状态层包体积 (gzip) | 0 | 3.2KB | +3.2KB |
| 状态相关 Bug(月均) | 7 | 2 | -71.4% |
| 单测覆盖率(store 层) | 41% | 86% | +45pp |
首屏提升主要来自删掉了 5 层 Provider 嵌套和大量无意义重渲染。包体积只增加 3.2KB,因为 Zustand 1.2KB + Pinia 2.0KB,换来的是可维护性的大幅提升。
七、总结
Context 和 provide/inject 适合低频、小范围的跨层级通信,不适合做全局状态管理。当你的 Context value 是个对象、消费者超过 20 个、或者 Props 透传超过 4 层时,就该考虑迁移了。
选型上,React 侧 Zustand 的 selector 模型和 1.2KB 体积是性价比之选;Vue 侧 Pinia 是官方答案,没有理由不用。迁移不要一次性全改,按领域逐个替换,每步都有测试兜底。
最后一句忠告:状态管理库不是银弹,selector 写不对,Zustand 一样全量重渲染。 迁移只是起点,真正的优化在于理解订阅粒度和渲染边界。