一、问题背景:当Context成为性能瓶颈

事情发生在我们的一个报表配置平台。最初用Context(React 18.2)管理全局的筛选条件、表格配置、用户偏好。但随着业务迭代,组件树变成这样:

          ... 20+个子组件

问题很典型:任何一个Provider的value变化,都会导致其下所有消费组件重新渲染。哪怕只是改了一个筛选框的值,整个表格组件(包含500行虚拟滚动)都要经历协调过程。Chrome Performance录制显示,一次简单的输入操作(onChange -> setState -> Context更新)耗时高达180ms,输入明显掉帧。在Vue那边,虽然Provide/Inject有响应式优化,但过度注入同样导致依赖追踪混乱,watchEffect频繁触发。

二、环境与版本:技术栈快照

  • React 18.2.0 / Next.js 14.0.4
  • Vue 3.5.1(Composition API)
  • 状态库:Zustand 4.5.2(React)、Pinia 2.1.7(Vue)
  • 构建工具:Vite 5.0(Vue)、Webpack 5.9(React)
  • 性能采集:Chrome DevTools Performance面板、React Profiler 6.0

核心诉求:不重构业务代码,只替换状态传递层。要求新方案能保留现有hook/API调用方式(如useFilter、useTableConfig),降低迁移阻力。

三、方案设计:为什么是Zustand和Pinia

对比了Redux Toolkit、Jotai、MobX以及Vuex后,最终定下这两个库,原因很务实:

  1. 零Provider包裹:Zustand和Pinia都不需要顶层Provider,直接import store即可使用。这直接砍掉了三层嵌套Provider。
  2. 细粒度订阅:Zustand的useStore支持选择器(selector),只有选中值变化才会触发组件更新;Pinia的storeToRefs配合computed能达到同样效果。
  3. 异步支持:Zustand的create支持异步action,Pinia天然支持async/await,无需中间件。
  4. 迁移成本低:两者都支持在组件外定义store,意味着我们可以先新建store,再逐步替换useContext调用。

架构设计

// 旧结构:Provider嵌套
 ->  -> 

// 新结构:扁平化Store
zustand/store/filterStore.ts
zustand/store/tableConfigStore.ts
zustand/store/userStore.ts

核心原则:按业务域拆分store,而不是一个大store。每个store独立管理自己的state和action。

四、核心实现:代码改造实录

4.1 React:从Context到Zustand

迁移前(Context写法)

// filterContext.tsx
const FilterContext = createContext(null!);
export const useFilter = () => useContext(FilterContext);

export const FilterProvider = ({ children }) => {
  const [filters, setFilters] = useState({ keyword: '', dateRange: [] });
  const updateKeyword = (kw: string) => setFilters(prev => ({ ...prev, keyword: kw }));
  return (

      {children}

  );
};

// 消费组件
function SearchInput() {
  const { filters, updateKeyword } = useFilter();
  return  updateKeyword(e.target.value)} />;
}

迁移后(Zustand写法)

// filterStore.ts
import { create } from 'zustand';

interface FilterState {
  keyword: string;
  dateRange: [Date, Date] | null;
  setKeyword: (kw: string) => void;
  setDateRange: (range: [Date, Date] | null) => void;
}

export const useFilterStore = create((set) => ({
  keyword: '',
  dateRange: null,
  setKeyword: (keyword) => set({ keyword }),
  setDateRange: (dateRange) => set({ dateRange }),
}));

// 消费组件(关键:用selector精准订阅)
function SearchInput() {
  const keyword = useFilterStore(state => state.keyword);
  const setKeyword = useFilterStore(state => state.setKeyword);
  return  setKeyword(e.target.value)} />;
}

// 如果组件同时依赖多个store值,可以用useShallow避免不必要渲染
import { useShallow } from 'zustand/react/shallow';
const { keyword, dateRange } = useFilterStore(useShallow(state => ({
  keyword: state.keyword,
  dateRange: state.dateRange,
})));

注意点:Zustand默认是浅比较(Object.is),如果selector返回新对象(如state => ({ a: state.a, b: state.b })),会导致无限渲染。必须配合useShallow或直接返回原始值。

4.2 Vue:从Provide/Inject到Pinia

迁移前(Vue 3 Provide/Inject)

import { provide, reactive } from 'vue';
const filters = reactive({ keyword: '', dateRange: [] });
const updateKeyword = (kw) => { filters.keyword = kw; };
provide('filterState', { filters, updateKeyword });




import { inject } from 'vue';
const { filters, updateKeyword } = inject('filterState');

迁移后(Pinia)

// store/filter.ts
import { defineStore } from 'pinia';

export const useFilterStore = defineStore('filter', {
  state: () => ({
    keyword: '',
    dateRange: null as [Date, Date] | null,
  }),
  actions: {
    setKeyword(kw: string) {
      this.keyword = kw;
    },
    setDateRange(range: [Date, Date] | null) {
      this.dateRange = range;
    },
  },
});

// 组件中使用

import { storeToRefs } from 'pinia';
import { useFilterStore } from '../store/filter';

const filterStore = useFilterStore();
// 关键:使用storeToRefs解构才会保持响应性,且实现按需更新
const { keyword, dateRange } = storeToRefs(filterStore);

五、踩坑与优化:那些文档没写的细节

坑1:Zustand的selector与React.memo冲突
如果组件内用了React.memo包裹,但selector返回的是函数(如action),会导致memo失效。解决方案:action在store外定义时用useShallow包裹,或者直接不memo组件,因为Zustand已经做了细粒度订阅。

坑2:Pinia的store实例在组件外使用
在router或工具函数中调用store,必须确保Pinia实例已激活。解决:在main.ts中const pinia = createPinia(); app.use(pinia);后,全局导出一个useStore函数,内部判断activePinia

性能优化:使用useCallback稳定action引用
在Zustand中,如果action函数在每次渲染时重建,会导致订阅的组件重新渲染。优化方案:

// 在store外部定义action,避免每次create时重建
const setKeyword = (kw: string) => useFilterStore.setState({ keyword: kw });
// 组件中直接引用外部函数
const keyword = useFilterStore(s => s.keyword);
// 不需要从store取setKeyword了

优化:视觉验证
用React Profiler录制,发现迁移后,点击搜索按钮时,只有SearchInputResultTable两个组件重新渲染,而之前的Context版本至少15个组件参与渲染。

六、效果数据:性能提升直观可见

在同样硬件(MacBook Pro M1)上用Chrome Performance录制2分钟操作(输入、筛选、翻页):

指标 Context/Provide Zustand/Pinia 提升幅度
输入延迟(ms) 180 45 75%
组件更新次数/次操作 23 8 65%
渲染耗时(ms) 120 42 65%
首次加载bundle体积(gzip) 285KB 249KB(去掉Provider代码) 12.6%

更直观的感受:迁移前,在筛选框输入时,表格会出现明显的白屏闪烁(重渲染);迁移后,输入流畅无感,表格仅在点击“应用筛选”时才更新。

七、总结与建议

这次迁移花了3天完成,核心工作量在识别哪些状态需要全局共享,而不是写store代码。建议遵循以下原则:

  1. 能用props传递的,不要进store。store只放跨多层级共享且频繁变动的状态(如筛选条件、用户信息)。
  2. 按业务域拆分store,不要搞一个巨型store。否则就失去了细粒度订阅的意义。
  3. 迁移时先建store,再逐步替换。一个模块一个模块来,避免大爆炸式重构,方便回滚。

对于新项目,我强烈建议直接上Zustand/Pinia,省去Provider嵌套的麻烦。但也要注意,状态管理库不是银弹,如果你的项目组件树只有2-3层,Context完全够用,别过度设计。

最后,附上本次迁移的commit记录:删掉了4个Provider文件,新增了5个store文件,净删200余行代码。代码越少,维护越轻松,这大概是这次重构最爽的地方。