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 函数支持 MapSet 数据结构,配合 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 内用中间件 devtoolspersist

第三步(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,才是稳妥的上产线路径。