一、问题背景:Context 和 Props 撑不住了

项目是一个后台管理系统,React 18.2 + TypeScript 5.3,Vue 子模块是 Vue 3.4 + Vite 5。早期为了快,全局状态直接用 Context 包了一层,局部状态靠 props 往下传。

问题在业务变复杂后集中爆发:

  1. Props 透传地狱。一个订单详情页,从页面组件到最底层的操作按钮,中间隔了 4 层组件,每层都要把 order、onUpdate、permissions 原样往下传。改一个字段要动 5 个文件。
  2. Context 重渲染失控。Provider 的 value 是一个对象字面量,每次父组件渲染都会生成新引用,导致消费该 Context 的所有组件全部重渲染。列表页滚动时,明明只有一行数据变了,整页 200 多个单元格都在 re-render。
  3. Vue 那边 provide/inject 也有类似问题,注入的响应式对象粒度太粗,改一个字段触发整棵子树更新。

我们用 React DevTools Profiler 抓了一次列表页交互:单次筛选操作触发 187 次组件渲染,commit 阶段耗时 46ms,明显掉帧。

二、环境与版本

  • React 18.2.0,ReactDOM 18.2.0
  • Vue 3.4.21,Vite 5.2.0
  • 状态库候选:Zustand 4.5.2、Redux Toolkit 2.2.3、Jotai 2.6.4、Pinia 2.1.7
  • 性能采集:React DevTools Profiler、Chrome Performance 面板、自定义 render 计数 HOC

三、方案设计:为什么选 Zustand 和 Pinia

对比了几个候选:

方案 包体积(gzip) 样板代码 选择器粒度 学习成本
Context 0 少 无(整对象订阅) 低
Redux Toolkit ~13KB 多(slice/store/provider) 中(useSelector) 中高
Zustand ~1.2KB 极少 细(selector + shallow) 低
Jotai ~4KB 少(atom) 原子级 中
Pinia ~6KB 少 细(storeToRefs) 低

选 Zustand 的核心理由:没有 Provider 包裹,store 是模块级单例,组件用 selector 订阅,天然避免无关重渲染;API 极简,迁移成本低。Vue 侧选 Pinia,因为它是 Vue 官方推荐,和 devtools 集成好,storeToRefs 能精确解构响应式字段。

设计原则定三条:
1. 按业务域拆 store(orderStore、userStore、uiStore),不搞一个大 store。
2. 组件只订阅自己用到的字段,禁止 const state = useStore() 全量订阅。
3. 异步请求放 store 的 action 里,组件不直接调 API。

四、核心实现

4.1 React 侧:从 Context 到 Zustand

迁移前的 Context 写法:

// 迁移前:Context + 对象 value,重渲染重灾区
const OrderContext = createContext(null);

export function OrderProvider({ children }: { children: React.ReactNode }) {
  const [order, setOrder] = useState(null);
  const [loading, setLoading] = useState(false);

  // 每次渲染都是新对象,所有消费者全部重渲染
  const value = { order, loading, setOrder, setLoading };

  return {children};
}

// 消费端
function OrderCell({ id }: { id: string }) {
  const { order } = useContext(OrderContext)!; // 订阅了整个 value
  return {order?.items[id]?.name};
}

迁移后的 Zustand store:

// stores/orderStore.ts
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';

interface OrderState {
  order: Order | null;
  loading: boolean;
  fetchOrder: (id: string) => Promise;
  updateItem: (itemId: string, patch: Partial) => void;
}

export const useOrderStore = create((set, get) => ({
  order: null,
  loading: false,
  fetchOrder: async (id) => {
    set({ loading: true });
    try {
      const data = await api.getOrder(id);
      set({ order: data, loading: false });
    } catch (e) {
      set({ loading: false });
      throw e;
    }
  },
  updateItem: (itemId, patch) => {
    const order = get().order;
    if (!order) return;
    set({
      order: {
        ...order,
        items: { ...order.items, [itemId]: { ...order.items[itemId], ...patch } },
      },
    });
  },
}));

// 组件里精确订阅,只关心单个 item
function OrderCell({ id }: { id: string }) {
  const itemName = useOrderStore((s) => s.order?.items[id]?.name);
  return {itemName};
}

关键点:selector 返回的是原始值(字符串),Zustand 用 Object.is 比较,只有该字段变化才重渲染。如果 selector 返回对象,必须配合 useShallow:

import { useShallow } from 'zustand/react/shallow';

const { order, loading } = useOrderStore(
  useShallow((s) => ({ order: s.order, loading: s.loading }))
);

迁移步骤我按这个顺序做,每步都能独立验证:

  1. 建 store 文件,先把 state 和 action 搬过去,组件暂时不动。
  2. 在入口移除 Provider,确认应用能正常启动(store 是单例,不需要包裹)。
  3. 逐个组件替换 useContext 为 useStore + selector,替换一个测一个。
  4. 删除旧 Context 文件和所有 props 透传参数,用 TS 编译报错兜底,确保没有遗漏。
  5. 跑一遍 Profiler,对比渲染次数。

4.2 Vue 侧:provide/inject 到 Pinia

// stores/useOrderStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useOrderStore = defineStore('order', () => {
  const order = ref(null);
  const loading = ref(false);
  const itemCount = computed(() => order.value?.items.length ?? 0);

  async function fetchOrder(id: string) {
    loading.value = true;
    try {
      order.value = await api.getOrder(id);
    } finally {
      loading.value = false;
    }
  }

  return { order, loading, itemCount, fetchOrder };
});

组件里用 storeToRefs 解构,保持响应式:

import { storeToRefs } from 'pinia';
import { useOrderStore } from '@/stores/useOrderStore';

const store = useOrderStore();
const { order, loading } = storeToRefs(store); // 解构后仍是响应式

注意 storeToRefs 只对 state 和 getter 有效,action 直接 store.fetchOrder() 调。

五、踩坑与优化

坑 1:selector 返回新对象导致无限重渲染。 一开始写了 useOrderStore((s) => ({ order: s.order })),每次返回新对象,Object.is 永远 false,组件疯狂重渲染。解决:用 useShallow 或者拆成多个单字段 selector。

坑 2:在 render 期间调 action 触发 set。 有个组件在渲染函数体里直接 updateItem,导致 React 报 "Cannot update a component while rendering"。解决:放到 useEffect 或事件回调里。

坑 3:Pinia 解构丢失响应式。 Vue 侧直接 const { order } = store 解构,order 变成普通值。必须用 storeToRefs。这个坑文档里有,但实际写的时候还是会忘。

优化 1:列表虚拟化配合细粒度订阅。 列表项组件订阅自己的 item,配合 react-window 只渲染可视区域,滚动时渲染数从 200+ 降到 15 左右。

优化 2:devtools 中间件。 Zustand 加 devtools 中间件,Pinia 自带 devtools,调试时能直接看 state 快照和 action 调用链,排查问题快很多。

六、效果数据

用 Profiler 和 Performance 面板采集,同一台机器(M1 MacBook,Chrome 124),同一份测试数据(订单列表 200 行,每行 8 个字段):

指标 迁移前(Context/Props) 迁移后(Zustand/Pinia) 变化
列表页首屏渲染 1.82s 1.21s -33%
单次筛选 commit 耗时 46ms 13ms -72%
单次筛选渲染组件数 187 52 -72%
props 透传平均层数 4 层 0 层 -
相关代码行数 约 1200 行 约 780 行 -35%

首屏提升主要来自两处:去掉了 Provider 嵌套带来的额外渲染,以及列表项细粒度订阅减少了无效更新。代码行数下降是因为删掉了大量 props 类型定义和透传代码。

内存占用变化不大(Zustand 单例常驻约 200KB 堆内存,可接受)。

七、总结

Context/Props 不是不能用,小项目、状态简单时它够用且零依赖。但当出现"改一个字段动五个文件"和"Profiler 里一片红"的时候,就该考虑状态管理库了。

我的建议:
- React 项目优先试 Zustand,体积小、无 Provider、selector 粒度细,迁移心智负担低。
- Vue 项目直接用 Pinia,官方生态,storeToRefs 用熟就行。
- 迁移按 store 建文件 → 去 Provider → 逐组件替换 → 删旧代码 → 跑 Profiler 的顺序来,每步可回滚。
- 一定用 selector 精确订阅,别全量订阅 store,否则等于换了个地方踩 Context 的坑。

这次迁移总共花了约 3 个工作日(React 侧 2 天,Vue 侧 1 天),收益是实打实的:渲染次数降七成,代码少三成,后面加新功能的心理负担小了很多。如果你的项目也在被 props 透传和 Context 重渲染折磨,可以试试这条路。