问题背景:Context不是状态管理库,但我们把它当成了
项目是一个后台管理系统,React 18.2 + TypeScript 5.2 做数据看板,Vue 3.3 做表单配置模块,两个子应用通过 qiankun 2.10 拼在一起。团队规模 6 人,迭代节奏两周一个版本。
早期的状态很简单:用户信息、主题、权限码,用 Context 完全够。问题出在业务变复杂之后——筛选条件、分页、表格选中项、弹窗开关,全都往一个 AppContext 里塞。半年后这个 Context 的 value 有 27 个字段,每次 setState 都会生成新对象,导致 useContext 的消费者全部重渲染。
我们做过一次 Profiler 采样:在数据列表页点击「筛选」按钮,React DevTools 显示提交阶段触发了 38 次组件重渲染,其中 31 次是无效渲染。页面有 200 行表格数据,卡顿肉眼可见。Vue 那边用 provide/inject 也有类似问题:注入的 reactive 对象被整个替换,依赖收集粒度太粗,表单联动时整棵子树更新。
到这一步我才承认:Context 和 provide/inject 解决的是「依赖传递」问题,不是「状态管理」问题。它们缺少选择器、缺少细粒度订阅、缺少 devtools 时间旅行,也没有脱离组件树的状态组织能力。
环境与版本
先把版本交代清楚,避免读者对不上:
- React 18.2.0 / TypeScript 5.2.2 / Vite 4.4.9
- Zustand 4.4.1(配
use-sync-external-store1.2.0 shim) - Vue 3.3.4 / Pinia 2.1.6 / Vite 4.4.9
- React DevTools 4.28、Vue DevTools 6.6
- 性能采集:Chrome 118,Lighthouse 11.0,CPU 4x 降速
选型时对比了四个方案,最终 React 侧选 Zustand、Vue 侧选 Pinia,理由在下一节。
方案设计:为什么是 Zustand 和 Pinia
先上对比表,这是我们内部评审时用的:
| 维度 | Context + useReducer | Redux Toolkit 1.9 | Zustand 4.4 | Jotai 2.4 |
|---|---|---|---|---|
| 重渲染粒度 | 整个 Provider 子树 | 需 useSelector 手写 | 选择器自动订阅 | 原子级 |
| 样板代码 | 中 | 高(slice + thunk) | 低 | 低 |
| 包体积(gzip) | 0 | ~13KB | ~1.2KB | ~3.5KB |
| 异步处理 | 手写 | createAsyncThunk | 直接 async action | 需插件 |
| DevTools | 无 | 强 | 支持 | 支持 |
| 学习成本 | 低 | 中高 | 低 | 中 |
Redux Toolkit 功能最全,但我们的迁移目标是「最小改动止血」,13KB 的体积和 slice 样板对 6 人团队是负担。Jotai 原子模型优雅,但把现有 27 个字段拆成原子是一次大重构。Zustand 的 store 就是一个 hook,选择器天然支持细粒度订阅,迁移成本最低。
Vue 侧没有太多纠结:Pinia 是官方推荐,2.1 版本对 SSR 和 devtools 支持成熟,且 defineStore 的 setup 写法几乎就是把 reactive 搬过去,心智负担为零。备选的 Vuex 4 已经进入维护模式,不选。
架构上我们定了三条规则:
- 按业务域拆 store,不搞全局大 store。看板一个、用户一个、UI 偏好一个。
- 组件只订阅自己需要的字段,禁止
useStore()不带选择器。 - 服务端数据不进 store,继续用 TanStack Query 4.36 管缓存,store 只放客户端状态。
核心实现:从 Context 到 Zustand
迁移前的 Context 长这样,27 个字段塞在一个 value 里:
// 迁移前:AppContext.tsx
const AppContext = createContext(null);
export function AppProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState(initialState);
// 每次 setState 都生成新对象,所有消费者重渲染
return {children};
}
// 组件里
const { filters, setFilters, page, setPage } = useContext(AppContext)!;
迁移后,store 按域拆分,筛选和分页归到 useFilterStore:
// stores/filterStore.ts
import { create } from 'zustand';
import { devtools, subscribeWithSelector } from 'zustand/middleware';
interface FilterState {
keyword: string;
status: 'all' | 'active' | 'archived';
page: number;
pageSize: number;
selectedIds: string[];
setKeyword: (v: string) => void;
setStatus: (v: FilterState['status']) => void;
setPage: (v: number) => void;
toggleSelect: (id: string) => void;
reset: () => void;
}
const initialState = {
keyword: '',
status: 'all' as const,
page: 1,
pageSize: 20,
selectedIds: [] as string[],
};
export const useFilterStore = create()(
devtools(
subscribeWithSelector((set) => ({
...initialState,
setKeyword: (keyword) => set({ keyword, page: 1 }, false, 'setKeyword'),
setStatus: (status) => set({ status, page: 1 }, false, 'setStatus'),
setPage: (page) => set({ page }, false, 'setPage'),
toggleSelect: (id) =>
set(
(s) => ({
selectedIds: s.selectedIds.includes(id)
? s.selectedIds.filter((x) => x !== id)
: [...s.selectedIds, id],
}),
false,
'toggleSelect'
),
reset: () => set(initialState, false, 'reset'),
})),
{ name: 'filterStore' }
)
);
组件侧的关键是选择器必须返回稳定引用。下面这个列表组件只订阅 keyword 和 status,selectedIds 变化不会触发它:
// components/FilterBar.tsx
import { useShallow } from 'zustand/react/shallow';
import { useFilterStore } from '../stores/filterStore';
export function FilterBar() {
// 多个字段用 useShallow,避免每次返回新对象导致重渲染
const { keyword, status, setKeyword, setStatus } = useFilterStore(
useShallow((s) => ({
keyword: s.keyword,
status: s.status,
setKeyword: s.setKeyword,
setStatus: s.setStatus,
}))
);
return (
setKeyword(e.target.value)}
placeholder="搜索..."
/>
setStatus(e.target.value as any)}>
全部
进行中
已归档
);
}
Vue 侧迁移更直接,provide/inject 换成 Pinia 的 setup store:
// stores/useFormStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useFormStore = defineStore('form', () => {
const fields = ref>({});
const dirty = ref(false);
const filledCount = computed(() => Object.values(fields.value).filter(Boolean).length);
function setField(key: string, value: string) {
fields.value[key] = value;
dirty.value = true;
}
function reset() {
fields.value = {};
dirty.value = false;
}
return { fields, dirty, filledCount, setField, reset };
});
组件里 const store = useFormStore(),模板中 store.filledCount 自动按需追踪,比 inject 整个 reactive 对象精确得多。
踩坑与优化:三个真实问题
坑一:选择器返回新对象,重渲染比 Context 还猛。 最初我写 useFilterStore((s) => ({ a: s.a, b: s.b })),每次 store 更新都返回新对象,Zustand 用 Object.is 比较直接判定不等,组件疯狂重渲染。解法是 useShallow,或者拆成多次单字段订阅。这个坑在官方文档里写了,但很容易忽略。
坑二:异步 action 里拿不到最新 state。 在 setTimeout 回调里读 useFilterStore.getState() 是安全的,但如果闭包捕获了组件里的 page 变量,就是旧值。统一改成 get().page 或 useFilterStore.getState().page。
坑三:DevTools 中间件在 production 被打包。 devtools 中间件默认会检查 process.env.NODE_ENV,但 Vite 下需要显式配置。我们在 vite.config.ts 里加了 define: { 'process.env.NODE_ENV': JSON.stringify(mode) },否则生产包多出约 4KB。
优化方面做了两件事:一是给 subscribeWithSelector 加上持久化,把 pageSize 和 status 存 localStorage;二是把表格行的选中状态从数组改成 Set,200 行数据下 toggleSelect 从 O(n) 降到 O(1),实测点击响应从 12ms 降到 3ms。
效果数据
迁移前后各跑 5 次 Lighthouse,取中位数:
| 指标 | 迁移前(Context) | 迁移后(Zustand) | 变化 |
|---|---|---|---|
| 筛选操作重渲染次数 | 38 | 6 | -84% |
| 首屏可交互时间(TTI) | 2.8s | 1.9s | -32% |
| Lighthouse Performance | 61 | 82 | +21 |
| 主包体积(gzip) | 0(Context) | +1.2KB | 可接受 |
| 列表滚动 FPS | 42 | 58 | +38% |
Vue 模块的表单联动场景,输入一个字符触发的组件更新从 17 个降到 4 个,输入延迟从 45ms 降到 11ms。这些数字不是 benchmark,是真实业务页面的采样,但足够说明问题:状态管理的核心收益不是「代码更好看」,而是订阅粒度变细带来的渲染次数下降。
总结
如果重来一次,我会更早划清边界:服务端数据交给 TanStack Query,客户端状态交给 Zustand/Pinia,Context 只用来传主题和 i18n 这类几乎不变的配置。迁移本身不难,难的是先承认「Context 撑不住了」——我们拖了两个月,代价是用户投诉了三次卡顿。
给准备迁移的团队三条建议:按业务域拆 store 而不是搞大仓库;选择器一律用 useShallow 或单字段订阅;迁移期间保留旧 Context 做灰度,用 feature flag 切换,别一次性删干净。工具是次要的,订阅粒度才是状态管理的第一性问题。