一、问题背景:Props drilling 和 Context 的边界在哪里
先说结论:Context 和 Props 不是不能用,而是当你的应用满足下面任意两条时,就该考虑状态管理库了。
- 全局状态跨越 3 层以上组件树
- 单次状态更新触发超过 20 个组件重渲染
- 多个不相关模块需要读写同一份数据
- 需要中间件能力(持久化、日志、时间旅行)
我们有两个项目同时撞上了这堵墙。
项目 A:React 18.2 + TypeScript 4.9 + Vite 4.1,后台管理系统,约 120 个组件,用 Context + useReducer 管理用户信息、权限、主题、通知、侧边栏折叠等 5 类全局状态。
项目 B:Vue 3.3 + TypeScript 5.0 + Vite 4.4,数据看板,约 80 个组件,用 Props + provide/inject 传递筛选条件和用户配置。
问题表现很典型:项目 A 里切换侧边栏折叠状态,整个页面卡顿约 300ms,React DevTools Profiler 显示 47 个组件重渲染,其中 41 个跟侧边栏毫无关系。项目 B 里修改一个筛选条件,触发了 3 层 provide/inject 的链式更新,图表组件重绘了 4 次。
二、环境与版本
| 项目 | 框架 | 语言 | 构建 | 原方案 | 新方案 |
|---|---|---|---|---|---|
| A | React 18.2.0 | TS 4.9.5 | Vite 4.1.0 | Context + useReducer | Zustand 4.3.9 |
| B | Vue 3.3.4 | TS 5.0.4 | Vite 4.4.5 | Props + provide/inject | Pinia 2.1.6 |
选 Zustand 而不是 Redux Toolkit,是因为项目 A 的状态逻辑不复杂,Redux 的 action/reducer 样板代码收益比太低。Zustand 的 selector 天然支持细粒度订阅,正好解决重渲染问题。Pinia 是 Vue 官方推荐,跟 Vue 3 的响应式系统结合最自然,没有理由选别的。
三、方案设计:Context vs Zustand vs Pinia 的取舍
先做一张对比表,这是当时团队评审时用的:
| 维度 | React Context | Zustand | Pinia |
|---|---|---|---|
| 重渲染控制 | 全量订阅,靠 memo 手动优化 | selector 细粒度订阅 | 响应式依赖收集 |
| 样板代码 | 中(Provider + reducer) | 低 | 低 |
| 中间件 | 无 | persist/devtools/immer | 插件系统 |
| 跨组件读写 | 需要 Provider 嵌套 | 直接 import store | 直接 import store |
| 调试 | DevTools 一般 | Redux DevTools 兼容 | Vue DevTools |
| 学习成本 | 低 | 低 | 低 |
关键差异在订阅粒度。Context 的 value 一变,所有 useContext 的组件全部重渲染,你只能靠 React.memo + useMemo 手动挡。Zustand 的 selector 是订阅级别的,只有 selector 返回值变化的组件才重渲染。
迁移策略上,我们没有一次性替换,而是采用并行迁移:
- 新建 store 目录,先迁移一个独立模块(比如主题)
- 保持 Context 存在,新旧并存
- 逐个模块迁移,每个模块迁移后跑一次性能对比
- 全部迁移完,删除 Context Provider
这样风险可控,任何一步出问题都能回滚。
四、核心实现
4.1 React:从 Context 迁移到 Zustand
迁移前的 Context 写法(简化):
// 旧代码:ThemeContext.tsx
import { createContext, useContext, useReducer } from 'react';
type Theme = 'light' | 'dark';
type State = { theme: Theme; sidebarCollapsed: boolean };
const ThemeContext = createContext(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(
(s: State, a: { type: string; payload?: any }) => {
switch (a.type) {
case 'SET_THEME': return { ...s, theme: a.payload };
case 'TOGGLE_SIDEBAR': return { ...s, sidebarCollapsed: !s.sidebarCollapsed };
default: return s;
}
},
{ theme: 'light', sidebarCollapsed: false }
);
return {children};
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
迁移后的 Zustand store:
// 新代码:stores/useAppStore.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
type Theme = 'light' | 'dark';
interface AppState {
theme: Theme;
sidebarCollapsed: boolean;
setTheme: (t: Theme) => void;
toggleSidebar: () => void;
}
export const useAppStore = create()(
devtools(
persist(
(set) => ({
theme: 'light',
sidebarCollapsed: false,
setTheme: (theme) => set({ theme }),
toggleSidebar: () =>
set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
}),
{ name: 'app-store', partialize: (s) => ({ theme: s.theme }) }
),
{ name: 'AppStore' }
)
);
组件里的用法对比:
// 旧:任何 state 变化都重渲染
const { theme, sidebarCollapsed } = useTheme();
// 新:只订阅需要的字段
const theme = useAppStore((s) => s.theme);
const toggleSidebar = useAppStore((s) => s.toggleSidebar);
注意 toggleSidebar 这种 action 引用是稳定的,不会引起重渲染。这里有个坑:如果你写 useAppStore((s) => ({ theme: s.theme, collapsed: s.sidebarCollapsed })),每次返回新对象,会无限重渲染。正确做法是用 useShallow:
import { useShallow } from 'zustand/react/shallow';
const { theme, sidebarCollapsed } = useAppStore(
useShallow((s) => ({ theme: s.theme, collapsed: s.sidebarCollapsed }))
);
4.2 Vue:从 Props/provide-inject 迁移到 Pinia
迁移前的 provide/inject:
import { provide, ref, readonly } from 'vue';
const filters = ref({ keyword: '', dateRange: [null, null], status: 'all' });
const updateKeyword = (v: string) => { filters.value.keyword = v; };
provide('filters', readonly(filters));
provide('updateKeyword', updateKeyword);
迁移后的 Pinia store:
// 新代码:stores/filterStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useFilterStore = defineStore('filter', () => {
const keyword = ref('');
const dateRange = ref([null, null]);
const status = ref('all');
const hasActiveFilter = computed(
() => keyword.value !== '' || status.value !== 'all'
);
function reset() {
keyword.value = '';
dateRange.value = [null, null];
status.value = 'all';
}
return { keyword, dateRange, status, hasActiveFilter, reset };
}, {
persist: { key: 'filter-store', paths: ['status'] }
});
组件中使用:
import { storeToRefs } from 'pinia';
import { useFilterStore } from '@/stores/filterStore';
const filterStore = useFilterStore();
const { keyword, status, hasActiveFilter } = storeToRefs(filterStore);
storeToRefs 是必须的,直接解构会丢失响应式。
五、踩坑与优化
坑 1:Zustand 的 selector 返回新对象导致无限重渲染
前面提过,返回对象必须用 useShallow。这个坑我们踩了两次,第二次是因为一个同事在 selector 里写了 .map()。
坑 2:persist 中间件和 SSR 冲突
项目 A 后来接了 Next.js,persist 在服务端渲染时报 localStorage is not defined。解决方案是用 createJSONStorage 加环境判断:
storage: createJSONStorage(() =>
typeof window !== 'undefined' ? localStorage : ({} as Storage)
)
坑 3:Pinia 的 store 在 setup 外调用
在路由守卫、axios 拦截器里调用 store,必须在函数内部调用,不能在外层 import 后就调用,否则 Pinia 实例还没挂载:
// 错误
const store = useFilterStore();
router.beforeEach(() => { store.reset(); });
// 正确
router.beforeEach(() => {
const store = useFilterStore();
store.reset();
});
优化 1:Zustand 状态分片
一开始把所有状态塞一个 store,后来拆成 4 个:appStore、userStore、notificationStore、permissionStore。分片后单个 store 更新影响面更小。
优化 2:Pinia 使用 $subscribe 做日志
开发环境用 $subscribe 监听所有变更,打到 console,排查问题效率提升明显。
六、效果数据
用 React DevTools Profiler 和 Vue DevTools Performance 采集,每种场景跑 10 次取平均。
项目 A(React):
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| 切换侧边栏重渲染组件数 | 47 | 6 | -87% |
| 切换主题重渲染组件数 | 52 | 4 | -92% |
| 交互平均延迟 | 312ms | 42ms | -86% |
| 首屏 JS 体积 | 218KB | 226KB | +8KB |
| 首次加载时间 | 1.42s | 1.46s | +0.04s |
项目 B(Vue):
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| 筛选更新触发重渲染 | 18 | 5 | -72% |
| 图表重绘次数 | 4 | 1 | -75% |
| 交互平均延迟 | 186ms | 38ms | -80% |
| 首屏 JS 体积 | 164KB | 172KB | +8KB |
体积增加了约 8KB(gzip 后约 3KB),换来的是交互延迟降低 80%+,这笔账很划算。
七、总结
回头看,从 Context/Props 迁移到状态管理库,核心收益不是"代码更好写",而是订阅粒度可控。Context 和 provide/inject 的设计目标是依赖注入,不是状态管理,用它做全局状态,重渲染是必然代价。
几点建议:
- 不要一上来就上 Redux,Zustand/Pinia 的收益比更高
- 迁移要并行,不要一次性替换,保留回滚能力
- selector/storeToRefs 是性能关键,必须理解订阅机制
- persist 中间件注意 SSR 和存储配额
- 状态分片,一个 store 不要超过 200 行
如果你的项目也是 100+ 组件、Context 嵌套 3 层以上,建议做个 Profiler 采样,数据会告诉你答案。