一、问题背景:当Props钻透和Context重渲染成为技术债
我们团队维护的「云枢管理后台」项目,主框架React 18.2 + Vue 3.4双技术栈并存(历史原因,两个子应用通过微前端集成)。业务逻辑复杂:全局有用户信息、权限点、主题配置、多语言、以及一个实时更新的工单列表(每3秒轮询一次)。
最初的实现很直白——React侧用Context + useReducer管理全局状态,Vue侧用Provide/Inject + reactive。数据流看起来清晰,但项目迭代到第8个月时问题集中爆发:
React侧痛点(Context方案):
- 每当userInfo或permissionList更新,所有消费UserContext的组件全部重渲染。通过React DevTools Profiler统计,一次权限变更触发214个组件重渲染,其中112个组件并未使用变更的数据。
- Props透传深度达到7层(Page -> Table -> Filter -> Form -> Select -> Option -> Data),中间层组件被迫声明十几个与自身无关的props。
Vue侧痛点(Provide/Inject方案):
- 依赖注入的响应式数据在深层组件中丢失响应式链接(因为解构了reactive对象)。
- 无法从Vue Devtools的Time Travel调试状态变化,排查问题只能靠console.log。
更致命的是,两个子应用的状态管理各自为政,父应用需要同时维护两套API。底层原因还是我们初期为了省事,跳过了状态管理库,直接使用框架内置能力——这个决定在当时看来是“轻量”,在复杂度上来后变成了“轻浮”。
二、方案对比:Zustand vs Pinia vs 现有方案
在选型阶段,我们花了两个工作日做技术调研,结合项目实际约束(不允许引入Redux/MobX,因为团队学习成本高且样板代码多),最终圈定Zustand 4.5.2(React侧)和Pinia 2.1.7(Vue侧)。
| 维度 | Context/Provide(旧) | Zustand 4.5.2 | Pinia 2.1.7 |
|---|---|---|---|
| 重渲染控制 | 无,Context值变化全量通知 | 细粒度,selector控制订阅 | 细粒度,storeToRefs + 按需订阅 |
| 样板代码 | 需自定义Provider/Custom Hook | 极简,无Provider | 需defineStore,但无Provider |
| TypeScript支持 | 需手动声明泛型,易丢失 | 推断能力强,支持create() |
依赖Volar插件,类型透传稳定 |
| 调试工具 | React DevTools只能看props | Redux DevTools插件直接支持 | Vue Devtools原生集成,含时间旅行 |
| 异步处理 | useReducer需手写Thunk | 内置async action,无额外依赖 |
支持异步action,天然支持组合式store |
| Bundle体积 | 0(内置) | 1.2KB min+gzip | 1.1KB min+gzip(核心) |
最终决策:两个库都采用“单一store + 领域切片”模式。React侧拆分为useAuthStore、useTicketStore、useThemeStore;Vue侧拆分为useUserStore、usePermissionStore、useTicketStore。
这里要说明,为什么不用Jotai/Valtio?因为我们的状态有强业务关联(比如权限变更必须同步更新菜单和按钮),原子化状态管理需要额外写衍生逻辑,反而增加复杂度。而Zustand/Pinia的store间调用(useAuthStore.getState())能直接解决这个问题。
三、迁移步骤:渐进式替换,不停机不发版
我们采用“按模块切割,灰度迁移”策略,完整迁移周期5个工作日,分四步走:
Step 1:新建store层,保留旧接口(第1天)
先不删任何旧代码,新建stores/authStore.js(React)和stores/user.js(Vue),将原有的Context/Provide里的state和action原样搬过来。注意React侧使用create函数时,把异步action直接写在store内部:
// React侧 - Zustand store(核心实现)
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { fetchUserInfo, fetchPermissions } from '@/api/user';
export const useAuthStore = create(
devtools(
(set, get) => ({
userInfo: null,
permissionCodes: [],
loading: false,
// 异步action,替代原来Context里的dispatch
loadUserAndPermissions: async (userId) => {
set({ loading: true });
try {
const [userRes, permRes] = await Promise.all([
fetchUserInfo(userId),
fetchPermissions(userId),
]);
set({
userInfo: userRes.data,
permissionCodes: permRes.data.codes,
loading: false,
});
// 关键:跨store联动,替代原来useEffect监听
useTicketStore.getState().refreshWithUser(userRes.data.tenantId);
} catch (error) {
set({ loading: false, error: error.message });
}
},
// 同步action
updateTheme: (theme) => set({ theme }),
}),
{ name: 'auth-store' } // Redux DevTools中显示的名称
)
);
Step 2:替换消费端,一次只改一个路由模块(第2-3天)
在页面上层组件中,把useContext(UserContext)替换为useAuthStore((state) => state.userInfo)。这里的关键是selector必须精确到字段级别,否则优化无效。
// 旧代码(Context)
const { userInfo, dispatch } = useContext(UserContext);
const { theme } = useContext(ThemeContext);
// 新代码(Zustand)
const userInfo = useAuthStore((state) => state.userInfo);
const theme = useThemeStore((state) => state.theme);
Step 3:清理Props透传,删除中间层(第4天)
拿工单列表页面开刀。原来需要`userInfo`和`permissionCodes`,但数据要经过和`两层。迁移后,直接在TicketTable内部调用useAuthStore.getState()获取数据,或通过useAuthStore`的selector订阅。中间层组件删掉了7个无关props,代码量减少约180行。
Step 4:删除旧Context/Provide代码,全量验证(第5天)
全局搜索useContext和inject,确认无遗漏后删除。运行全量E2E测试(Cypress 13.6),重点关注权限变更和主题切换两个高频交互。
四、踩坑与优化:三个必须记录的坑
坑1:Zustand的selector函数必须稳定引用。
最初我们写useAuthStore((state) => state.userInfo?.name),当userInfo为null时会返回undefined,但每次渲染都会生成新函数引用,导致Zustand内部比较失败,仍然触发重渲染。解决方案是用useShallow包装或提取为模块级常量:
// 错误写法(每次渲染都重渲染)
const userName = useAuthStore((state) => state.userInfo?.name);
// 正确写法:用useShallow
import { useShallow } from 'zustand/react/shallow';
const { userName, avatar } = useAuthStore(
useShallow((state) => ({
userName: state.userInfo?.name,
avatar: state.userInfo?.avatar,
}))
);
坑2:Pinia的store解构丢失响应式。
同事在Vue组件里写了const { userInfo, fetchUser } = useUserStore(),结果userInfo变成普通变量,页面不更新。这是因为Pinia的store是reactive对象,解构会破坏响应式链。必须用storeToRefs处理state,action可以直接解构:
// Vue侧正确用法
import { storeToRefs } from 'pinia';
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
const { userInfo, permissionCodes } = storeToRefs(userStore); // state必须用storeToRefs
const { fetchUser, updatePermission } = userStore; // action直接解构
坑3:Zustand的devtools中间件在微前端下报错。
因为我们用qiankun加载子应用,Redux DevTools的实例会被父应用干扰。解决方式是在create时判断环境:
const isQiankun = window.__POWERED_BY_QIANKUN__;
export const useAuthStore = create(
isQiankun ? (set, get) => ({ ... }) : devtools((set, get) => ({ ... }))
);
性能优化进阶:对于高频更新的工单列表(3秒轮询),我们给store增加了partialize配置,只持久化需要缓存到localStorage的字段,避免整个store序列化导致的性能损耗。
五、效果数据:实打实的性能提升
迁移完成后,我们用React Profiler和Vue Performance Tab对核心页面重新评测(Chrome 120,MacBook Pro M1):
| 指标 | 迁移前(Context/Provide) | 迁移后(Zustand/Pinia) | 提升幅度 |
|---|---|---|---|
| 权限变更触发重渲染组件数 | 214个 | 87个 | 59.3% |
| 工单列表页面渲染耗时(React) | 48ms | 32ms | 33.3% |
| 工单列表页面渲染耗时(Vue) | 42ms | 27ms | 35.7% |
| 首屏加载Bundle体积(React) | 342KB | 338KB | 1.2%(Zustand极小) |
| 代码量 | 模块内Props透传行数 | 删除约1200行 | - |
| 状态调试耗时(平均排查一个bug) | 25分钟 | 8分钟 | 68% |
特别说明:首屏体积没有明显增加,因为Zustand和Pinia的核心包都是KB级。相比Redux全家桶动辄增加50KB,这个代价几乎可忽略。
六、总结:什么时候该迁移,什么时候不该
这次重构最大的收获不是性能数字,而是心态转变。之前总认为“框架内置的够用了”,但在复杂业务面前,内置方案的抽象能力确实不足。给同行的建议:
- 如果你只是表单页内部状态,别用任何状态管理库,
useState/ref足够。 - 如果有跨页面共享的异步数据(用户信息、权限、配置),且更新频率低,但影响面广,直接上Zustand/Pinia,别用Context/Provide硬扛。我们的数据已经证明,重渲染减少量是数量级的。
- 迁移不可怕,可怕的是没有渐进式策略。按模块切,保留旧接口,灰度一周,风险完全可控。
最后说点真实的:状态管理库不是银弹,如果不控制store数量(我们限制整个应用最多5个store),如果selector写得粗糙,性能照样会烂。但至少,它给了我们可控的粒度,这是Context和Props给不了的。现在新入职的同事看代码,不会再问“这个props是从哪传下来的”这种问题了——这大概就是工程化改造最大的价值。