一、问题背景:当Context/Props开始“拖后腿”
我们有一个内部运营管理系统,React 18 + Vue 3双技术栈并存(历史原因),共享用户信息、权限码、主题配置、购物车等12个全局状态。最初用Context/Props实现,但随着业务增长,出现了三个致命问题:
- Provider嵌套地狱:最高层级嵌套了7层Provider,代码可读性极差,新成员接手时花费大量时间解耦。
- 无意义的全量重渲染:React Context一旦value变化,所有消费该Context的组件都会重渲染(即使只用了其中一部分数据)。我们统计过,一次权限变更会触发约400个组件重渲染,其中70%是不必要的。
- Vue Props逐层透传:在Vue 3中,一个userInfo对象从根组件传到第5层子组件,中间4层组件都要写
defineProps和defineEmits,纯粹是样板代码。
二、环境与版本:明确技术栈基线
| 技术 | 版本 |
|---|---|
| React | 18.2.0 |
| React DOM | 18.2.0 |
| Zustand | 4.4.7 |
| Vue | 3.4.21 |
| Pinia | 2.1.7 |
| Vite | 5.1.0 |
| TypeScript | 5.3.3 |
性能测试工具:React Profiler、Vue Devtools Performance Tab、Lighthouse 11.3.0。
三、方案设计:为什么选Zustand和Pinia?
React侧——Zustand:我们对比了Redux Toolkit、Jotai和Zustand。Redux Toolkit样板代码太多(需要写Action/Reducer/Selector);Jotai虽然轻量但原子化拆分过碎。Zustand胜在极简API(无Provider包裹)、细粒度订阅(不会全量重渲染)和支持transient updates(非React组件也能读写状态)。
Vue侧——Pinia:Vue官方推荐,天然支持Composition API,且去掉了Vuex的Mutation(直接改state),DevTools时间旅行调试非常方便。
核心设计原则:
- 拆分为多个独立store(按业务域),而不是一个巨型store。
- 组件中禁止直接修改store,必须通过action(Zustand)或store方法(Pinia)修改。
- 对于频繁更新的状态(如输入框内容),使用非响应式引用或局部状态兜底。
四、核心实现:从Context到Zustand的迁移代码
4.1 React:Context+useReducer → Zustand
迁移前(Context版本):
// 迁移前:7层Provider嵌套 + 手动拆分useContext
const ThemeContext = createContext void }>({} as any);
const UserContext = createContext void }>({} as any);
export function AppProviders({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
return (
{children}
);
}
迁移后(Zustand版本):
// 迁移后:一个store文件搞定,无需Provider
import { create } from 'zustand';
interface AppState {
theme: 'light' | 'dark';
user: UserInfo | null;
setTheme: (t: 'light' | 'dark') => void;
login: (u: UserInfo) => void;
logout: () => void;
}
export const useAppStore = create((set) => ({
theme: 'light',
user: null,
setTheme: (theme) => set({ theme }),
login: (user) => set({ user }),
logout: () => set({ user: null }),
}));
// 组件中按需取用,避免全量重渲染
// 只用theme的组件:
const theme = useAppStore((s) => s.theme);
// 只用user的组件:
const user = useAppStore((s) => s.user);
关键点:Zustand的useAppStore((s) => s.theme)是细粒度订阅,只有theme变化时该组件才会重渲染。而Context是只要value对象引用变化,所有消费者都重渲染。
4.2 Vue:Props透传 → Pinia
迁移前(Props透传):
defineProps();
const emit = defineEmits();
迁移后(Pinia):
// stores/user.ts
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', {
state: () => ({
user: null as UserInfo | null,
permissions: [] as string[],
}),
actions: {
async login(credentials: { username: string; password: string }) {
const res = await api.login(credentials);
this.user = res.user;
this.permissions = res.permissions;
},
logout() {
this.user = null;
this.permissions = [];
},
},
});
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
// 模板中直接使用 userStore.user,无需props
五、踩坑与优化:迁移中遇到的坑
坑1:Zustand的selector返回值陷阱。如果selector返回一个新对象(如(s) => ({ user: s.user, theme: s.theme })),会导致无限重渲染。解决:用useShallow或分开写多个useStore。
// 错误写法:每次返回新对象,触发重渲染
const { user, theme } = useAppStore((s) => ({ user: s.user, theme: s.theme }));
// 正确写法:分开订阅
const user = useAppStore((s) => s.user);
const theme = useAppStore((s) => s.theme);
坑2:Pinia的state直接解构会失去响应性。必须用storeToRefs进行解构。
// 错误:直接解构丢失响应性
const { user, permissions } = userStore;
// 正确:
import { storeToRefs } from 'pinia';
const { user, permissions } = storeToRefs(userStore);
优化:对于高频更新的状态(如输入框内容),我们保留组件局部state,不放入store。例如一个编辑弹窗的草稿内容,只在提交时才写入store。
六、效果数据:迁移前后的性能对比
| 指标 | 迁移前(Context/Props) | 迁移后(Zustand/Pinia) | 变化 |
|---|---|---|---|
| 首屏渲染时间(Lighthouse) | 2.8s | 2.1s | ↓ 23% |
| 组件重渲染次数(每分钟均值) | 约4200次 | 约400次 | ↓ 90.5% |
| 全局状态代码量(行) | 1560行 | 1210行 | ↓ 22% |
| Provider嵌套层级 | 7层 | 0层 | ↓ 100% |
| 包体大小(gzip后) | +35KB(Context无额外包) | +18KB(Zustand) | 可接受 |
特别说明:React侧的重渲染优化效果最明显。我们用一个权限更新操作做单测:Context版本触发412个组件重渲染,Zustand版本只触发3个(仅真正使用权限码的组件)。
七、总结与建议
如果你也在经历Provider嵌套地狱或Props透传噩梦,建议尽早考虑迁移。但要注意:
1. 不要一刀切:只把真正跨层级共享的状态放入store,组件内部临时状态留在本地。
2. 选择成熟库:Zustand和Pinia都是经过生产环境验证的方案,社区活跃,文档齐全。
3. 编写类型安全:TypeScript泛型约束store的状态和action,避免运行时错误。
本次迁移总共耗时3个工作日,换来的是更快的渲染速度、更清晰的代码结构和更愉快的开发体验。如果你有类似的项目,希望这篇博客能给你一些参考。有问题欢迎评论区交流。