一、问题背景:当Context/Props开始“拖后腿”

我们有一个内部运营管理系统,React 18 + Vue 3双技术栈并存(历史原因),共享用户信息、权限码、主题配置、购物车等12个全局状态。最初用Context/Props实现,但随着业务增长,出现了三个致命问题:

  1. Provider嵌套地狱:最高层级嵌套了7层Provider,代码可读性极差,新成员接手时花费大量时间解耦。
  2. 无意义的全量重渲染:React Context一旦value变化,所有消费该Context的组件都会重渲染(即使只用了其中一部分数据)。我们统计过,一次权限变更会触发约400个组件重渲染,其中70%是不必要的。
  3. Vue Props逐层透传:在Vue 3中,一个userInfo对象从根组件传到第5层子组件,中间4层组件都要写definePropsdefineEmits,纯粹是样板代码。

二、环境与版本:明确技术栈基线

技术 版本
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个工作日,换来的是更快的渲染速度、更清晰的代码结构和更愉快的开发体验。如果你有类似的项目,希望这篇博客能给你一些参考。有问题欢迎评论区交流。