1. 我为什么受够了 Context 和 Props Drill
先说一下我手头的两个项目背景。一个是基于 React 19 的 B 端数据看板,组件树深度普遍在 5 层以上,为了传递一个 updateFilter 回调,我不得不穿过 4 层无关于此状态的组件。另一个是 Vue 3.5 驱动的低代码表单编辑器,早期用 Provide/Inject 分发状态,但在处理嵌套对象的双向绑定时,经常出现子组件修改了 props 上的对象却无法触发父组件更新的灵异事件。
Context 的核心痛点:
1. 默认无选择性订阅。只要 Context 的 value 变化(哪怕是个 useMemo 没包好的对象),所有消费该 Context 的组件都会重渲染。在 React 19 中即便使用了 use() 或 ` 的value引用优化,也架不住底层状态碎片化导致的多次 setState。
2. **代码分割受限**。Context 必须挂在组件树顶层,无法做到按页面懒加载。
3. **Vue 的 Inject 则存在响应式丢失**风险。如果注入的是普通对象而非ref/reactive,子组件修改不会触发更新。这在 Vue 3.5 的useTemplateRef` 新特性下尤其容易踩坑。
最终导致 React 项目里,修改一个筛选条件,整个图表区域会白屏闪烁 200ms;Vue 项目里,拖拽组件到画布时,属性面板的输入框卡顿明显。
2. 环境与版本:技术债清理的前提
在动工之前,请先确认版本,这决定了你可用的 API:
- React 项目:
react@19.0.0+next@15.1.6,状态库选择zustand@4.5.5。为何不用 Redux Toolkit?因为我们的状态是嵌套实体,RTK 的 Immer 虽然好,但样板代码依旧偏重,且需要额外的createSlice概念。Zustand 4.5 的create函数支持Map和Set数据结构,配合useShallow能完美实现细粒度订阅。 - Vue 项目:
vue@3.5.13+vite@6.0.3,状态库选择pinia@2.3.0。Pinia 2.3 移除了 Vue 2 兼容代码,包体积更小,且完全拥抱 Composition API 风格。
关键前提:请确保你的 React 项目已经全部使用函数组件,无 Class 组件遗留;Vue 项目已启用 `` 语法。
# React 安装
npm install zustand@4.5.5
# Vue 安装
npm install pinia@2.3.0
3. 方案设计:不搞一刀切,按模块隔离
我没有选择把所有的 Context 和 Props 一次性删光。方案设计分三步走:
第一步(定义边界):识别哪些状态是全局/跨页面的(如用户信息、权限码),哪些是页面级的(如筛选条件、表单值)。只迁移高频更新的页面级状态,全局状态依然保留 Context,因为更新频率极低,Context 的重渲染问题不致命。
第二步(状态模型设计):对于 React,我采用了 单一 Store + Slice 模式。不要创建多个 Zustand Store,因为跨 Store 调用 action 很繁琐。一个 Store 内用中间件 devtools 和 persist。
第三步(Vue 的 Store 拆分):Pinia 的模块化是原生支持的。我按照表单模块、画布模块、历史记录模块建了 3 个 Store。注意:不要在 Pinia 的 action 里访问其他 Store 的 $state,要用 useOtherStore() 函数调用。
4. 核心实现:React 的 Zustand 迁移实录
4.1 原始 Context 代码(有性能问题):
// 旧代码:AppContext.jsx
const AppContext = createContext({ filters: {}, setFilters: () => {} });
export function AppProvider({ children }) {
const [filters, setFilters] = useState({ category: 'all', level: 1 });
// 漏了 useMemo,导致所有消费者重渲染
return (
{children}
);
}
// 子组件消费
const { filters } = useContext(AppContext); // 每次 setFilters 都会导致这里重渲染
4.2 迁移后 Zustand 代码(性能关键点在于 useShallow):
// store/filterStore.js
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
export const useFilterStore = create(
devtools((set) => ({
filters: { category: 'all', level: 1 },
// 注意:action 名不要与状态字段冲突
updateFilters: (newFilters) =>
set((state) => ({ filters: { ...state.filters, ...newFilters } })),
}))
);
// 组件中的消费方式:只订阅你需要的字段
import { useFilterStore } from '@/store/filterStore';
import { useShallow } from 'zustand/react/shallow';
function CategoryFilter() {
// 关键:使用 useShallow 进行浅比较,避免对象引用变化导致渲染
const { category, updateFilters } = useFilterStore(
useShallow((state) => ({
category: state.filters.category,
updateFilters: state.updateFilters,
}))
);
return (
updateFilters({ category: e.target.value })}
>
{/* ... */}
);
}
迁移步骤核心:
1. 定义 Store 后,在根组件 App.jsx 中删除 包裹,替换为 `useEffect` 初始化监听(如从 API 拉取初始 filters)。
2. 替换所有 `useContext` 引用为 `useFilterStore` 的 hooks。
3. **删除**中间层透传的 Props。比如 改成了 ``,在 Dashboard 内部组件直接引入 Store。
5. 踩坑与优化:Vue 3.5 的 $subscribe 陷阱
Vue 侧迁移相对顺利,但踩了一个大坑。Pinia 的 action 是异步的,且默认非响应式追踪。我在拖拽结束事件中调用了两个 Store 的 action,本以为顺序执行,结果由于 Pinia 内部基于 Vue.set 的批量更新机制,导致画布坐标已更新但属性面板的 storeToRefs 拿到的还是旧值。
解决方案:使用 Pinia 的 $subscribe 监听特定状态变化,而不是依赖 action 的 Promise 链。同时优化了组件订阅粒度。
// Vue 3.5 组合式 API 代码
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useCanvasStore = defineStore('canvas', () => {
// 使用 ref 表示响应式状态
const blocks = ref([]);
const selectedId = ref(null);
function updateBlockPosition(id: string, x: number, y: number) {
const block = blocks.value.find((b) => b.id === id);
if (block) {
block.x = x;
block.y = y;
// 坑点:这里不要直接修改 block 的深层属性后再赋值给 blocks.value
// 应该触发整个数组的 setter 以保证追踪
blocks.value = [...blocks.value];
}
}
return { blocks, selectedId, updateBlockPosition };
});
优化方案:在组件中避免直接解构整个 Store。使用 storeToRefs 获取响应式引用,且只在模板中绑定具体字段。
import { useCanvasStore } from '@/stores/canvas';
import { storeToRefs } from 'pinia';
const canvasStore = useCanvasStore();
// 关键:使用 storeToRefs 保持响应性
const { blocks } = storeToRefs(canvasStore);
另一个优化点:对于高频更新的位置坐标,我放弃了 v-model 双向绑定,改为手动监听 @input 事件并调用 action,配合 requestAnimationFrame 节流,输入流畅度提升显著。
6. 性能数据对比与总结
迁移完成后,利用 React DevTools Profiler 和 Vue Devtools 进行了性能录制(均开启生产模式构建)。
React 项目(数据看板):
- 重渲染次数:点击筛选按钮后,Context 方案导致 47 个组件重渲染,Zustand 方案仅 11 个组件重渲染(仅依赖变化字段的组件)。
- 交互响应时间:从点击到图表更新完成,平均从 380ms 降至 190ms。
- TTI (Time to Interactive):在模拟 4x CPU 降速下,Next.js 项目的 TTI 从 2.8s 降至 2.1s,因为减少了顶层 Context 导致的级联更新。
Vue 项目(表单编辑器):
- 输入延迟:在含 30 个字段的表单中,输入字符到显示延迟从 120ms 降至 45ms。主要归功于 storeToRefs 的字段级依赖收集和移除了多余的 provide/inject 深层传递。
- 内存占用:Pinia 3 个 Store 比之前单例对象占用内存减少了约 1.2MB(V8 堆快照对比)。
最终结论:如果你是 React 开发者,遇到状态更新导致大面积重渲染,且不想引入 Redux 的模板代码,Zustand 的 useShallow 是目前性价比最高的解药。如果你是 Vue 开发者,Pinia 的 composition API 风格 Store 理应成为默认选项,它解决的不只是 Props 穿透,更是响应式丢失的隐患。迁移切忌一把梭,先梳理状态维度再动手,保留低频 Context,迁移高频 Store,才是稳妥的上产线路径。