一、问题背景:Context 不是银弹
我们这套系统是一个混合栈后台:主框架 React 18.2,内嵌一个 Vue 3.3 的报表子应用,两边通过 iframe 之外的微前端方式共存。项目做了两年,状态管理一直是“能跑就行”:
- React 侧:一个
AppContext塞了用户信息、权限、主题、全局筛选条件、字典缓存,Provider 嵌套了 5 层(Theme → Auth → Permission → Dict → Filter)。 - Vue 侧:
provide/inject从根组件往下传,报表页的筛选条件靠inject拿。
问题在去年 Q4 集中爆发。运营反馈:列表页搜索框输入一个字符,光标会“顿一下”。我用 React Profiler 录了一段:输入 “abc” 三个字符,每次 setState 触发 Context value 变化,AppContext.Provider 下订阅的组件树里 约 120 个组件发生重渲染,其中真正依赖该字段的只有 6 个。单次 commit 耗时 140–180ms,在低端办公机上直接掉帧。
根因很明确:Context 的 value 是一个大对象,任何字段变都会生成新引用,所有 useContext 的消费者全部重渲染;useMemo 只能缓解不能根治,因为依赖数组里只要有一个字段变,整个 value 还是新的。props drilling 更不用说,中间层组件为了透传被迫接收一堆无关 props。
于是决定迁移到专门的状态库:React 用 Zustand(轻、无 Provider、选择器粒度细),Vue 用 Pinia(官方推荐、devtools 友好、TS 支持好)。
二、环境与版本
react: 18.2.0
react-dom: 18.2.0
zustand: 4.4.7
vue: 3.3.4
pinia: 2.1.7
typescript: 5.2.2
vite: 4.4.9
node: 18.17.1
选型对比我们拉了个表,重点看三个维度:
| 方案 | 包体积(gzip) | 是否需要Provider | 选择器粒度 | 学习成本 |
|---|---|---|---|---|
| Context+useReducer | 0 | 是 | 组件级 | 低 |
| Redux Toolkit 1.9 | ~13KB | 是 | 选择器 | 中 |
| Zustand 4.4 | ~1.2KB | 否 | 字段级 | 低 |
| Jotai 2.4 | ~3KB | 否 | 原子级 | 中 |
| Pinia 2.1 (Vue) | ~5KB | 否(需app.use) | 字段级 | 低 |
Redux 对我们这种“中等复杂度、团队5人”的项目偏重,模板代码多;Jotai 原子化很优雅但团队没经验;Zustand API 极简、不需要包 Provider、支持 subscribeWithSelector 和 shallow 比较,胜出。Vue 侧没有悬念,Pinia 是官方方案。
三、方案设计:按领域切片,而不是按组件切片
迁移最容易犯的错是把原来那个大 Context 原样搬成一个 store。我们按“更新频率 + 消费范围”切了 4 个 store:
useAuthStore:用户信息、token、权限码。低频更新(登录/刷新),全局消费。useThemeStore:主题、布局密度。低频,全局消费。useDictStore:字典缓存。写入后基本不变,用Map存,配合持久化。useFilterStore:列表筛选条件。高频更新(每次输入),只有列表页消费——这是性能优化的重点。
关键原则:高频状态单独成 store,并且消费端必须用选择器订阅具体字段,不能 const store = useFilterStore() 一把梭,那等于换了个壳的 Context。
Vue 侧同理,useReportFilterStore 独立,报表组件用 storeToRefs 解构具体字段。
四、核心实现
4.1 React:Zustand store 与选择器
// stores/filterStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { shallow } from 'zustand/shallow';
interface FilterState {
keyword: string;
status: string | null;
page: number;
pageSize: number;
setKeyword: (v: string) => void;
setStatus: (v: string | null) => void;
setPage: (v: number) => void;
reset: () => void;
}
const initial = { keyword: '', status: null, page: 1, pageSize: 20 };
export const useFilterStore = create()(
subscribeWithSelector((set) => ({
...initial,
setKeyword: (keyword) => set({ keyword, page: 1 }),
setStatus: (status) => set({ status, page: 1 }),
setPage: (page) => set({ page }),
reset: () => set(initial),
}))
);
消费端必须精确订阅。下面这个搜索框组件只订阅 keyword 和 setKeyword,输入时只有它自己重渲染:
// components/SearchInput.tsx
import { useFilterStore } from '@/stores/filterStore';
export function SearchInput() {
// 逐个字段订阅,避免对象整体订阅
const keyword = useFilterStore((s) => s.keyword);
const setKeyword = useFilterStore((s) => s.setKeyword);
return (
setKeyword(e.target.value)}
/>
);
}
需要一次取多个字段时,用 useShallow(Zustand 4.4 提供)或 shallow 比较,避免每次返回新对象导致重渲染:
import { useShallow } from 'zustand/react/shallow';
const { status, page, pageSize } = useFilterStore(
useShallow((s) => ({ status: s.status, page: s.page, pageSize: s.pageSize }))
);
另外把原来 Context 里的副作用迁到 subscribeWithSelector,比如筛选变化后防抖请求:
// 防抖 300ms 后触发列表请求,替代原来 useEffect 里的依赖数组
let timer: ReturnType;
useFilterStore.subscribe(
(s) => [s.keyword, s.status, s.page],
() => {
clearTimeout(timer);
timer = setTimeout(() => fetchList(useFilterStore.getState()), 300);
},
{ equalityFn: shallow }
);
4.2 Vue:Pinia store 与 storeToRefs
// stores/reportFilter.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useReportFilterStore = defineStore('reportFilter', () => {
const keyword = ref('');
const dateRange = ref(null);
const page = ref(1);
const query = computed(() => ({
keyword: keyword.value,
start: dateRange.value?.[0],
end: dateRange.value?.[1],
page: page.value,
}));
function setKeyword(v: string) {
keyword.value = v;
page.value = 1;
}
return { keyword, dateRange, page, query, setKeyword };
});
组件里用 storeToRefs 保持响应性,同时只解构需要的字段:
import { storeToRefs } from 'pinia';
import { useReportFilterStore } from '@/stores/reportFilter';
const store = useReportFilterStore();
const { keyword, page } = storeToRefs(store);
第 {{ page }} 页
注意别写 const { keyword } = store,那样会丢失响应性——这是 Pinia 新手最常见的坑。
五、踩坑与优化
坑1:迁移期双写导致状态不一致。 我们分两周灰度,期间 Context 和 Zustand 并存。有次筛选条件在 Context 更新了、store 没更新,列表请求参数错乱。解决办法是写了个临时同步层:Context 的 reducer 里 useEffect 同步到 store,并加了一条 lint 规则禁止新代码 useContext(AppContext),两周后删掉同步层。
坑2:选择器返回新对象引发无限重渲染。 早期有同事写 useFilterStore((s) => ({ a: s.a, b: s.b })),每次返回新引用,React 判定变化 → 重渲染 → 再取 → 再变,直接 Maximum update depth。统一改成 useShallow 后解决。这条我们写进了 code review checklist。
坑3:Zustand 的 set 合并是浅合并。 嵌套对象更新时 set({ filter: { ... } }) 会整个替换,得手动展开。我们把 state 拍平成一层,避开了这个问题。
坑4:Pinia 在微前端子应用里的实例隔离。 报表子应用独立挂载,createPinia() 每次挂载新建实例,热更新时旧 store 残留。改成在子应用 mount 时创建、unmount 时丢弃,并在 vite.config.ts 里配 optimizeDeps.include: ['pinia'] 避免重复打包。
优化: 给 useFilterStore 加了 devtools 中间件(仅 dev 环境),配合 Redux DevTools 看每次 action 的 diff;字典 store 用 persist 中间件存 localStorage,key 为 dict-cache-v2,TTL 24 小时,减少首屏 3 次字典请求。
六、效果数据
迁移前后用同一台测试机(i5-8250U / 8G / Chrome 118)跑:
| 指标 | Context/provide 方案 | Zustand/Pinia 方案 |
|---|---|---|
| 搜索输入单次 commit | 140–180ms | 8–16ms |
| 输入 “abc” 重渲染组件数 | ~120 | 3(输入框+2个派生) |
| 列表页首次渲染 | 620ms | 540ms |
| 首屏 JS 体积(gzip) | 基准 | +2.8KB |
| Lighthouse Performance | 71 | 83 |
React Profiler 里 commit 的火焰图从“一片红”变成几个小方块。运营侧反馈搜索不再卡顿。代码层面,删掉了 3 个 Context 文件、约 400 行 props 透传代码,新增 store 约 260 行,净减 140 行。Vue 子应用的报表筛选联动延迟从肉眼可见的 ~90ms 降到 20ms 以内。
七、总结
这次迁移最大的收获不是换了个库,而是想清楚了一件事:状态管理的第一原则是更新频率和消费范围要匹配。Context 和 provide/inject 适合低频、大范围的状态(主题、用户),高频状态放进去就是给自己挖坑。迁移步骤建议:先按领域切 store → 灰度双写 → 逐组件替换消费端并加 lint 约束 → 删旧代码 → 用 Profiler 验证。Zustand 的选择器订阅和 Pinia 的 storeToRefs 是性能收益的核心,用错了等于白迁。如果你的项目还在用一个大 Context 塞所有东西,且已经出现输入卡顿,值得动手了——成本比想象中低,我们两个人两周搞定。