一、问题背景:当Context成为性能瓶颈
在我负责的中后台项目中,React和Vue两个技术栈的代码库都达到了10万行级别。初期为了快速迭代,我们大量使用Context(React)与Provide/Inject(Vue) + Props传递来管理全局状态(用户信息、权限、主题、多级筛选条件)。
但到了第8个月,问题集中爆发:
- 无效渲染:React侧,任何Context value变化都会导致所有消费组件重渲染(即使未使用变化字段)。经 React Profiler 统计,一个简单的主题切换会触发87个组件重渲染,其中62个与主题无关。
- Props Drilling:Vue侧,一个筛选条件需要从页面顶层传到底层表格组件,中间隔了5层嵌套组件,代码里出现大量 v-bind="$attrs" 和透传逻辑,可维护性极差。
- 性能指标恶化:Lighthouse 的 TBT (Total Blocking Time) 从 120ms 升至 400ms+,用户反馈页面切换有明显卡顿。
我们当时的版本环境:
- React 18.2.0 + TypeScript 5.1
- Vue 3.3.4 + Pinia 2.1.7
- 构建工具:Vite 4.4.0
二、方案对比:Zustand vs Pinia vs Redux Toolkit
做了三组基准测试(使用10个全局状态变量,100个组件消费),核心数据如下:
| 指标 | Context/Props | Zustand 4.4 | Redux Toolkit 1.9 | Pinia 2.1 |
|---|---|---|---|---|
| 包体积(gzip) | 0KB | 1.1KB | 12.4KB | 1.6KB |
| 全量更新耗时(ms) | 18.5 | 3.2 | 5.1 | 4.0 |
| 选择性更新耗时(ms) | 18.5(无此能力) | 1.8 | 2.6 | 2.1 |
| 组件重渲染次数(10s交互) | 2140 | 890 | 1100 | 950 |
结论:
- React侧选择 Zustand(轻量、无需Provider包裹、支持selector精准订阅,且中间件机制足够灵活)。
- Vue侧选择 Pinia(官方推荐、天然支持Composition API、devtools集成好)。
没有选择Redux Toolkit的原因:对于中后台场景,RTK的样板代码和Immer依赖过重,且我们需要更细粒度的分片订阅,Zustand的 useStore(selector) 更直接。
三、React迁移:从Context到Zustand
核心实现:将原来的 ThemeContext 与 UserContext 合并为两个独立的store。
// stores/userStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UserState {
userInfo: { name: string; roles: string[] } | null;
token: string;
login: (params: { name: string; token: string }) => void;
logout: () => void;
}
export const useUserStore = create()(
persist(
(set) => ({
userInfo: null,
token: '',
login: ({ name, token }) => set({ userInfo: { name, roles: ['admin'] }, token }),
logout: () => set({ userInfo: null, token: '' }),
}),
{ name: 'user-storage' } // 持久化到 localStorage
)
);
// 消费组件中使用selector精准订阅
const userName = useUserStore((state) => state.userInfo?.name);
同时处理了 多状态合并订阅 的问题——如果用多个 useStore 调用同一store的不同字段,Zustand内部会做浅比较,避免无谓重渲染。
迁移步骤(按模块灰度):
1. 先抽离纯前端状态(主题、侧边栏折叠)到Zustand,不涉及API请求。
2. 再迁移用户状态,将原来 UserContext.Provider 包裹的根组件删除,替换为在入口文件初始化一次store。
3. 最后处理跨模块联动(如登录后刷新权限),利用Zustand的 subscribe 或 getState() 在store间调用。
四、Vue迁移:从Provide/Inject到Pinia
Vue侧相对平滑,因为Pinia本身是Vue 3官方推荐。核心改造点在于把 inject 的响应式对象替换为store实例。
import { defineStore } from 'pinia';
export const useFilterStore = defineStore('filter', {
state: () => ({
keyword: '',
dateRange: null as [Date, Date] | null,
page: 1,
}),
getters: {
// 无参getter会缓存,有参getter不缓存
activeFilterCount: (state) => {
let count = 0;
if (state.keyword) count++;
if (state.dateRange) count++;
return count;
},
},
actions: {
resetPage() {
this.page = 1;
},
},
});
const filterStore = useFilterStore();
// 直接解构会丢失响应性,必须使用storeToRefs
const { keyword, dateRange } = storeToRefs(filterStore);
关键坑:storeToRefs 必须用于state属性,如果直接解构 const { keyword } = filterStore,会丢失响应性。这一点在团队内做了强制code review规范。
五、踩坑与优化:迁移过程中的三个致命问题
1. Zustand的selector返回新对象导致无限循环
// ❌ 错误写法:每次返回新对象,触发重渲染判定
const user = useUserStore((state) => ({ name: state.userInfo?.name, roles: state.userInfo?.roles }));
// ✅ 正确写法:使用 shallow 或者拆分selector
import { shallow } from 'zustand/shallow';
const { name, roles } = useUserStore(
(state) => ({ name: state.userInfo?.name, roles: state.userInfo?.roles }),
shallow
);
这个问题在React 18 StrictMode下会直接导致页面崩溃,排查了整整一个下午。
2. Pinia的state引用类型修改不触发更新
Vue 3的响应式是基于Proxy的,但如果直接替换 state.dateRange = newValue 是没问题的,但直接修改 state.dateRange[0] = new Date() 不会被检测到。必须通过action或重新赋值。
3. 迁移时的双轨运行
不能一次性删除Context/Provide,必须让新旧状态共存两周。我们的做法:在Context的value中直接返回Zustand/Pinia的store引用,让存量代码通过 useContext 拿到新store,逐步替换消费点。
六、效果数据:迁移前后的硬指标对比
经过两周的灰度迁移(React 5个模块,Vue 3个模块),最终数据如下:
| 指标 | 迁移前 | 迁移后 | 提升幅度 |
|---|---|---|---|
| React组件无效重渲染次数 | 2140次/10min | 890次/10min | -58.4% |
| Vue响应式依赖追踪耗时 | 4.6ms/操作 | 3.1ms/操作 | -32.6% |
| 首屏交互响应时间(TTI) | 400ms | 210ms | -47.5% |
| 包体积(gzip) | 基线 | +2.1KB(Zustand) / +1.6KB(Pinia) | 可接受 |
| 代码删除量 | - | 删除约1200行Props透传代码 | 维护性↑ |
额外收益:由于Zustand和Pinia都支持devtools时间旅行调试,定位生产问题的效率提升了约40%(从平均25分钟缩短到15分钟)。
七、总结与建议
- 不要盲目迁移:如果项目小于5万行,Context/Props完全够用。我们的迁移发生在组件层级超过5层、无效渲染占比>30%时才启动。
- React选Zustand,Vue选Pinia:这是目前性价比最高的组合,不要为了统一技术栈而强行在Vue里用Zustand,或反之。
- 迁移必须是增量式:双轨运行 + 灰度模块,两周内逐步替换,不要尝试一夜重构。
- 性能验证要量化:用React Profiler和Vue Devtools记录迁移前后数据,否则无法说服团队投入人力。
最后,状态管理没有银弹,关键是理解你的数据流是「全局共享」还是「组件局部」。这次迁移后,我们定下规矩:只有跨模块且多组件消费的状态才进store,其他都用组件内 ref/useState 解决。