一、问题背景: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 保留作为兜底,跑一个月没问题再删。
分层设计:
- 全局层(用户、权限、主题):Zustand/Pinia store,应用启动时初始化
- 模块层(筛选条件、图表配置):按业务域拆 store,避免单个巨型 store
- 局部层(弹窗开关、表单临时态):继续用
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 里出现大量"父组件状态变了、子组件跟着渲染"时,就该考虑换工具了。
给同样在迁移的同学三条建议:
- 先划边界再选库。服务端状态(接口数据)和客户端状态(UI 交互)分开管,store 体积能砍一半。
- 渐进迁移,别一刀切。保留 Context 兜底,按模块替换,跑稳一个月再删旧代码。
- 选择器写法决定性能上限。Zustand 的
useShallow、Pinia 的storeToRefs这两个细节没处理好,迁移等于白做。
最后说句实话:如果你的项目只有三五个全局状态、组件层级不超过三层,Context 完全够用,别为了用库而用库。我们迁移是因为 42 次重渲染已经影响用户体验了,不是为了追新技术。工具是手段,性能才是目的。