一、问题背景:props 透传和 Context 到底哪里疼
项目是一个典型的中后台系统,React 18.2 + TypeScript 5.0 做管理端,Vue 3.3 + Vite 4.4 做数据看板,两个子应用共享一套业务模型。早期为了"简单",React 侧用 Context 存全局用户信息、权限、主题和一部分列表筛选条件,Vue 侧用 provide/inject 做类似的事,局部状态就靠 props 一层层往下传。
一开始没问题,页面层级浅。等到业务铺开,问题集中爆发:
第一,props 透传的层级到了 5~7 层。一个 DataTable 组件要接收 filters、onFilterChange、permissions、theme 四个跟它自身逻辑无关的 prop,中间三层组件纯粹当"二传手"。改一个字段名要动 6 个文件。
第二,Context 的"全量广播"特性让性能很难看。我们的 AppContext 把 user、theme、filters、notifications 塞在一个对象里,任何字段变化都会让所有 useContext 的消费者重渲染。用 React DevTools Profiler 录了一次筛选条件变更,提交阶段有 63 个组件重渲染,其中真正需要更新的只有 8 个。
第三,Vue 侧 provide/inject 的响应式追踪虽然比 Context 精细,但 inject 的值来源不透明,新人排查"这个 theme 从哪来的"要全局搜 provide(。而且 inject 默认不响应解构,团队里踩过 const { theme } = inject('config') 丢掉响应性的坑。
第四,测试困难。Context 的值要靠 wrapper 包裹,每个测试文件重复写 Provider,mock 成本高。
结论很明确:不是"要不要状态管理库"的问题,是"再不上就要重构不动了"。
二、环境与版本
先把版本钉死,避免读者对不上:
- React 18.2.0,react-dom 18.2.0,TypeScript 5.0.4
- Zustand 4.4.1,immer 10.0.2(中间件)
- Vue 3.3.4,Pinia 2.1.4,Vite 4.4.5
- 测试:Vitest 0.34.2 + @testing-library/react 14.0.0
- 构建:Vite 4.4.5,生产构建开启
build.sourcemap: false - 性能采集:React DevTools Profiler 4.28、Chrome 116 Performance 面板、
web-vitals3.4.0 采集 LCP
机器是 M1 Pro / 16G,Chrome 116,禁用缓存跑 5 次取中位数。
三、方案设计:选型对比与边界划分
选型时我们列了三个候选:Zustand、Redux Toolkit、Jotai(React 侧);Vue 侧对比 Pinia 和 Vuex 4。
React 侧最终选 Zustand,理由很实际:
- 不需要 Provider 包裹,store 是模块级单例,测试时直接
setState重置,省掉 wrapper。 - 选择器订阅粒度细,
useStore(s => s.filters)只在 filters 引用变化时重渲染,天然解决 Context 全量广播。 - 包体积小,gzip 后约 1.2KB,比 RTK 的 13KB 轻很多。
- 不需要写 action/reducer 模板,中后台的 CRUD 状态用起来不啰嗦。
Redux Toolkit 能力更强但模板代码多,团队已经在为 Context 的冗余头疼,不想再引入一层样板。Jotai 的原子化模型很好,但我们的状态是"按业务域聚合"而非"细粒度派生",用 atom 会把状态切得太碎,反而增加心智负担。
Vue 侧选 Pinia 没有悬念:Vuex 4 已进入维护模式,Pinia 是官方推荐,且 setup 语法写起来跟组合式 API 一致,storeToRefs 解决解构丢响应性的问题。
边界划分是这次迁移最重要的设计决策。我们定的规则:
- 跨页面、跨层级共享的业务状态进 store(用户、权限、主题、全局筛选、通知)。
- 组件内部 UI 状态(弹窗开关、输入框临时值)留在
useState/ref。 - 只在父子间传递、层级 ≤2 的数据继续用 props,不强行上 store。
- 服务端数据(列表、详情)不进 store,交给请求库缓存层,store 只存"筛选条件"这类客户端状态。
这条边界很关键,很多团队迁移失败是因为把什么都塞进 store,结果 store 变成一个巨型全局变量,性能没提升反而更难维护。
四、核心实现:两套代码
4.1 React:从 Context 到 Zustand
迁移前的 Context 长这样(简化):
// 迁移前:AppContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface AppState {
user: User | null;
theme: 'light' | 'dark';
filters: Record;
notifications: Notice[];
}
const AppContext = createContext) => void;
toggleTheme: () => void;
} | null>(null);
export function AppProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState({
user: null, theme: 'light', filters: {}, notifications: [],
});
const setFilters = (filters: Record) =>
setState(prev => ({ ...prev, filters }));
const toggleTheme = () =>
setState(prev => ({ ...prev, theme: prev.theme === 'light' ? 'dark' : 'light' }));
return (
{children}
);
}
export function useApp() {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('useApp must be used within AppProvider');
return ctx;
}
问题就在 value={{ state, setFilters, toggleTheme }}:每次 setState 都生成新对象,所有消费者无条件重渲染。
迁移后用 Zustand,按业务域拆成两个 store:
// store/appStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface User { id: string; name: string; roles: string[] }
interface AppStore {
user: User | null;
theme: 'light' | 'dark';
filters: Record;
setUser: (u: User | null) => void;
toggleTheme: () => void;
setFilters: (f: Record) => void;
reset: () => void;
}
const initialState = { user: null, theme: 'light' as const, filters: {} };
export const useAppStore = create()(
immer((set) => ({
...initialState,
setUser: (user) => set((s) => { s.user = user; }),
toggleTheme: () => set((s) => { s.theme = s.theme === 'light' ? 'dark' : 'light'; }),
setFilters: (filters) => set((s) => { s.filters = filters; }),
reset: () => set(initialState),
}))
);
// 选择器:组件只订阅自己需要的切片
export const useTheme = () => useAppStore((s) => s.theme);
export const useFilters = () => useAppStore((s) => s.filters);
export const useSetFilters = () => useAppStore((s) => s.setFilters);
组件里的用法:
// 迁移后:DataTable.tsx
import { useFilters, useSetFilters } from '@/store/appStore';
export function DataTable() {
const filters = useFilters(); // 只在 filters 引用变化时重渲染
const setFilters = useSetFilters(); // action 引用稳定,永不触发重渲染
return (
setFilters(next)}
/>
);
}
这里有个细节:useSetFilters 返回的是 store 里定义的函数,Zustand 保证 action 引用稳定,所以订阅 action 的组件不会因为状态变化而重渲染。这就是把"读"和"写"拆成两个 hook 的价值。
4.2 Vue:从 provide/inject 到 Pinia
迁移前的 provide/inject:
import { provide, reactive, readonly } from 'vue';
const config = reactive({ theme: 'light', filters: {} as Record });
provide('config', readonly(config));
import { inject } from 'vue';
const config = inject('config')!;
迁移后用 Pinia:
// stores/app.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useAppStore = defineStore('app', () => {
const theme = ref('light');
const filters = ref>({});
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light';
}
function setFilters(next: Record) {
filters.value = next;
}
return { theme, filters, toggleTheme, setFilters };
});
组件里用 storeToRefs 保持响应性:
import { storeToRefs } from 'pinia';
import { useAppStore } from '@/stores/app';
const store = useAppStore();
const { theme, filters } = storeToRefs(store); // 解构仍然响应式
const { setFilters } = store; // action 直接解构
storeToRefs 是 Vue 侧最大的收益点:它把 store 的 state 和 getter 转成 ref,解构不丢响应性,彻底消灭了 const { theme } = inject(...) 那类坑。
迁移步骤我们按这个顺序执行,两套技术栈通用:
- 盘点:用
grep -rn "useContext\|inject(" src列出所有消费点,标注"读"还是"写"。 - 建 store:按业务域拆,先建不接线,写单测。
- 双写过渡:Context 保留,新增 store 同步写一份,跑一周观察数据一致性。
- 逐组件切换:从叶子组件往上切,每切一个跑一次 Profiler 对比。
- 删除旧 Context:确认无引用后删 Provider,清理测试 wrapper。
第 3 步的双写很土但有效,出了不一致能立刻定位是哪个消费点漏切。
五、踩坑与优化
坑 1:选择器返回新对象导致无限重渲染。 有人写了 useAppStore(s => ({ theme: s.theme, filters: s.filters })),每次返回新对象,引用永远不等,组件无限重渲染。解决办法是用 useShallow:
import { useShallow } from 'zustand/react/shallow';
const { theme, filters } = useAppStore(
useShallow((s) => ({ theme: s.theme, filters: s.filters }))
);
坑 2:Vue 里解构 store 丢响应性。 直接 const { theme } = useAppStore() 拿到的是普通值,theme 变了组件不更新。必须用 storeToRefs。我们加了 ESLint 规则 pinia/no-store-destructuring 拦住这类写法。
坑 3:immer 中间件下误用返回值。 用了 immer 后 set 里直接改 s.xxx,如果又 return {...} 会覆盖掉 immer 的 draft,状态静默丢失。统一约定:immer 下 set 不写 return。
坑 4:测试隔离。 Zustand store 是模块单例,测试间会串状态。在 setup.ts 里加:
import { beforeEach } from 'vitest';
import { useAppStore } from '@/store/appStore';
beforeEach(() => useAppStore.getState().reset());
Pinia 则是在每个测试里 setActivePinia(createPinia())。
优化:selector 粒度。 把 useAppStore(s => s) 这种全量订阅全部改成字段级订阅。改完后 Profiler 里单次筛选变更的重渲染组件数从 63 降到 8。
六、效果数据
迁移前后用同一份操作脚本跑 5 次取中位数:
| 指标 | 迁移前(Context/provide) | 迁移后(Zustand/Pinia) |
|---|---|---|
| 首屏 LCP | 1.82s | 1.13s |
| 首屏 JS 执行 | 420ms | 310ms |
| 筛选变更重渲染组件数 | 63 | 8 |
| 单次交互平均重渲染次数 | 47 | 6 |
| 状态相关测试 wrapper 代码行 | 约 180 行 | 约 20 行 |
| 包体积增量(gzip) | - | +1.2KB(Zustand)/ +1.6KB(Pinia) |
首屏提升主要来自两点:一是删掉了 Provider 树带来的额外渲染层级,二是 Context 的全量广播消失后,首屏挂载阶段少了约 40 次无意义重渲染。JS 执行时间下降是选择器细粒度订阅的直接结果。
需要诚实说的是:如果项目层级浅、共享状态少,迁移收益没这么大,甚至可能因为引入新依赖而变慢。我们这套数据是在 60+ 组件、7 层透传、状态更新频繁的中后台场景下测的,场景不同结论会变。
七、总结
这次迁移最大的收获不是"换了个库",而是把状态按"业务域 + 读写分离"重新划了一遍边界。Zustand 的选择器订阅和 Pinia 的 storeToRefs 解决的是机制问题,真正让性能数字好看的是那条"什么该进 store、什么不该进"的规则。
给准备迁移的团队三条建议:
- 先盘点消费点再动手,别一上来就删 Context。
- 双写过渡一周,比事后 debug 便宜得多。
- 边界规则写进团队文档,否则三个月后 store 又会变成新的全局变量堆。
两个库的版本都在快速迭代,Zustand 4.x 和 Pinia 2.x 的 API 已经比较稳定,按本文的写法可以直接落地。如果你的项目还在 Context 里塞十几个字段,建议先做一次 Profiler,看看重渲染数字,再决定要不要动。