一、问题背景:当Context成为性能瓶颈
去年接手的一个B端后台项目,最初用React 18 + Context API管理全局状态,后来由于团队技术栈调整,部分模块用Vue 3.5重写,出现了跨框架状态同步的噩梦。项目从MVP的12个页面膨胀到47个页面后,Context的value变化导致所有消费组件re-render的问题彻底爆发。
实际数据(Chrome Performance面板测量):
- 某个包含用户信息、权限、主题的Context,每次切换侧边栏菜单触发setState,导致43个子组件重渲染
- 首屏TTI(Time to Interactive)从1.2s恶化到3.8s
- 内存占用峰值达到89MB,在低端安卓设备上频繁触发GC
我们尝试过React.memo、useCallback、拆分Context,但很快发现Context本质上是“发布-订阅”模式,value一变,所有消费者都得执行。更致命的是跨框架通信——Vue模块和React模块共享一份登录态,需要手动在window上挂事件总线,代码丑且易错。
二、环境与版本:技术栈全景
React 18.2.0 + TypeScript 5.3.3 + Vite 5.0.0
Zustand 4.5.2(React侧)
Vue 3.5.1 + Pinia 2.1.7(Vue侧)
共用模块:@tanstack/react-query 5.x(服务端状态分离)
选型理由:
- Zustand:无Provider包裹,redux-devtools支持好,且支持在React组件外使用(对跨框架通信至关重要)
- Pinia:Vue 3官方推荐,配合Composition API比Vuex更直观
- 放弃Redux Toolkit:样板代码太多,且团队对Immer的mutate风格有争议
三、方案设计:双轨制状态层
核心思路是将“全局UI状态”与“服务端状态”彻底分离:
┌─────────────────────────────────┐
│ Server State (React Query) │ ← 接口缓存、轮询、乐观更新
├─────────────────────────────────┤
│ Client State (Zustand/Pinia) │ ← 用户信息、权限、主题、UI开关
└─────────────────────────────────┘
关键设计点:
1. React侧Zustand store:用create函数定义,store内部持有set和get,不依赖React上下文
2. Vue侧Pinia store:用defineStore定义,setup语法配合ref/computed
3. 跨框架桥接:写一个createBridge函数,用useSyncExternalStore(React 18)和watch(Vue 3)双向同步一个“最小状态切片”
四、核心实现:从Context到Zustand的迁移模板
4.1 React侧:Context → Zustand
原始Context代码(已简化为最小复现):
// 改造前:Context API
const AppContext = createContext({...});
export function AppProvider({children}) {
const [user, setUser] = useState(null);
const [theme, setTheme] = = useState('light');
// 每次setUser,所有useContext(AppContext)的组件全部re-render
return (
{children}
);
}
改造后Zustand store:
// store/authStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
user: User | null;
permissions: string[];
login: (user: User) => void;
logout: () => void;
}
export const useAuthStore = create()(
persist(
(set) => ({
user: null,
permissions: [],
login: (user) => set({ user, permissions: user.permissions }),
logout: () => set({ user: null, permissions: [] }),
}),
{ name: 'auth-storage' } // localStorage持久化
)
);
// 组件中使用:只订阅user字段
const user = useAuthStore((state) => state.user);
// 不订阅user的组件不会re-render
const login = useAuthStore((state) => state.login);
迁移工具函数(用于自动化替换):
// scripts/migrate-context.ts (简化版)
import { parse, visit } from 'ts-ast-parser';
export function replaceContextWithStore(filePath: string) {
const ast = parse(filePath);
const replacements = new Map();
visit(ast, {
// 找到 useContext(AppContext) 调用
CallExpression(path) {
const callee = path.node.callee;
if (callee.name === 'useContext' && path.node.arguments[0].name === 'AppContext') {
// 从具体字段映射到useAuthStore((state) => state.xxx)
const fieldName = path.parentPath.node.property.name;
replacements.set(
path.node.start,
`useAuthStore((state) => state.${fieldName})`
);
}
}
});
// 输出替换后的代码
return applyReplacements(filePath, replacements);
}
4.2 Vue侧:Props → Pinia
改造前Vue组件需要层层传递user和theme,改造后:
// stores/theme.ts
import { defineStore } from 'pinia';
export const useThemeStore = defineStore('theme', {
state: () => ({
theme: 'light' as 'light' | 'dark',
sidebarCollapsed: false,
}),
actions: {
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light';
},
},
getters: {
isDark: (state) => state.theme === 'dark',
},
});
import { storeToRefs } from 'pinia';
import { useThemeStore } from '@/stores/theme';
const themeStore = useThemeStore();
// storeToRefs保证解构后仍是响应式
const { theme, sidebarCollapsed } = storeToRefs(themeStore);
当前主题: {{ theme }} ({{ themeStore.isDark ? '暗色' : '亮色' }})
4.3 跨框架桥接(核心难点)
由于React和Vue的响应式原理不同,我们写了一个通用桥接层:
// shared/bridge.ts
import { useSyncExternalStore } from 'react';
import { watch, ref } from 'vue';
type StoreSnapshot = { version: number; data: T };
export function createCrossFrameworkBridge(initialData: T) {
const sharedData = ref(initialData); // Vue响应式
const listeners = new Set void>(); // React订阅
let version = 0;
// Vue侧更新 → 通知React
watch(sharedData, (newVal) => {
version++;
listeners.forEach((listener) => listener());
});
return {
// React 18的useSyncExternalStore
useReactSnapshot(): T {
return useSyncExternalStore(
(callback) => {
listeners.add(callback);
return () => listeners.delete(callback);
},
() => ({ version, data: sharedData.value })
).data;
},
// Vue侧直接访问
getVueRef() {
return sharedData;
},
// 通用setter
setData(newData: T) {
sharedData.value = newData;
}
};
}
// 创建登录态桥接
export const authBridge = createCrossFrameworkBridge({
user: null,
token: '',
});
五、踩坑与优化:三个预想不到的坑
坑1:Zustand的shallow比较陷阱
一开始我们直接useAuthStore((state) => state)——这会导致store任何字段变化都触发re-render。后来改用useAuthStore(selector, shallow),但selector返回对象时shallow只做浅比较,内层对象引用变化仍会触发渲染。
解决:将selector拆细,或者用useShallow从zustand/react/shallow导入。
坑2:Pinia的getter缓存问题
在Vue 3.5中,Pinia的getter会基于state自动缓存,但如果你在getter里返回一个新对象(如return this.items.filter(...)),每次访问都会重新计算。我们一个权限列表getter导致表格组件每帧都在重新过滤。
解决:在getter里用computed包一层,或者直接改为action在数据变化时手动更新。
坑3:React Compiler与Zustand的兼容性
试用React Compiler的react-compiler-runtime时,发现Zustand的useStore返回的函数会被Compiler错误地memorize,导致store更新不生效。最终放弃React Compiler,只保留babel-plugin-react-compiler的memoize选项,并对关键组件手动React.memo。
六、效果数据:迁移前后的量化对比
| 指标 | 迁移前 (Context/Props) | 迁移后 (Zustand/Pinia) | 变化 |
|---|---|---|---|
| 首屏TTI (中位数) | 3.8s | 2.2s | ↓ 42% |
| 菜单切换re-render数 | 43个组件 | 7个组件 | ↓ 84% |
| 内存峰值 (Chrome) | 89MB | 61MB | ↓ 31% |
| 代码行数 (全局状态相关) | 2140行 | 1560行 | ↓ 27% |
| 跨框架同步延迟 | 事件总线+手动触发 | 桥接层自动同步 | < 16ms |
特别说明:TTI下降很大一部分来自React Query接管服务端状态,Context只占其中的50%左右。但re-render数的改善完全归功于Zustand的细粒度订阅。
七、总结与建议
- Context不是不能用:如果你的状态是低频更新(如主题、语言),且组件树不超过3层,Context完全够用。我们的问题是把高频状态(用户信息)和全局UI状态混在一起。
- 状态管理库不是银弹:Zustand的selector需要设计好,否则性能比Context还差。建议用
useShallow+ 细粒度selector。 - 跨框架通信:如果遇到React/Vue混合项目,别用事件总线,写一个基于
useSyncExternalStore的桥接层,性能接近原生。 - 迁移要增量:我们花了2周时间,按“服务端状态 → 全局UI状态 → 跨模块共享状态”的顺序迁移,每次只改一个模块并跑完整测试。
最后,如果你也在做类似迁移,建议先用why-did-you-render库定位re-render热点,再决定是否值得迁移——别为了“用新技术”而重构。但如果你也面临40+页面、多技术栈、Context已经让开发效率下降,这篇文章的方法可以作为一个参考模板。
代码仓库已开源:github.com/your-project/state-migration-example(仅示例代码,不含业务逻辑)。有问题欢迎评论区交流。