一、问题背景:Context和Props透传到底哪里疼
我们团队维护的是一个中后台数据平台,前端同时存在React 18.2和Vue 3.3两个技术栈(历史原因,部分老模块是React,新模块用Vue 3)。项目初期页面简单,状态管理就是用最朴素的方式:
- React侧:
createContext+useContext,顶层包一个AppContext.Provider,所有全局状态(用户信息、权限、主题、字典数据)都塞进去。 - Vue侧:
props逐层透传,或者用provide/inject。
一开始没问题。但当组件层级超过4层、全局状态字段超过20个之后,问题集中爆发:
- React Context的“全量更新”:只要Context value里任何一个字段变化,所有
useContext的组件都会重渲染。我们用一个Profile页面测试,修改一个输入框的值,触发了47次组件渲染(React DevTools Profiler数据)。 - Vue Props透传的“中间商”:一个
dictData从App传到第5层组件,中间3层组件根本不使用它,却被迫声明props,代码噪音极大。 - 逻辑复用困难:多个页面需要“获取用户权限→判断按钮显隐→记录操作日志”这一套逻辑,用Context写就是复制粘贴。
于是我们决定迁移到专门的状态管理库。选型上,React侧选Zustand 4.4.7,Vue侧选Pinia 2.1.7。
二、环境与版本
先交代清楚环境,避免版本差异导致行为不一致:
- React:18.2.0,react-dom 18.2.0,构建工具Vite 4.4.5
- Vue:3.3.4,构建工具Vite 4.4.5
- 状态管理:zustand 4.4.7,pinia 2.1.7
- 开发调试:React DevTools 4.28.5,Vue DevTools 6.6.1
- 测试设备:MacBook Pro M1,Chrome 118,Performance面板采样
三、方案设计:为什么是Zustand和Pinia
我们对比了三个方案,用表格说清楚:
| 维度 | Context/Props | Redux Toolkit | Zustand / Pinia |
|---|---|---|---|
| 学习成本 | 低 | 中高 | 低 |
| 模板代码 | 少 | 多(slice、reducer) | 极少 |
| 选择性订阅 | 不支持 | 需reselect | 原生支持 |
| 异步处理 | 自己写 | createAsyncThunk | 直接async函数 |
| DevTools | 一般 | 优秀 | 良好 |
| 包体积 | 0 | ~11KB | zustand ~1.2KB / pinia ~1.5KB |
选Zustand的核心原因是它的选择性订阅:useStore(state => state.foo)只在foo变化时重渲染,这正好解决Context的全量更新问题。选Pinia则是因为它是Vue官方推荐,且和Vue 3的响应式系统无缝集成,storeToRefs解构后仍保持响应性。
四、核心实现:从迁移到落地
4.1 React侧:Context迁移到Zustand
迁移前的Context写法(简化):
// 旧代码:AppContext.jsx
import { createContext, useContext, useState } from 'react';
const AppContext = createContext(null);
export function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [dict, setDict] = useState({});
return (
{children}
);
}
export const useApp = () => useContext(AppContext);
问题很明显:任何setState都会让value对象变化,所有消费者重渲染。
迁移后的Zustand store:
// 新代码:stores/appStore.js
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
export const useAppStore = create(
devtools(
persist(
(set, get) => ({
user: null,
theme: 'light',
dict: {},
setUser: (user) => set({ user }, false, 'setUser'),
setTheme: (theme) => set({ theme }, false, 'setTheme'),
// 异步action直接写
fetchDict: async () => {
const res = await fetch('/api/dict');
const dict = await res.json();
set({ dict }, false, 'fetchDict');
},
// 派生状态用selector,不放进store
hasPermission: (code) => {
const { user } = get();
return user?.permissions?.includes(code) ?? false;
},
}),
{ name: 'app-storage', partialize: (s) => ({ theme: s.theme }) }
)
)
);
组件里按需订阅:
// 只订阅theme,user变化不会触发本组件重渲染
function ThemeToggle() {
const theme = useAppStore((s) => s.theme);
const setTheme = useAppStore((s) => s.setTheme);
return setTheme(theme === 'light' ? 'dark' : 'light')}>{theme};
}
注意partialize配置:只把theme持久化到localStorage,user和dict不落盘,避免敏感信息和过期字典被缓存。
4.2 Vue侧:Props透传迁移到Pinia
迁移前,dictData从App透传到第5层:
const props = defineProps({ dictData: Object });
迁移后,用Pinia store:
// stores/dict.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useDictStore = defineStore('dict', () => {
const dict = ref({});
const loading = ref(false);
const statusOptions = computed(() => dict.value.status ?? []);
async function fetchDict() {
loading.value = true;
try {
const res = await fetch('/api/dict');
dict.value = await res.json();
} finally {
loading.value = false;
}
}
return { dict, loading, statusOptions, fetchDict };
});
组件里直接用,中间层不再需要声明props:
import { storeToRefs } from 'pinia';
import { useDictStore } from '@/stores/dict';
const dictStore = useDictStore();
// 解构后仍保持响应性,这是Pinia的关键API
const { statusOptions, loading } = storeToRefs(dictStore);
{{ item.label }}
4.3 迁移步骤(我们实际执行的顺序)
- 冻结新功能:迁移期间只修bug,不加需求,避免边迁边改。
- 盘点状态:把Context/Props里的状态分三类——全局(用户、主题)、模块级(字典、权限)、组件级(表单输入)。只有前两类进store,组件级留在本地
useState/ref。 - 先建store,再改组件:store写好后,逐个组件替换
useContext/props,每改一个提交一次,方便回滚。 - 保留旧Context一层:用
useAppStore包装一个useApp兼容函数,老组件不改也能跑,新组件用新API。这一步让迁移可以分批进行。 - 全量回归后删除旧代码:确认无引用后删除Context文件和props声明。
五、踩坑与优化
坑1:Zustand的selector返回新对象导致无限重渲染。
// 错误写法:每次返回新对象,引用变化,触发重渲染
const { user, theme } = useAppStore((s) => ({ user: s.user, theme: s.theme }));
解决:用useShallow(zustand 4.4+提供),或者分开写多个selector。
import { useShallow } from 'zustand/react/shallow';
const { user, theme } = useAppStore(useShallow((s) => ({ user: s.user, theme: s.theme })));
坑2:Pinia解构丢失响应性。 直接const { dict } = useDictStore()会变成普通值,必须用storeToRefs。这个坑我们团队新人踩了三次,后来直接在ESLint规则里加了检测。
坑3:DevTools里action名字不明显。 Zustand的set第三个参数可以传action名,我们在devtools中间件里统一加了名字,调试时能直接看到setTheme、fetchDict,而不是一堆匿名setState。
优化:字典数据只请求一次。 迁移前每个页面各自请求字典,迁移后store里加了个inited标志,fetchDict先判断是否已初始化,避免重复请求。接口调用从每天约2000次降到约300次。
六、效果数据
迁移前后用Chrome Performance面板和React DevTools Profiler采样,数据如下:
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| Profile页修改输入框触发渲染次数 | 47 | 6 | -87% |
| 首屏TTI(3G模拟) | 2.8s | 1.9s | -32% |
| 字典接口日调用量 | ~2000 | ~300 | -85% |
| 中间层组件props声明行数 | 平均4.2行/组件 | 0 | -100% |
| 包体积增量 | 0 | +1.2KB(gzip后) | 可接受 |
需要说明的是,TTI的下降主要来自减少重渲染和字典请求,而不是状态管理库本身“更快”。Zustand和Pinia的体积都很小,性能收益来自更细粒度的订阅,这点在选型时容易被忽略。
七、总结
从Context/Props迁移到Zustand和Pinia,本质上不是“换个库”,而是把状态按作用域重新划分。我们的经验是:
- 全局状态、跨模块共享状态进store,组件内部状态继续留在本地,不要什么都往store塞。
- React侧优先用
useShallow处理多字段订阅,Vue侧牢记storeToRefs。 - 迁移要分批,保留兼容层,别想着一个PR改完。
- 性能收益要看具体场景,如果项目只有两三个全局状态,Context其实够用,不必为了用库而用库。
对于中大型项目,状态管理库带来的可维护性提升是实打实的;对于小项目,先想清楚痛点再决定,别被“最佳实践”绑架。