一、问题背景:Context 不是不能用,是用错了地方

先说结论:Context 适合低频、全局、变化不频繁的值(主题、locale、用户信息)。但我们把它当成了 Redux 用——一个 AppContext 里塞了 30 多个字段,任何一次 dispatch 都会让所有 useContext 的组件重渲染。

具体症状:

  • 订单列表页有 200 行数据,勾选任意一行复选框,整个页面(包括顶部统计卡片、筛选栏、分页器)全部重渲染。React DevTools Profiler 显示单次交互 commit 耗时 180ms,其中 90% 的组件 props 根本没变。
  • Context value 每次 render 都是新对象引用,useMemo 依赖写错一次就前功尽弃,code review 时没人能保证不漏。
  • Vue 子应用更原始:props 从 App.vue 透传到第 7 层叶子组件,中间层组件被迫声明一堆自己用不到的 props,改一个字段名要动 5 个文件。

压垮骆驼的最后一根稻草是一个跨模块的"当前选中门店"状态:订单、库存、报表三个模块都要读,用 Context 后切门店会触发三个模块同时重渲染,其中两个模块当时根本不在可视区。

二、环境与版本

  • React 18.2.0 + TypeScript 4.9.5 + Vite 4.3.9
  • 状态库:Zustand 4.4.1(选它是因为 API 最小、无 Provider 嵌套、TS 推导好)
  • Vue 3.3.4 + Pinia 2.1.4(Vue 官方推荐,没有悬念)
  • 性能测量:React DevTools Profiler 4.28、Chrome 118 Performance 面板,测试机 MacBook Pro M1 / 16G,数据取 5 次中位数

对比过的方案(React 侧):

方案 包体积(gzip) 学习成本 重渲染控制 结论
Context + useReducer 0 低 差,需手动拆多个 Context 现状,放弃
Redux Toolkit 1.9 ~13KB 中 好,需 useSelector 团队嫌样板代码多
Zustand 4.4 ~1.2KB 低 好,selector 粒度细 采用
Jotai 2.4 ~3KB 中 原子级最优 适合细粒度,但我们状态偏"模块级",略重

Vue 侧没得选,Pinia 就是事实标准,setup store 写法跟 Composition API 心智一致。

三、方案设计:按"状态生命周期"切分,而不是按页面切

迁移前先做了一件事:把所有状态按生命周期分类,而不是按页面归类。这一步比选库重要得多。

  1. 服务端状态(列表数据、详情):本来就不该进全局 store,用 TanStack Query / SWR 管,这次不动。
  2. 模块级 UI 状态(筛选条件、分页、选中项):进各自模块的 store,一个模块一个文件。
  3. 全局状态(当前门店、用户权限、主题):单独 globalStore,字段控制在 10 个以内。
  4. 表单临时状态:留在组件内 useState,不迁移。

React 侧文件结构:

src/stores/
  globalStore.ts      // 门店、用户、权限
  orderStore.ts       // 订单模块
  inventoryStore.ts   // 库存模块

关键设计决策:store 里只放数据和同步 action,异步请求留在组件或 hooks 里。Zustand 官方也建议这么干,避免 store 变成"什么都往里塞"的垃圾桶。异步拉数据后调 setOrders(data) 即可。

四、核心实现

React 侧:Zustand store 与 selector 写法

先看 globalStore。注意 create 的泛型写法,以及 action 和 state 放在同一层(Zustand 4.x 推荐,不再需要分成两个参数):

// src/stores/globalStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface GlobalState {
  currentStoreId: string | null;
  permissions: string[];
  setCurrentStore: (id: string) => void;
  setPermissions: (p: string[]) => void;
}

export const useGlobalStore = create()(
  subscribeWithSelector((set) => ({
    currentStoreId: null,
    permissions: [],
    setCurrentStore: (id) => set({ currentStoreId: id }),
    setPermissions: (p) => set({ permissions: p }),
  }))
);

// 派生 selector 单独导出,避免组件里写内联对象导致每次新引用
export const selectCurrentStoreId = (s: GlobalState) => s.currentStoreId;
export const selectPermissions = (s: GlobalState) => s.permissions;

组件里这样用,一次只订阅一个字段:

// src/pages/order/OrderList.tsx
import { useGlobalStore, selectCurrentStoreId } from '@/stores/globalStore';
import { useOrderStore } from '@/stores/orderStore';

export function OrderList() {
  // 只订阅 currentStoreId,门店权限变化不会触发本组件重渲染
  const currentStoreId = useGlobalStore(selectCurrentStoreId);
  const orders = useOrderStore((s) => s.orders);
  const toggleSelect = useOrderStore((s) => s.toggleSelect);

  return (
     toggleSelect(id),
      }}
    />
  );
}

这里有个必须强调的点:不要写 useOrderStore((s) => ({ orders: s.orders, total: s.total }))。Zustand 4.x 默认用 Object.is 比较返回值,返回新对象每次都会判定为变化,等于没优化。要么拆成两次调用,要么用 useShallow:

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

const { orders, total } = useOrderStore(
  useShallow((s) => ({ orders: s.orders, total: s.total }))
);

useShallow 是 Zustand 4.4 才正式从 zustand/shallow 挪到 zustand/react/shallow 的,老版本路径不一样,升级时容易 import 错。

Vue 侧:Pinia setup store

Vue 侧用 setup 语法,和组件写法一致,迁移成本最低:

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

export const useOrderStore = defineStore('order', () => {
  const orders = ref([]);
  const selectedIds = ref>(new Set());

  const selectedCount = computed(() => selectedIds.value.size);

  function toggleSelect(id: string) {
    const next = new Set(selectedIds.value);
    next.has(id) ? next.delete(id) : next.add(id);
    selectedIds.value = next; // 必须赋新引用,Set 的 mutation 不触发响应
  }

  return { orders, selectedIds, selectedCount, toggleSelect };
});

组件里用 storeToRefs 解构保持响应性,方法直接从 store 拿:

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

const orderStore = useOrderStore();
const { orders, selectedCount } = storeToRefs(orderStore);
const { toggleSelect } = orderStore;

五、踩坑与优化

坑 1:Context 迁移时把"服务端状态"也搬进了 store。 第一版把订单列表数据放进 Zustand,结果切门店时要手动清空、手动 refetch、手动处理竞态,写了 80 行样板。后来退回 TanStack Query,store 只留 selectedIds 这种纯 UI 状态,代码少了三分之一。

坑 2:Vue 的 Set 响应性。 上面代码里 selectedIds.value = next 这行是踩坑后改的。直接 selectedIds.value.add(id) 在 Vue 3.3 里对 ref(new Set()) 不会触发更新(深层响应式对 Set 的 mutation 追踪有边界),必须换引用。这个 bug 上线前测试没发现,因为测试用例是刷新页面验证的。

坑 3:Zustand 的 selector 里做计算。 有同事写了 useOrderStore((s) => s.orders.filter(o => o.status === 'paid')),每次 store 任何字段变化都重新 filter,200 条数据下 CPU 占用明显。改成在 store 里维护 paidOrders 派生字段,或用 useMemo 包一层。

优化: 给 Zustand 加了 subscribeWithSelector 中间件,在非组件环境(比如埋点、WebSocket 回调)里监听门店切换:

useGlobalStore.subscribe(
  selectCurrentStoreId,
  (id, prevId) => {
    if (id !== prevId) reportStoreSwitch(id);
  }
);

六、效果数据

迁移前后用同一份测试数据(订单 200 条、门店 12 个)在 Chrome Performance 面板测 5 次取中位数:

指标 迁移前(Context/props) 迁移后(Zustand/Pinia) 变化
订单列表首屏渲染 1180ms 420ms -64%
勾选单行 commit 耗时 180ms 22ms -88%
切门店触发重渲染组件数 47 7 -85%
Vue 侧 props 透传最深层级 7 层 0(store 直取) -
状态相关代码行数 ~2100 ~1400 -33%

代码行数下降主要来自删掉中间的 props 声明和 Context Provider 嵌套。有个反直觉的点:包体积几乎没变(Zustand +1.2KB gzip,Pinia +4KB),因为删掉的 Context 样板和 props 类型定义差不多抵消了。

七、总结

三句话:

  1. Context 不是状态管理库,它解决的是依赖注入,不是高频状态分发。字段超过 10 个、更新频率高于每秒一次,就该考虑换。
  2. 迁移的关键不是选库,是先把状态按生命周期分类。服务端状态、模块 UI 状态、全局状态混在一起,换什么库都救不了。
  3. selector 粒度决定性能上限。Zustand 的 useShallow、Pinia 的 storeToRefs,这两个 API 用错,迁移等于白做。

如果重来一次,我会在项目初期就直接上 Zustand/Pinia,而不是"先用 Context 顶着,以后再说"。这个"以后"的迁移成本,是初期多写 200 行 store 代码的十倍。