一、问题背景:当Context成为性能瓶颈
上季度接手了一个维护了两年的中后台项目,技术栈是React 18.3 + Vue 3.4混合开发(历史原因)。其中一个核心页面“订单管理”包含了订单列表、筛选器、详情抽屉、操作日志时间线四个大模块。
最初架构遵循“就近原则”,用户信息用UserContext,权限状态用AuthContext,购物车数据则通过5层Props层层传递。在数据量小的时候一切正常,但当订单列表扩展到500条数据,且每次操作都要更新权限标签时,问题暴露:
- 打开订单详情抽屉:320ms(其中React渲染占180ms,Context重新消费占90ms)
- 切换订单状态后,整个页面所有组件全部重新渲染(因为
AuthContext.Provider包裹了根组件) - Vue侧更头疼,Props传递的响应式对象在深层嵌套时,每次修改都会触发
父组件 -> 子组件 -> 孙组件的链式更新,Vue Devtools显示的更新链路长达7层。
用React.memo和computed优化无果后,决定全面迁移到状态管理库。
二、环境与版本:统一基础设施
迁移前先统一版本,减少不可控因素:
// package.json 关键依赖
{
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.2.3",
"vue": "^3.5.13",
"pinia": "^2.3.1",
"vite": "^5.4.0",
"typescript": "^5.6.0"
}
选型理由:
- React侧:Zustand 5比Redux Toolkit轻量(打包后约1.2KB vs 11KB),且无需Provider包裹,支持create和useStore的selector精确订阅。
- Vue侧:Pinia 2完全兼容Vue 3.5的reactive和ref,且支持setup store语法,比Vuex 4更符合组合式API习惯。
三、方案设计:渐进式迁移而非重写
不推荐一次性把所有的Context/Props都替换掉。我们分三步走:
- 先迁移“跨模块共享”状态:用户登录信息、权限标签、全局主题。
- 再迁移“高频更新”状态:购物车、订单列表筛选条件(因为这两个是性能瓶颈)。
- 保留Props传递“纯展示”数据:组件内部UI状态(如弹窗开关)继续用
useState或ref。
核心设计原则:
- React侧:每个store独立文件,使用create + selector,避免组件订阅整个store。
- Vue侧:使用setup store,返回ref和computed,配合storeToRefs使用。
四、核心实现:React侧Zustand 5迁移
先看迁移前的Context代码(痛点示例):
// 迁移前:Context导致所有子组件订阅
const UserContext = createContext(null);
function App() {
const [user, setUser] = useState(null);
// 每次setUser,所有消费UserContext的组件都重渲染
return (
);
}
// OrderList内部:const { user } = useContext(UserContext); // 每次user变化,OrderList重渲染
迁移后,使用Zustand 5的useShallow和独立selector:
// stores/userStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UserState {
userInfo: { id: number; name: string; role: string } | null;
permissions: string[];
setUser: (user: UserState['userInfo']) => void;
updatePermission: (perm: string) => void;
}
export const useUserStore = create()(
persist(
(set) => ({
userInfo: null,
permissions: [],
setUser: (userInfo) => set({ userInfo }),
updatePermission: (perm) =>
set((state) => ({
permissions: state.permissions.includes(perm)
? state.permissions
: [...state.permissions, perm],
})),
}),
{
name: 'user-storage', // 持久化到localStorage
partialize: (state) => ({ userInfo: state.userInfo }), // 只持久化userInfo
}
)
);
// 组件中使用:精确订阅,避免无关渲染
import { useShallow } from 'zustand/react/shallow';
function OrderList() {
// 只订阅userInfo,不订阅permissions
const userInfo = useUserStore((state) => state.userInfo);
// 订阅多个字段时,使用useShallow避免浅比较失效
const [permissions, updatePermission] = useUserStore(
useShallow((state) => [state.permissions, state.updatePermission])
);
if (!userInfo) return 请先登录;
return (
操作人:{userInfo.name}
{permissions.includes('order:delete') && 删除订单}
);
}
关键优化点:Zustand 5的selector默认使用Object.is比较,如果直接返回新对象(如[...state.permissions]),会导致无限渲染。必须使用useShallow包装,它内部会做浅层比较。
五、核心实现:Vue侧Pinia 2迁移
Vue侧原本的Props drilling代码(痛点示例):
// 父组件
const filterParams = reactive({ status: 'all', dateRange: [] });
const userRole = computed(() => props.userRole); // 从更上层传下来
迁移到Pinia 2的setup store:
// stores/orderStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useOrderStore = defineStore('order', () => {
// 状态
const filterParams = ref({ status: 'all', dateRange: [] as string[] });
const orderList = ref([]);
// getters
const filteredOrders = computed(() => {
return orderList.value.filter((order) => {
if (filterParams.value.status !== 'all' && order.status !== filterParams.value.status) return false;
return true;
});
});
// actions
function updateFilter(params: Partial) {
filterParams.value = { ...filterParams.value, ...params };
}
async function fetchOrders() {
// 模拟请求
orderList.value = await api.getOrders(filterParams.value);
}
return { filterParams, orderList, filteredOrders, updateFilter, fetchOrders };
});
组件中使用:
import { useOrderStore } from '@/stores/orderStore';
import { storeToRefs } from 'pinia';
const store = useOrderStore();
// 解构时用storeToRefs保持响应性,直接解构会失去响应性
const { filterParams, filteredOrders } = storeToRefs(store);
// 调用action时直接使用store
onMounted(() => {
store.fetchOrders();
});
重要提示:Pinia的store本身是reactive对象,直接从store解构属性会丢失响应性。必须使用storeToRefs。另外,setup store返回的ref会被自动解包,所以模板中写store.filterParams而不是store.filterParams.value。
六、踩坑与优化:真实环境中的意外
坑1:React 18的useSyncExternalStore与Zustand 5的兼容性
Zustand 5默认使用useSyncExternalStore,但在React 18.3的严格模式下,如果selector返回不稳定引用(如state.items.map(...)),会出现“getSnapshot should be cached”警告。解决:在selector内使用useMemo或直接返回primitive值。
坑2:Vue的ref在Pinia setup store中的自动解包陷阱
在setup store中,如果返回一个reactive对象,模板中访问属性时不需要.value;但如果返回一个ref,模板中需要.value(除非在store内部已经解包)。我们统一规范:所有返回的state用ref,getters用computed,模板中全部通过store.xxx访问(Pinia自动解包),但storeToRefs解构出来的ref在`中需要.value`。
坑3:迁移过程中的双写问题
为了平稳过渡,我们同时保留了Context和Store,通过一个isMigrated标志控制。结果发现双写导致状态不一致(用户改了Context但Store没更新)。最终决定:一次性开关,删除所有Context代码,不留后路。
优化效果数据(基于Chrome Performance tab + React DevTools Profiler):
| 指标 | 迁移前(Context/Props) | 迁移后(Zustand/Pinia) | 提升 |
|---|---|---|---|
| 订单详情打开时间 | 320ms | 78ms | -75.6% |
| 切换订单状态重渲染组件数 | 42个 | 9个 | -78.6% |
| 内存占用(快照) | 48.2MB | 35.1MB | -27.2% |
| Vue侧筛选操作响应 | 210ms | 65ms | -69.0% |
七、总结与建议
- 不要过度使用状态管理库:如果状态只在单个组件内使用,
useState/ref足够。我们的原则是:至少被3个非父子关系的组件共享,才放入全局store。 - React侧优先用selector订阅:Zustand的优势在于精确订阅,但要注意
useShallow的用法,否则性能反而更差。 - Vue侧优先用
storeToRefs:避免解构丢失响应性,这是Pinia新手最常见的错误。 - 版本锁定:React 19与Zustand 5配合良好,但Zustand 4的
createAPI在React 19下会有警告。Vue 3.5 + Pinia 2.3是稳定组合,不要盲目升级到Pinia 3(目前还是beta)。
如果项目是大型应用(超过50个页面),建议直接上Redux Toolkit或Zustand + Immer;如果是中小型应用,Context + useReducer也够用。关键是识别出真正的性能瓶颈——不要因为“大家都在用”就盲目引入状态管理库,我们迁移是因为有明确的数据支撑(120ms到380ms的劣化)。
最后,附上完整的迁移Demo仓库(已脱敏):github.com/yourname/state-migration-demo,里面包含了React和Vue两个版本的前后对比代码。欢迎star交流。