一、问题背景:Context 不是状态管理库

先说结论:Context 是依赖注入工具,不是状态管理方案。这个认知我们花了两个月才用性能数据换回来。

项目是一个数据看板类后台,React 18.2 负责图表编辑区,Vue 3.4 负责报表配置区(历史原因,微前端拆分)。两边共享用户信息、主题、权限、筛选条件。

React 侧最初的结构是这样的:

// 典型的 Context + useReducer 组合
const FilterContext = createContext(null);

function FilterProvider({ children }) {
  const [filters, dispatch] = useReducer(filterReducer, initFilters);
  return (

      {children}

  );
}

问题出在 value={{ filters, dispatch }} 这行——每次 filters 变化都会创建一个新对象,所有 useContext(FilterContext) 的组件全部重渲染,哪怕它只用到 dispatch。

我们的图表编辑区有 3 层嵌套的 Provider(用户、筛选、主题),一次筛选操作后 React DevTools Profiler 显示:单次交互触发 42 次组件渲染,其中 31 次是纯浪费。Vue 侧用 provide/inject 也有类似问题,注入的响应式对象粒度太粗,inject 拿到整个 store 后任意字段变化都会触发依赖收集。

更难受的是 Props 透传。有个 ChartToolbar 组件,从 App 到它要经过 5 层,中间 3 层组件压根不用这些 props,纯粹当搬运工。加一个筛选字段要改 6 个文件。

二、环境与版本

先交代环境,避免版本差异导致结论不可复现:

  • React 18.2.0 + TypeScript 5.3.3 + Vite 5.0.10
  • Vue 3.4.15 + TypeScript 5.3.3 + Vite 5.0.10
  • 状态管理选型:Zustand 4.5.0(React 侧)、Pinia 2.1.7(Vue 侧)
  • 性能采集:React DevTools Profiler 4.28、Vue DevTools 6.6、Chrome Performance 面板
  • 测试机:MacBook Pro M1 / 16G,Chrome 121

选型对比我们拉了个表,重点看四个维度:

方案 包体积(gzip) 重渲染控制 学习成本 与现有代码兼容
Context + useReducer 0(内置) 差,需手动 memo 低 完全兼容
Redux Toolkit 2.0 13.2KB 中,需 useSelector 中 需改造
Zustand 4.5 1.2KB 好,选择器天然细粒度 低 渐进迁移
Jotai 2.6 3.8KB 好,原子级 中 需重构思路

Vue 侧对比:

方案 包体积(gzip) 响应式粒度 学习成本 DevTools
provide/inject 0 粗,整对象 低 弱
Vuex 4.1 4.1KB 中 中 好
Pinia 2.1 2.8KB 细,setup 语法 低 好

选 Zustand 的核心理由:不需要 Provider 包裹,可以在组件外调用,选择器订阅天然解决重渲染。选 Pinia 的理由更简单——Vue 官方推荐,setup store 写法跟 Composition API 一致,迁移成本最低。

三、方案设计:渐进式迁移而非一刀切

我们没敢一次性重写。策略是新代码直接用新库,老代码按模块逐个替换,Context 保留作为兜底,跑一个月没问题再删。

分层设计:

  1. 全局层(用户、权限、主题):Zustand/Pinia store,应用启动时初始化
  2. 模块层(筛选条件、图表配置):按业务域拆 store,避免单个巨型 store
  3. 局部层(弹窗开关、表单临时态):继续用 useState/ref,不上库

关键原则:服务端状态和客户端状态分开。之前把接口数据塞 Context 里是重灾区,现在接口数据交给 TanStack Query 5.17 管,Zustand 只管 UI 状态(筛选、选中项、面板开合)。这条边界划清后,store 体积小了 60%。

四、核心实现

React 侧:Zustand + 选择器

先看迁移前的 Context 写法,这是重渲染的根源:

// 迁移前:任何 filters 变化,所有消费者重渲染
function ChartList() {
  const { filters, dispatch } = useContext(FilterContext);
  // 只用到 filters.dateRange,但整个对象变化都触发渲染
  return ;
}

迁移到 Zustand:

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

interface FilterState {
  dateRange: [string, string];
  category: string | null;
  keyword: string;
  setDateRange: (range: [string, string]) => void;
  setCategory: (c: string | null) => void;
  setKeyword: (k: string) => void;
  reset: () => void;
}

const initial = {
  dateRange: ['2024-01-01', '2024-12-31'] as [string, string],
  category: null,
  keyword: '',
};

export const useFilterStore = create()(
  subscribeWithSelector((set) => ({
    ...initial,
    setDateRange: (dateRange) => set({ dateRange }),
    setCategory: (category) => set({ category }),
    setKeyword: (keyword) => set({ keyword }),
    reset: () => set(initial),
  }))
);

组件里用选择器精确订阅,这是性能提升的核心:

// 迁移后:只有 dateRange 变化才重渲染
function ChartList() {
  const dateRange = useFilterStore((s) => s.dateRange);
  return ;
}

// 只取 action 的组件,永远不会因状态变化重渲染
function FilterActions() {
  const reset = useFilterStore((s) => s.reset);
  return 重置;
}

注意 setDateRange: (dateRange) => set({ dateRange }) 这里没有用 set((s) => ({...s, dateRange})),因为 Zustand 的 set 默认浅合并,直接传对象就行,少一次展开。这个细节在官方文档里不明显,但能省一点开销。

Vue 侧:Pinia setup store

Vue 侧从 provide/inject 迁移。原来的写法:

// 迁移前:inject 整个对象,任意字段变化都触发
const filterStore = inject('filterStore');
watch(() => filterStore.filters, () => { /* 任意字段变化都触发 */ });

迁移到 Pinia setup store:

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

export const useFilterStore = defineStore('filter', () => {
  const dateRange = ref(['2024-01-01', '2024-12-31']);
  const category = ref(null);
  const keyword = ref('');

  const hasActiveFilter = computed(
    () => category.value !== null || keyword.value !== ''
  );

  function reset() {
    dateRange.value = ['2024-01-01', '2024-12-31'];
    category.value = null;
    keyword.value = '';
  }

  return { dateRange, category, keyword, hasActiveFilter, reset };
});

组件里按需解构,Pinia 的响应式是字段级的,解构后 dateRange 变化不会触发只用了 category 的组件:

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

const store = useFilterStore();
// 关键:用 storeToRefs 保持响应式,直接解构会丢失响应式
const { dateRange, hasActiveFilter } = storeToRefs(store);

五、踩坑与优化

坑 1:Zustand 选择器返回新对象导致无限重渲染。 这个坑几乎人人踩。下面写法是错的:

// 错误:每次渲染返回新数组,引用变化触发重渲染,无限循环
const range = useFilterStore((s) => [s.dateRange, s.category]);

正确做法是用 useShallow(Zustand 4.4+ 内置):

import { useShallow } from 'zustand/react/shallow';
const [range, category] = useFilterStore(
  useShallow((s) => [s.dateRange, s.category])
);

坑 2:Pinia 解构丢响应式。 上面提到了,必须用 storeToRefs。我们有个同事直接 const { dateRange } = useFilterStore(),结果日期选了不生效,查了半天。

坑 3:Context 和 Zustand 混用期间的状态不同步。 迁移过渡期两边都有筛选状态,我们写了个同步 hook 临时兜底,迁移完成后删掉。教训是迁移期间要有明确的"状态唯一来源"标记,否则很容易出现两个 store 打架。

优化:selector 函数提到组件外。 内联箭头函数每次渲染都是新引用,虽然 Zustand 内部做了缓存,但提到外面更稳:

const selectDateRange = (s: FilterState) => s.dateRange;
function ChartList() {
  const dateRange = useFilterStore(selectDateRange);
}

优化:批量更新用 set 合并。 筛选面板一次改多个字段时,分开调用 set 会触发多次订阅通知。我们用 set({...}) 一次性提交,配合 subscribeWithSelector 做防抖持久化。

六、效果数据

迁移后跑了一周的埋点对比,数据来自 Chrome Performance 和自建性能监控(采样率 100%,取 P50 和 P95):

指标 迁移前 迁移后 变化
单次筛选交互组件渲染次数 42 次 6 次 -85.7%
交互到图表更新延迟 P50 180ms 45ms -75%
首屏可交互时间(TTI) 3.8s 2.9s -23.7%
筛选面板输入卡顿(>100ms 帧) 每秒 8 次 每秒 1 次 -87.5%
Context 相关代码行数 约 1200 行 约 480 行 -60%

包体积方面,Zustand 1.2KB + Pinia 2.8KB,但删掉了自研的 Context 工具函数和一堆 useMemo/memo 补丁,净增不到 1KB。

还有一个隐性收益:Props 透传层数从 5 层降到 0。新加筛选字段只需改 store 和一个组件,改文件数从 6 个降到 2 个。

七、总结

回头看,迁移的核心不是"换个库",而是想清楚状态该放哪一层。Context 和 provide/inject 不是不能用,是它们解决的是依赖注入问题,不是状态订阅粒度问题。当你的 Profiler 里出现大量"父组件状态变了、子组件跟着渲染"时,就该考虑换工具了。

给同样在迁移的同学三条建议:

  1. 先划边界再选库。服务端状态(接口数据)和客户端状态(UI 交互)分开管,store 体积能砍一半。
  2. 渐进迁移,别一刀切。保留 Context 兜底,按模块替换,跑稳一个月再删旧代码。
  3. 选择器写法决定性能上限。Zustand 的 useShallow、Pinia 的 storeToRefs 这两个细节没处理好,迁移等于白做。

最后说句实话:如果你的项目只有三五个全局状态、组件层级不超过三层,Context 完全够用,别为了用库而用库。我们迁移是因为 42 次重渲染已经影响用户体验了,不是为了追新技术。工具是手段,性能才是目的。