一、问题背景:当Context成为性能瓶颈
我先描述一下我们的项目状况:一个B端数据可视化平台,左侧菜单、顶部筛选栏、中间内容区、右侧详情抽屉。状态涉及用户信息、筛选条件、表格数据、主题配置、多语言等。最初我们采用的是最朴素的Props Drilling + Context组合。
在React 19中,我们使用use(Context)和useReducer管理全局状态。但问题在迭代到第20个页面时集中爆发:
- 无效渲染:任何筛选条件的变更,都会导致所有消费
Context的组件重渲染。使用React Profiler测量,一次theme切换导致42个组件重渲染,其中25个与主题无关。 - 代码侵入:为了减少props钻取,我们不得不将组件拆得更细,但反向导致
props数量爆炸——最深的一个组件接收了11层props。 - 并发特性失效:React 19的
useTransition在处理筛选输入时,因为Context没有细粒度的订阅机制,导致startTransition内部的更新仍然会阻塞渲染。
Vue 3.5那边的情况类似:使用provide/inject后,虽然语法简洁,但依赖注入的响应式数据一旦在深层组件被修改,由于Vue的响应式系统是细粒度的,性能问题没有React那么严重,但代码的可读性和类型推导变得极差——我们不得不为每个inject写一堆as断言。
核心痛点总结:
- 渲染性能:Context导致的重渲染范围不可控
- 开发体验:Props类型定义冗余,Context消费代码样板化
- 调试成本:状态变更链路模糊,难以追踪
二、环境与版本:我们站在哪个版本上
- React项目:React 19.0.0 (使用
usehook),Vite 6.0.0,TypeScript 5.6.0 - Vue项目:Vue 3.5.13 (引入
useTemplateRef和响应式props解构),Vite 6.0.0 - 迁移目标:React侧选择 Zustand 5.0.2(因为其体积小,约3.2KB gzipped,且支持React 19的
useSyncExternalStore);Vue侧选择 Pinia 3.0.1(官方推荐,与Vue Devtools集成好)。
选择Zustand而非Redux Toolkit的原因:我们不需要不可变更新的严格约束,且Redux的样板代码量在已有业务中改造风险太大。选择Pinia因为它是Vue 3官方生态的一部分,且对TypeScript的支持是开箱即用的。
三、方案设计:为什么是Zustand与Pinia,而不是Jotai或Vuex?
React侧方案对比:
| 方案 | 重渲染控制 | 样板代码 | 学习成本 | 备注 |
|---|---|---|---|---|
| Context+useReducer | 差(需手动memo) | 中 | 低 | 现状 |
| Redux Toolkit | 好 | 高 | 高 | 不适合快速重构 |
| Zustand | 优(selector自动订阅) | 低 | 低 | 最终选择 |
Zustand的核心优势是状态与视图分离——组件通过useStore(selector)订阅,只有selector返回值变化才会触发重渲染。这完美解决了Context的痛点。
Vue侧方案对比:
provide/inject在Vue 3.5中已经很好用了,但主要问题是类型推导在inject时容易丢失。Pinia则提供了完美的类型安全,且其store的$subscribe方法可以精确追踪变更。
架构设计:
- React侧:将全局状态拆分为useAuthStore、useFilterStore、useThemeStore三个独立的store,避免单一store过大导致的状态竞争。
- Vue侧:使用两个Pinia store——useUserStore和useLayoutStore。其中useLayoutStore管理抽屉、菜单折叠等UI状态。
四、核心实现:从Context到Store的代码改造
4.1 React 19:Context代码改造为Zustand 5
迁移前(Context+useReducer):
// ThemeContext.tsx (改造前)
const ThemeContext = createContext void } | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(themeReducer, { theme: 'light' });
// 此处useMemo是为了减少无效渲染,但依然无法精准控制
const value = useMemo(() => ({
theme: state.theme,
toggle: () => dispatch({ type: 'toggle' })
}), [state.theme]);
return {children};
}
// 消费组件
export function ThemeButton() {
const { theme, toggle } = use(ThemeContext); // React 19的use
return {theme};
}
迁移后(Zustand 5):
// stores/themeStore.ts (改造后)
import { create } from 'zustand';
interface ThemeState {
theme: 'light' | 'dark';
toggle: () => void;
setTheme: (theme: 'light' | 'dark') => void;
}
export const useThemeStore = create((set) => ({
theme: 'light',
toggle: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
setTheme: (theme) => set({ theme }),
}));
// 消费组件 (只需要一行hook)
export function ThemeButton() {
// 关键:selector精确指定依赖,只有theme变化时才会重渲染
const theme = useThemeStore((state) => state.theme);
const toggle = useThemeStore((state) => state.toggle);
return {theme};
}
关键点:Zustand的useStore底层使用了useSyncExternalStore,React 19中这是并发渲染安全的。迁移后我删掉了所有用于防止Context重渲染的React.memo,组件代码量减少了约20%。
4.2 Vue 3.5:provide/inject改造为Pinia 3
迁移前(provide/inject):
import { provide, ref } from 'vue';
// 类型定义麻烦,每次inject都要断言
const sidebarCollapsed = ref(false);
provide('sidebar', {
collapsed: sidebarCollapsed,
toggle: () => sidebarCollapsed.value = !sidebarCollapsed.value
});
import { inject } from 'vue';
// 类型丢失,需要使用接口断言
const { collapsed, toggle } = inject('sidebar') as { collapsed: Ref; toggle: () => void };
迁移后(Pinia 3):
// stores/layout.ts
import { defineStore } from 'pinia';
export const useLayoutStore = defineStore('layout', {
state: () => ({
sidebarCollapsed: false,
drawerVisible: false,
activeMenu: '/dashboard'
}),
actions: {
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed;
},
openDrawer() {
this.drawerVisible = true;
}
}
});
import { useLayoutStore } from '@/stores/layout';
// 自动类型推导,无需断言
const layoutStore = useLayoutStore();
// 使用storeToRefs保持响应性
const { sidebarCollapsed } = storeToRefs(layoutStore);
Pinia的storeToRefs解决了Vue 3中解构store会丢失响应性的问题,这是比provide/inject更优雅的API设计。
五、踩坑与优化:迁移过程中的三个深坑
坑1:Zustand的Selector导致的内存泄漏(React)
在Zustand v5中,如果selector返回一个新对象(例如(state) => ({ a: state.a, b: state.b })),会导致无限循环渲染,因为每次比较的都是新引用。解决方法是使用useShallow:
import { useShallow } from 'zustand/react/shallow';
// 错误写法:返回新对象
const { a, b } = useThemeStore((state) => ({ a: state.a, b: state.b }));
// 正确写法
const { a, b } = useThemeStore(
useShallow((state) => ({ a: state.a, b: state.b }))
);
坑2:Pinia的Store嵌套调用(Vue)
在Pinia的action中调用另一个store的action时,必须在函数内部获取store实例,不能在外面顶层获取(防止SSR和测试时的store实例污染):
export const useUserStore = defineStore('user', {
actions: {
async login(payload) {
// 错误:在外部获取
// const layoutStore = useLayoutStore();
// 正确:在action内部获取
const layoutStore = useLayoutStore();
layoutStore.openDrawer();
// ...登录逻辑
}
}
});
坑3:Vue 3.5的useTemplateRef与Pinia的联动
在Vue 3.5中,我们使用useTemplateRef获取DOM节点,但如果在v-for中与Pinia的列表数据联动,需要注意模板ref的更新时机。nextTick在Vue 3.5中行为有微调,建议使用:
await nextTick();
// 此时DOM已更新,但Pinia的state可能还在pending
// 需要使用flush: 'post'来确保
const layoutStore = useLayoutStore();
await layoutStore.$patch({ activeMenu: '/new' });
await nextTick(); // 再等待一次
六、效果数据:性能与代码量的实测对比
性能测试环境:Chrome 131,MacBook Pro M1 Pro,无GPU加速干扰。
React项目(Zustand迁移后):
- 首屏交互延迟:从420ms降至180ms(React Profiler的Render Duration平均值),降幅57%。
- 重渲染组件数:切换主题时,从42个组件重渲染降至4个(仅主题相关按钮和样式容器)。
- 内存占用:快照对比,从87.3MB降至72.1MB,因为移除了大量React.memo和useCallback的闭包引用。
- 包体积:Zustand gzipped 3.2KB比Context方案(本身无库)多3.2KB,但比Redux Toolkit的14KB小了一个量级。
Vue项目(Pinia迁移后):
- 组件更新次数:在Devtools的Performance面板中,筛选条件变化触发的组件更新从31次/秒降至12次/秒。
- FPS:拖动图表时,FPS从38帧提升至稳定60帧。
- 代码量:composable目录下的代码量减少了约30%,因为不再需要为每个inject写类型断言和工厂函数。
团队开发效率:
- 新成员上手时间从3天缩短至1天,因为Zustand的create和Pinia的defineStore的API是自解释的。
- Code Review中关于状态管理的评论数量下降了80%。
七、总结:迁移的终极建议
如果你也面临同样的状态管理混乱问题,我的建议是:
- 不要盲目迁移:如果项目少于20个页面,且组件树深度小于5,用Context和provide/inject完全没问题。我们的问题出在深度嵌套+频繁交互。
- React侧优先选Zustand:它是对React 19的
useSyncExternalStore的最佳封装,没有之一。如果你需要更严苛的不可变约束,才考虑Redux Toolkit。 - Vue侧直接上Pinia:Vuex 4在Vue 3中已经是维护模式,Pinia是官方推荐且类型支持更好。
- 迁移策略:不要一次性替换所有Context。推荐的做法是:先为一个状态(如主题)建一个store,替换掉对应Context,运行两周观察无回归后,再迁移下一个状态。我们整个迁移耗时3周,没有出现线上故障。
最后,状态管理的本质是管理状态变更的影响范围。Zustand和Pinia通过selector和store模块化,把影响范围控制在最小粒度。这比任何性能优化技巧都重要。希望这篇记录对你有用。