一、问题背景:Props 和 Context 在什么规模下会崩
项目是一个运营后台,React 侧负责数据看板与配置中心,Vue 侧负责内容审核与日志查询。两边最初都遵循“简单优先”:
- React:跨层级用
createContext+useContext,相邻层级用 Props。 - Vue:用
provide/inject,局部状态用props+emit。
页面数量到 40+ 之后,问题集中爆发:
- Context 的“全局刷新”问题:一个 Context 里放了
userInfo、permission、theme、globalFilter。任何一项变化,所有useContext的组件都会重渲染。我们实测修改一次globalFilter,触发 230 次组件渲染,其中 190 次是无意义渲染。 - Props 链过深:Vue 侧一个审核详情页,
props从ListPage传到DetailPanel再到ActionBar,最深 7 层。中间层组件根本不使用这些 props,只做“搬运”。 - 逻辑复用困难:多个页面需要“当前选中项”“批量操作状态”,用 Context 要新建 Provider,用 provide/inject 要写重复的 symbol key,维护成本高。
- 调试困难:React DevTools 里 Context 值变化看不到“谁改的”;Vue DevTools 里 inject 的值没有时间线。
我们决定迁移到专门的状态管理库:React 用 Zustand 4.4.1,Vue 用 Pinia 2.1.6。
二、环境与版本
- React:18.2.0,TypeScript 5.2.2,Vite 4.4.5
- Vue:3.3.4,TypeScript 5.2.2,Vite 4.4.5
- 原状态方案:React Context + useReducer;Vue provide/inject + reactive
- 新状态库:Zustand 4.4.1;Pinia 2.1.6
- 性能测量:React Profiler API + Vue
onRenderTracked,Chrome Performance 面板 - 测试页面:数据看板(React,约 120 个组件)、审核列表(Vue,约 90 个组件)
三、方案设计:为什么选 Zustand 和 Pinia
我们对比了 4 个候选:
| 方案 | React 支持 | Vue 支持 | 包体积 | 学习成本 | 细粒度更新 |
|---|---|---|---|---|---|
| Context + useReducer | 原生 | 不适用 | 0 | 低 | 差 |
| Redux Toolkit | 好 | 需适配 | 13KB | 中 | 中(需 reselect) |
| Zustand | 好 | 不适用 | 1.2KB | 低 | 好(selector) |
| Pinia | 不适用 | 好 | 1.5KB | 低 | 好(setup store) |
最终选择理由:
- Zustand:不需要 Provider,store 是外部对象,组件通过 selector 订阅。只有 selector 返回值变化时才重渲染。API 极简:
create+useStore。 - Pinia:Vue 官方推荐,setup 语法和组合式 API 一致,支持
storeToRefs解构保持响应性,DevTools 时间线完整。
迁移原则:不一次性重写。先在新页面用新库,再逐步替换旧页面。每个模块迁移后跑一次性能对比。
四、核心实现与迁移步骤
4.1 React:从 Context 到 Zustand
原 Context 写法(问题版):
// 旧代码:GlobalContext.tsx
const GlobalContext = createContext(null);
export function GlobalProvider({ children }) {
const [userInfo, setUserInfo] = useState({});
const [globalFilter, setGlobalFilter] = useState({ status: 'all' });
const [theme, setTheme] = useState('light');
const value = { userInfo, setUserInfo, globalFilter, setGlobalFilter, theme, setTheme };
return {children};
}
// 任意子组件
const { globalFilter, theme } = useContext(GlobalContext); // 任一值变化都重渲染
迁移步骤:
- 按领域拆分 store:
useUserStore、useFilterStore、useThemeStore。 - 每个 store 用
create定义,状态和 action 放一起。 - 组件用 selector 订阅,避免解构整个 store。
Zustand 实现:
// stores/filterStore.ts
import { create } from 'zustand';
interface FilterState {
status: 'all' | 'pending' | 'done';
keyword: string;
setStatus: (s: FilterState['status']) => void;
setKeyword: (k: string) => void;
reset: () => void;
}
export const useFilterStore = create((set) => ({
status: 'all',
keyword: '',
setStatus: (status) => set({ status }),
setKeyword: (keyword) => set({ keyword }),
reset: () => set({ status: 'all', keyword: '' }),
}));
// 组件中使用:只订阅 status
function StatusSelect() {
const status = useFilterStore((s) => s.status);
const setStatus = useFilterStore((s) => s.setStatus);
console.log('StatusSelect render'); // 只有 status 变化才打印
return (
setStatus(e.target.value as any)}>
全部
待审核
已完成
);
}
关键点:useFilterStore((s) => s.status) 返回原始值,Zustand 用 Object.is 比较。如果返回对象,需要 shallow 比较:
import { shallow } from 'zustand/shallow';
const { status, keyword } = useFilterStore(
(s) => ({ status: s.status, keyword: s.keyword }),
shallow
);
4.2 Vue:从 provide/inject 到 Pinia
原 provide/inject 写法:
import { provide, reactive } from 'vue';
const filter = reactive({ status: 'all', keyword: '' });
provide('filter', filter);
import { inject } from 'vue';
const filter = inject('filter'); // 类型丢失,key 硬编码
迁移步骤:
- 创建
stores/filter.ts,用defineStore+ setup 语法。 - 组件中
useFilterStore()获取 store,用storeToRefs解构状态。 - 移除所有
provide/inject和对应的 symbol key。
Pinia 实现:
// stores/filter.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useFilterStore = defineStore('filter', () => {
const status = ref('all');
const keyword = ref('');
const hasFilter = computed(() => status.value !== 'all' || keyword.value !== '');
function setStatus(s: typeof status.value) {
status.value = s;
}
function setKeyword(k: string) {
keyword.value = k;
}
function reset() {
status.value = 'all';
keyword.value = '';
}
return { status, keyword, hasFilter, setStatus, setKeyword, reset };
});
import { storeToRefs } from 'pinia';
import { useFilterStore } from '@/stores/filter';
const filterStore = useFilterStore();
const { status, keyword } = storeToRefs(filterStore); // 保持响应性
const { setStatus } = filterStore; // action 直接解构
console.log('StatusSelect setup'); // 只有 status 变化才触发组件更新
全部
待审核
已完成
注意:storeToRefs 只对 state 和 getter 有效,action 直接解构即可。
五、踩坑与优化
坑 1:Zustand selector 返回新对象导致无限重渲染
// 错误:每次返回新对象,Object.is 比较失败
const { status, keyword } = useFilterStore((s) => ({
status: s.status,
keyword: s.keyword,
})); // 每次渲染都触发更新
// 正确:用 shallow 比较
import { shallow } from 'zustand/shallow';
const { status, keyword } = useFilterStore(
(s) => ({ status: s.status, keyword: s.keyword }),
shallow
);
坑 2:Pinia 解构丢失响应性
// 错误:直接解构,status 变成普通值
const { status } = useFilterStore();
// 正确:用 storeToRefs
const { status } = storeToRefs(useFilterStore());
坑 3:迁移期间两套状态并存导致数据不同步
我们采用“双写”过渡:旧 Context 的 setter 内部调用新 store 的 action,新组件只读新 store。持续 2 周后删除旧代码。不要试图一次性替换所有组件,否则回归测试量太大。
优化:Zustand 持久化与 Pinia 插件
// Zustand 持久化
import { persist } from 'zustand/middleware';
export const useThemeStore = create(
persist(
(set) => ({
theme: 'light',
setTheme: (theme: string) => set({ theme }),
}),
{ name: 'theme-storage' }
)
);
// Pinia 持久化插件
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
// store 中
defineStore('theme', () => { ... }, {
persist: { key: 'theme-storage', storage: localStorage },
});
六、效果数据
迁移后我们用 React Profiler 和 Vue onRenderTracked 统计同一操作(修改筛选条件)的渲染次数:
| 指标 | 迁移前(Context/provide) | 迁移后(Zustand/Pinia) | 变化 |
|---|---|---|---|
| 组件渲染次数 | 230 | 18 | -92% |
| 首屏可交互时间 | 1.82s | 1.48s | -340ms |
| 内存占用(堆快照) | 42.3MB | 38.7MB | -3.6MB |
| 代码行数(状态相关) | 1,240 | 860 | -31% |
| 新增页面平均开发时间 | 2.5 天 | 1.5 天 | -40% |
具体到页面:
- React 数据看板:修改
globalFilter从触发 230 次渲染降到 18 次,其中 12 次是真正需要更新的图表组件。 - Vue 审核列表:切换分页从触发 90 次渲染降到 9 次,列表滚动 FPS 从 48 提升到 58。
七、总结
从 Context/Props 迁移到 Zustand/Pinia 不是“为了用而用”,而是在特定规模下的必然选择。我们的经验:
- Context 适合低频、全局、不常变的值(如用户信息、主题)。一旦出现高频更新或大对象,就该换。
- Props 透传超过 3 层就考虑状态库或组合式函数,不要硬传。
- 迁移要渐进:双写过渡、按模块替换、每步测性能。
- Zustand 的 selector 和 Pinia 的 storeToRefs 是性能关键,用错等于没换。
- 版本选择:Zustand 4.4.x 稳定,Pinia 2.1.x 配合 Vue 3.3 无坑。
如果你的项目也出现“改一个值触发上百次渲染”,建议先量一下渲染次数,再决定是否迁移。数据不会骗人。