一、问题背景:Context 不是状态管理库
项目是一个混合栈中后台:主框架 React 18.2,部分历史模块是 Vue 3.3,通过微前端共存。早期为了快,跨组件状态基本靠两种方式:
- React 侧:
createContext+useContext,Provider 嵌套最多到 5 层; - Vue 侧:
provide/inject配合reactive对象。
问题在业务变复杂后集中爆发。最典型的一次:订单筛选表单有 12 个字段,都放在同一个 FilterContext 里,用户每敲一个字符,Context value 变化,所有 useContext(FilterContext) 的组件全部重渲染。我们用 React DevTools Profiler 录了一次输入过程:
- 单次按键触发渲染组件数:18
- 提交阶段耗时:约 42ms
- 输入框在低端机(Redmi Note 系列)上有肉眼可见的延迟
Vue 侧的问题不同但同源:provide/inject 注入的 reactive 对象粒度太粗,任何一个字段变化,依赖该对象的组件都要走一遍更新。而且它没有 devtools 时间旅行、没有统一的 action 日志,排查“谁改了这个值”只能靠断点。
一句话总结:Context / provide 解决的是“依赖注入”,不是“状态管理”。它们缺少选择器、缺少细粒度订阅、缺少可观测性。
二、环境与版本
先把环境交代清楚,避免版本差异导致结论不可复用:
| 项 | 版本 |
|---|---|
| React | 18.2.0 |
| Vue | 3.3.4 |
| Zustand | 4.5.2 |
| Pinia | 2.1.7 |
| Vite | 5.2.8 |
| TypeScript | 5.4.3 |
| Node | 18.19.0 |
选型上没有搞“统一一个库”,而是各栈用各自生态最顺的:React 用 Zustand,Vue 用 Pinia。原因后面讲。
三、方案对比:为什么是 Zustand + Pinia
我们对比了四类方案,评估维度是:重渲染粒度、样板代码量、DevTools、SSR/微前端兼容、迁移成本。
1. 继续用 Context,但拆分 + memo
把一个大 Context 拆成十几个小 Context,配合 useMemo 和 React.memo。可行,但代价是 Provider 嵌套爆炸,且每次新增字段都要判断“该放哪个 Context”,心智负担高。实测拆到 7 个 Context 后,单次按键渲染组件数从 18 降到 9,但仍不理想。
2. Redux Toolkit
能力强、DevTools 完善,但样板代码多。我们的状态大多是“表单草稿、UI 开关、用户偏好”这类中低频状态,用 slice + thunk 写起来偏重。团队里 6 个人,有 3 个不熟 RTK,学习成本要考虑。
3. Zustand 4.5
API 极简,create 一个 store 就是一个 hook,天然支持选择器订阅,useStore(s => s.xxx) 只在选中值变化时重渲染。没有 Provider 包裹(除非需要),对微前端友好。缺点是生态约定弱,需要团队自己定规范。
4. Pinia 2.1(Vue 侧)
Vue 官方推荐,和 Vue Devtools 深度集成,支持 setup 语法写 store,storeToRefs 解构保持响应性。Vue 侧没有理由不用它。
最终决定:React 侧 Zustand,Vue 侧 Pinia。核心判断是——状态管理库的价值在于“订阅粒度”和“可观测性”,而不是“能不能共享”。
四、核心实现与迁移步骤
迁移不是一次性重写,我们按“先立规范、再迁高频、最后清残留”三步走,历时 3 周,涉及 27 个文件。
4.1 第一步:定义 store 分层规范
我们定了三条硬规则:
- 按“领域”切 store,不按“页面”切。比如
useOrderFilterStore、useUserPreferenceStore。 - 一个 store 内 state 扁平化,禁止三层以上嵌套,避免选择器写起来痛苦。
- 所有写操作封装成 action,组件里不允许
setState式直接改。
4.2 第二步:React 侧从 Context 迁到 Zustand
迁移前的典型代码(简化):
// 迁移前:FilterContext.tsx
const FilterContext = createContext(null);
export function FilterProvider({ children }: { children: React.ReactNode }) {
const [filter, setFilter] = useState({ keyword: '', status: 'all', page: 1 });
const value = useMemo(() => ({ filter, setFilter }), [filter]);
return {children};
}
// 任意子组件
const { filter, setFilter } = useContext(FilterContext)!;
问题很明显:value 一变,所有消费者重渲染;setFilter 和 filter 绑在一起,只想拿 setter 的组件也被迫订阅数据。
迁移后:
// stores/useOrderFilterStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
interface OrderFilterState {
keyword: string;
status: 'all' | 'pending' | 'done';
page: number;
setKeyword: (v: string) => void;
setStatus: (v: OrderFilterState['status']) => void;
reset: () => void;
}
const initialState = { keyword: '', status: 'all' as const, page: 1 };
export const useOrderFilterStore = create()(
subscribeWithSelector((set) => ({
...initialState,
setKeyword: (keyword) => set({ keyword, page: 1 }),
setStatus: (status) => set({ status, page: 1 }),
reset: () => set(initialState),
}))
);
组件里按需订阅,这是关键:
// 只关心 keyword 的输入框组件
function KeywordInput() {
const keyword = useOrderFilterStore((s) => s.keyword);
const setKeyword = useOrderFilterStore((s) => s.setKeyword);
return setKeyword(e.target.value)} />;
}
// 只关心 status 的下拉框,keyword 变化时它不会重渲染
function StatusSelect() {
const status = useOrderFilterStore((s) => s.status);
const setStatus = useOrderFilterStore((s) => s.setStatus);
return (
setStatus(e.target.value as any)}>
全部
待处理
已完成
);
}
迁移动作本身机械:把 Context 里的 state 和 setter 搬进 store,把 useContext 换成 useStore(selector),然后删掉 Provider。27 个文件里,有 19 个是纯替换,8 个需要拆分选择器。
4.3 第三步:Vue 侧从 provide/inject 迁到 Pinia
迁移前:
// 父组件
const filter = reactive({ keyword: '', status: 'all' });
provide('filter', filter);
// 子组件
const filter = inject('filter')!;
迁移后:
// stores/orderFilter.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useOrderFilterStore = defineStore('orderFilter', () => {
const keyword = ref('');
const status = ref('all');
const page = ref(1);
function setKeyword(v: string) {
keyword.value = v;
page.value = 1;
}
function reset() {
keyword.value = '';
status.value = 'all';
page.value = 1;
}
return { keyword, status, page, setKeyword, reset };
});
组件里用 storeToRefs 解构,保持响应性:
import { storeToRefs } from 'pinia';
import { useOrderFilterStore } from '@/stores/orderFilter';
const store = useOrderFilterStore();
const { keyword, status } = storeToRefs(store);
全部
待处理
Vue 侧迁移比 React 更顺,因为 provide/inject 本身用法就接近 store,替换后 API 差异小。唯一要改的是 inject 的默认值兜底逻辑——Pinia store 是全局单例,不再需要兜底。
五、踩坑与优化
坑 1:Zustand 选择器返回新对象导致无限重渲染
一开始有人这么写:
const { keyword, status } = useOrderFilterStore((s) => ({ keyword: s.keyword, status: s.status }));
选择器每次返回新对象,引用永远不等,组件每次都重渲染。解决办法是用 useShallow:
import { useShallow } from 'zustand/react/shallow';
const { keyword, status } = useOrderFilterStore(
useShallow((s) => ({ keyword: s.keyword, status: s.status }))
);
这个坑我们踩了两次,后来写进了团队规范:选择器返回对象必须包 useShallow。
坑 2:Pinia 解构丢响应性
const { keyword } = useOrderFilterStore() 解构出来的是普通值,改 store 不会更新视图。必须用 storeToRefs。这个在 code review 里拦下了 4 次。
坑 3:微前端下 store 实例隔离
React 主应用和 Vue 子应用各自有 store,跨应用通信原本靠 Context 传 callback,迁到 store 后不能直接共享。我们的做法是保留一个极薄的 bridge 事件总线(mitt 3.0.1),只在跨应用边界用,应用内部一律走 store。
优化:持久化
用户偏好类状态用 persist 中间件落 localStorage,配置如下:
import { persist } from 'zustand/middleware';
export const useUserPreferenceStore = create(
persist(
(set) => ({
theme: 'light' as 'light' | 'dark',
setTheme: (theme: 'light' | 'dark') => set({ theme }),
}),
{ name: 'user-preference', version: 1 }
)
);
注意 version 一定要给,后面改结构时用 migrate 做版本迁移,不然老用户 localStorage 里的脏数据会炸。
六、效果数据
迁移前后用同一台测试机(Redmi Note 11,Chrome 120)和 Lighthouse 跑了对比,取 5 次中位数:
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| 订单列表页输入延迟 P95 | 180ms | 45ms | -75% |
| 单次按键触发渲染组件数 | 18 | 3 | -83% |
| 首屏可交互时间(TTI) | 2.8s | 2.1s | -25% |
| 首屏 JS 体积(gzip) | 312KB | 298KB | -14KB |
| 状态相关 bug 月均数 | 9 | 3 | -67% |
JS 体积反而降了,是因为删掉了多层 Provider 包装和部分手写的 memo 逻辑。bug 数下降主要归功于 action 日志和 DevTools 时间旅行——出问题能直接看到是哪个 action 改的值。
七、总结
这次迁移最大的收获不是性能数字,而是纠正了一个认知:Context 和 provide/inject 是依赖注入工具,不是状态管理方案。当你的共享状态超过 10 个、或者出现“改 A 导致 B 重渲染”的时候,就该考虑换库了。
给准备迁移的团队几条建议:
- 别一次性重写。先定 store 分层规范,再迁高频状态,最后清残留 Provider。
- React 侧优先用 Zustand,选择器 +
useShallow的组合能解决 90% 的重渲染问题;Vue 侧直接用 Pinia,别自己造轮子。 - 持久化一定带
version,这是血泪教训。 - 迁移后把 DevTools 用起来,action 命名规范比代码规范更重要——它决定了你排查问题快不快。
如果你的项目还在用 Context 传十几个字段,不妨先打开 Profiler 录一段,看看那个渲染数字,再决定要不要迁。