一、问题背景:Context 和 Props 不是不能用,是贵

项目是一个后台管理系统,React 18.2 + TypeScript 4.9 + Vite 4.1,Vue 子项目是 Vue 3.2 + Pinia 2.1 混用。最初为了快速上线,全局状态直接用 React Context 和 Vue provide/inject,局部状态靠 Props 层层传递。

问题在数据量上来后暴露:

  1. React 端 FilterContext 里放了 filterssetFiltersresetFiltersloadingerror 五个值。任意一个变化,所有 useContext(FilterContext) 的组件全部重渲染。用 React DevTools Profiler 录了一次筛选操作,提交阶段 37 个组件重渲染,耗时 186ms。
  2. Vue 端 provide('globalState', reactive({...})) 类似,24 个 watcher 被触发,其中 11 个是无关的。
  3. Props drilling 最深处有 6 层,中间组件只做透传,改一个字段要动 4 个文件。

不是 Context 和 provide 本身慢,是它们没有选择器粒度。状态管理库的核心价值就是“按需订阅”。

二、环境与版本

  • React 18.2.0、react-dom 18.2.0、TypeScript 4.9.5、Vite 4.1.0
  • Zustand 4.4.1、immer 10.0.2
  • Vue 3.2.47、Pinia 2.1.6、vue-tsc 1.0.24
  • 测试机:MacBook Pro M1 16G,Chrome 114
  • 性能采集:React DevTools Profiler、Vue DevTools、Chrome Performance

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

对比过 Redux Toolkit、Jotai、Recoil、MobX、Zustand;Vue 侧对比过 Vuex 4、Pinia。

维度 Context Redux Toolkit Zustand Pinia
选择器粒度
模板代码
包体积 0 ~13KB ~1.2KB ~1.5KB
TS 推断 一般
迁移成本 -

选 Zustand 的原因:无需 Provider、无 boilerplate、选择器天然支持、可在组件外调用 store。Pinia 是 Vue 官方推荐,和 Vuex 比去掉 mutations,TS 推断更好。

四、核心实现

4.1 React:从 Context 迁移到 Zustand

迁移前的 Context:

// 迁移前:FilterContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';

interface Filters { keyword: string; status: string; page: number; }
interface FilterCtx {
  filters: Filters;
  setFilters: (f: Partial) => void;
  resetFilters: () => void;
}

const Ctx = createContext(null);

export function FilterProvider({ children }: { children: ReactNode }) {
  const [filters, setFiltersState] = useState({ keyword: '', status: '', page: 1 });
  const setFilters = (f: Partial) => setFiltersState(p => ({ ...p, ...f }));
  const resetFilters = () => setFiltersState({ keyword: '', status: '', page: 1 });
  return {children};
}

export function useFilter() {
  const ctx = useContext(Ctx);
  if (!ctx) throw new Error('useFilter must be used within FilterProvider');
  return ctx;
}

迁移后的 Zustand store:

// 迁移后:useFilterStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface Filters { keyword: string; status: string; page: number; }
interface FilterState {
  filters: Filters;
  loading: boolean;
  setFilters: (f: Partial) => void;
  resetFilters: () => void;
  setLoading: (v: boolean) => void;
}

export const useFilterStore = create()(
  immer((set) => ({
    filters: { keyword: '', status: '', page: 1 },
    loading: false,
    setFilters: (f) => set((s) => { Object.assign(s.filters, f); }),
    resetFilters: () => set((s) => { s.filters = { keyword: '', status: '', page: 1 }; }),
    setLoading: (v) => set((s) => { s.loading = v; }),
  }))
);

组件里按需订阅:

// 只订阅 keyword,keyword 变化才重渲染
const keyword = useFilterStore((s) => s.filters.keyword);
// 只订阅 action,action 引用稳定,永不因状态变化重渲染
const setFilters = useFilterStore((s) => s.setFilters);

关键点:action 从 store 里取,引用固定,组件不会因为 filters 变化而重渲染。

4.2 Vue:从 provide/inject 迁移到 Pinia

迁移前的 provide/inject:

import { reactive, provide } from 'vue';
const globalState = reactive({ keyword: '', status: '', page: 1, loading: false });
provide('globalState', globalState);

迁移后的 Pinia store:

// 迁移后:stores/filter.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useFilterStore = defineStore('filter', () => {
  const keyword = ref('');
  const status = ref('');
  const page = ref(1);
  const loading = ref(false);

  const hasFilter = computed(() => keyword.value !== '' || status.value !== '');

  function setFilters(payload: Partial) {
    if (payload.keyword !== undefined) keyword.value = payload.keyword;
    if (payload.status !== undefined) status.value = payload.status;
    if (payload.page !== undefined) page.value = payload.page;
  }

  function reset() {
    keyword.value = '';
    status.value = '';
    page.value = 1;
  }

  return { keyword, status, page, loading, hasFilter, setFilters, reset };
});

组件里用 storeToRefs 保持响应式,action 直接解构:

import { storeToRefs } from 'pinia';
import { useFilterStore } from '@/stores/filter';

const store = useFilterStore();
const { keyword, hasFilter } = storeToRefs(store);
const { setFilters, reset } = store;

五、踩坑与优化

  1. Zustand 选择器返回对象会引发无限重渲染。useFilterStore((s) => ({ a: s.a, b: s.b })) 每次返回新对象,默认 Object.is 比较失败。解决:用 useShallow 或拆成两个选择器。
import { useShallow } from 'zustand/react/shallow';
const { a, b } = useFilterStore(useShallow((s) => ({ a: s.a, b: s.b })));
  1. Pinia 解构 store 会丢响应式。必须用 storeToRefs,action 可以直接解构,因为 action 是函数引用。

  2. immer 的 Object.assign(s.filters, f) 在嵌套层级深时有性能开销。我们把 filters 拍平成三个独立字段后,更新耗时从 0.8ms 降到 0.2ms。

  3. Context 迁移期间不能一刀切。我们保留了一个 FilterProvider 作为兼容层,内部用 Zustand,外部 API 不变,组件可以逐个迁移,两周内完成。

  4. Vue 端 reactive 大对象在 DevTools 里序列化慢。Pinia 的 setup store 用 ref 更轻,DevTools 采集时间从 120ms 降到 45ms。

六、效果数据

用 React DevTools Profiler 和 Chrome Performance 各采集 5 次,取中位数:

指标 迁移前 Context 迁移后 Zustand 变化
筛选操作重渲染组件数 37 8 -78%
提交阶段耗时 186ms 42ms -77%
首屏可交互 TTI 2.8s 2.1s -25%
列表滚动 FPS 42 58 +38%

Vue 端:

指标 迁移前 provide 迁移后 Pinia 变化
watcher 触发数 24 6 -75%
筛选响应耗时 96ms 31ms -68%
内存占用 48MB 41MB -15%

包体积:Zustand + immer 增加 1.2KB + 5.6KB,Pinia 增加 1.5KB,gzip 后总增量不到 4KB,可接受。

七、总结

Context 和 provide/inject 适合低频、全局、变化少的状态,比如主题、语言。一旦状态更新频繁、订阅者多,选择器粒度就是刚需。Zustand 和 Pinia 的迁移成本比想象中低,React 端可以用兼容层渐进迁移,Vue 端几乎可以逐文件替换。

三条经验:第一,选择器返回对象一定要用 useShallow 或拆字段;第二,Pinia 解构必须 storeToRefs;第三,状态结构尽量拍平,immer 的嵌套更新在深层级下并不便宜。

迁移不是银弹,但在这个项目里,37 到 8 的重渲染数字,值得动手。