一、问题背景:Context 不是状态管理库,Props 更不是
先说结论:Context 是依赖注入工具,不是状态管理方案。这句话我在迁移前就看过,但真正被教育是在一次线上事故之后。
我们的后台系统有两个入口:React 18.2 写的运营控制台,Vue 3.3 写的商家工作台。两边共享一套业务模型,但状态管理是各写各的:
- React 侧:
AppContext+useReducer,所有全局状态塞在一个 reducer 里,Provider 包在最顶层。 - Vue 侧:props + emits 一路往下传,跨层级用
provide/inject兜底。
问题在 Q2 集中爆发:
- React 侧:一个"切换租户"的操作会触发
AppContext更新,而 Context value 是一个新对象,导致所有useContext(AppContext)的组件全部重渲染。我们在 React DevTools Profiler 里看到一次切换租户触发了 312 次组件渲染,其中真正需要更新的只有侧边栏和列表区大约 40 个组件。 - Vue 侧:
provide/inject的响应式对象被某个子组件直接mutate,父组件不知情,出现了一个"改了数据但视图不更新"的 bug,排查了两天才定位到。
指标上:Lighthouse 首屏 TTI 3.4s,React Profiler 单次交互平均渲染耗时 180ms,Vue 侧一次表单联动触发 60+ 组件更新。
这不是性能优化问题,是架构问题。
二、环境与版本
先交代清楚环境,避免版本差异导致结论不可复现。
React: 18.2.0
ReactDOM: 18.2.0
Zustand: 4.5.2
Immer: 10.1.1
Vue: 3.3.4
Pinia: 2.1.7
Vite: 5.0.10
TypeScript: 5.3.3
Node: 20.11.0
浏览器基准:Chrome 120,MacBook Pro M1 16G,测试数据取 5 次运行中位数。
三、方案设计:为什么选 Zustand 和 Pinia
候选方案我们列了这几个:
React 侧:
| 方案 | 包体积(gzip) | 学习成本 | 重渲染控制 | 结论 |
|---|---|---|---|---|
| Context + useReducer | 0 | 低 | 差(全量订阅) | 现状,淘汰 |
| Redux Toolkit | ~13KB | 中 | 中(需 useSelector) | 样板代码多 |
| Zustand | ~1.2KB | 低 | 好(selector 粒度) | 采用 |
| Jotai | ~3KB | 中 | 好(原子化) | 适合细粒度,但迁移成本高 |
Vue 侧:
| 方案 | 包体积(gzip) | DevTools | SSR | 结论 |
|---|---|---|---|---|
| props/emits | 0 | 无 | 支持 | 现状,淘汰 |
| provide/inject | 0 | 无 | 支持 | 隐式依赖,淘汰 |
| Vuex 4 | ~4KB | 好 | 支持 | 官方已推荐 Pinia |
| Pinia | ~1.5KB | 好 | 支持 | 采用 |
选 Zustand 的核心理由:它不在 React 树里,订阅是外部的。这意味着 Context 那种"Provider value 变了整棵树重渲染"的问题从根上不存在。Pinia 则是 Vue 官方推荐,TypeScript 推断好,且天然支持组合式 API。
四、核心实现
4.1 React:从 Context 到 Zustand
迁移前(简化版):
// 迁移前:Context + useReducer
const AppContext = createContext(null);
function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
// value 每次都是新对象,全量订阅者重渲染
const value = { state, dispatch };
return {children};
}
// 任意子组件
function TenantSwitcher() {
const { state, dispatch } = useContext(AppContext);
// 只用到 state.tenant,但 state.user / state.list 变化也会重渲染
return dispatch({ type: 'SWITCH_TENANT' })}>{state.tenant};
}
迁移后:
// store/useAppStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface AppState {
tenant: string;
user: { id: string; name: string } | null;
list: Array;
switchTenant: (t: string) => void;
}
export const useAppStore = create()(
immer((set) => ({
tenant: 'default',
user: null,
list: [],
switchTenant: (t) =>
set((draft) => {
draft.tenant = t;
}),
}))
);
// 使用:selector 精确订阅
function TenantSwitcher() {
// 只订阅 tenant,其他字段变化不触发本组件重渲染
const tenant = useAppStore((s) => s.tenant);
const switchTenant = useAppStore((s) => s.switchTenant);
return switchTenant('new-tenant')}>{tenant};
}
关键点:
- selector 必须返回原始值或稳定引用。如果写
useAppStore((s) => ({ tenant: s.tenant })),每次返回新对象,会触发无限重渲染。要配合useShallow:
import { useShallow } from 'zustand/react/shallow';
const { tenant, user } = useAppStore(useShallow((s) => ({ tenant: s.tenant, user: s.user })));
- action 直接写在 store 里,不要单独 dispatch,减少一层间接。
4.2 Vue:从 props/emits 到 Pinia
迁移前:
一路透传,中间组件被迫声明自己根本不用的 props。
迁移后:
// stores/app.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useAppStore = defineStore('app', () => {
const tenant = ref('default');
const user = ref(null);
const tenantLabel = computed(() => `租户:${tenant.value}`);
function switchTenant(t: string) {
tenant.value = t;
}
return { tenant, user, tenantLabel, switchTenant };
});
import { storeToRefs } from 'pinia';
import { useAppStore } from '@/stores/app';
const store = useAppStore();
// 用 storeToRefs 保持解构后的响应性
const { tenant, user } = storeToRefs(store);
// action 直接解构,不需要 storeToRefs
const { switchTenant } = store;
{{ tenant }}
踩坑点:直接解构 store 会丢响应性,必须用 storeToRefs。这是 Pinia 新手最容易犯的错,我们 code review 时抓到过 3 次。
五、踩坑与优化
坑 1:Zustand selector 返回新对象导致无限渲染
前面提过。表现是 React 报 "Maximum update depth exceeded"。解决:useShallow 或拆成多个原子 selector。
坑 2:Pinia 在 setup 外使用
在路由守卫、axios 拦截器里用 useAppStore() 会报 "no active Pinia"。解决:在 main.ts 里 app.use(pinia) 之后,把 pinia 实例导出,或者在这些地方传入实例:
// main.ts
export const pinia = createPinia();
app.use(pinia);
// 拦截器里
import { pinia } from '@/main';
const store = useAppStore(pinia);
坑 3:Zustand 的 set 默认浅合并,嵌套对象要小心
set({ user: { name: 'x' } }) 会整个替换 user,丢掉 id。我们用 immer 中间件解决,写法更直观。
坑 4:迁移期间的混合状态
迁移不是一蹴而就的,我们花了 3 周,期间新旧共存。做法是:先迁移叶子组件依赖的全局状态,再逐层往上。Context 和 Zustand 可以并存,但要注意不要两处都存同一份数据,否则同步噩梦。
优化:selector 粒度
迁移后我们发现还有优化空间。原来 useAppStore((s) => s.list) 在 list 数组任何一项变化时都会重渲染,即使用了 React.memo。后来改成:
// 列表组件只订阅长度和 id 列表
const ids = useAppStore(useShallow((s) => s.list.map((i) => i.id)));
渲染次数又降了约 15%。
六、效果数据
迁移前后对比(同一套测试用例,5 次中位数):
React 侧:
| 指标 | Context | Zustand | 变化 |
|---|---|---|---|
| 切换租户触发渲染次数 | 312 | 87 | -72% |
| 单次交互平均渲染耗时 | 180ms | 52ms | -71% |
| 首屏 TTI | 3.4s | 1.9s | -44% |
| 包体积(gzip) | 0 | +1.2KB | +1.2KB |
| Lighthouse Performance | 68 | 89 | +21 |
Vue 侧:
| 指标 | props/emits | Pinia | 变化 |
|---|---|---|---|
| 表单联动触发更新组件数 | 60+ | 12 | -80% |
| 表单输入响应延迟 | 120ms | 28ms | -77% |
| 代码行数(状态相关) | ~2400 | ~1600 | -33% |
包体积上 Zustand + Pinia 一共增加约 2.7KB gzip,换来 TTI 缩短 1.5s,非常划算。
七、总结
几点真实体会:
- Context 和 props 不是不能用,是不该用在频繁更新的全局状态上。静态配置、主题、i18n 用 Context 完全没问题。判断标准:这个状态更新频率高不高?订阅者多不多?两个都高,就该上状态管理库。
- Zustand 的核心优势是"在 React 树外"。它不是更快的 Context,它是完全不同的订阅模型。理解这一点,很多设计决策就顺了。
- Pinia 的
storeToRefs是必踩的坑,团队里要有约定,code review 要盯。 - 迁移要分阶段,不要想着一次全换。新旧共存期间,关键是"同一份数据只有一个 source of truth"。
- 性能数据要自己测。网上说的"Zustand 比 Redux 快"、"Pinia 比 Vuex 轻"都是参考,你的项目里真正瓶颈在哪,只有 Profiler 知道。
如果你们也在做类似迁移,欢迎交流。下一步我们打算试试 Jotai 做细粒度表单状态,有经验的朋友可以留言。