问题背景:Context不是状态管理库,但我们把它当成了

项目是一个后台管理系统,React 18.2 + TypeScript 5.2 做数据看板,Vue 3.3 做表单配置模块,两个子应用通过 qiankun 2.10 拼在一起。团队规模 6 人,迭代节奏两周一个版本。

早期的状态很简单:用户信息、主题、权限码,用 Context 完全够。问题出在业务变复杂之后——筛选条件、分页、表格选中项、弹窗开关,全都往一个 AppContext 里塞。半年后这个 Context 的 value 有 27 个字段,每次 setState 都会生成新对象,导致 useContext 的消费者全部重渲染。

我们做过一次 Profiler 采样:在数据列表页点击「筛选」按钮,React DevTools 显示提交阶段触发了 38 次组件重渲染,其中 31 次是无效渲染。页面有 200 行表格数据,卡顿肉眼可见。Vue 那边用 provide/inject 也有类似问题:注入的 reactive 对象被整个替换,依赖收集粒度太粗,表单联动时整棵子树更新。

到这一步我才承认:Context 和 provide/inject 解决的是「依赖传递」问题,不是「状态管理」问题。它们缺少选择器、缺少细粒度订阅、缺少 devtools 时间旅行,也没有脱离组件树的状态组织能力。

环境与版本

先把版本交代清楚,避免读者对不上:

  • React 18.2.0 / TypeScript 5.2.2 / Vite 4.4.9
  • Zustand 4.4.1(配 use-sync-external-store 1.2.0 shim)
  • Vue 3.3.4 / Pinia 2.1.6 / Vite 4.4.9
  • React DevTools 4.28、Vue DevTools 6.6
  • 性能采集:Chrome 118,Lighthouse 11.0,CPU 4x 降速

选型时对比了四个方案,最终 React 侧选 Zustand、Vue 侧选 Pinia,理由在下一节。

方案设计:为什么是 Zustand 和 Pinia

先上对比表,这是我们内部评审时用的:

维度 Context + useReducer Redux Toolkit 1.9 Zustand 4.4 Jotai 2.4
重渲染粒度 整个 Provider 子树 需 useSelector 手写 选择器自动订阅 原子级
样板代码 中 高(slice + thunk) 低 低
包体积(gzip) 0 ~13KB ~1.2KB ~3.5KB
异步处理 手写 createAsyncThunk 直接 async action 需插件
DevTools 无 强 支持 支持
学习成本 低 中高 低 中

Redux Toolkit 功能最全,但我们的迁移目标是「最小改动止血」,13KB 的体积和 slice 样板对 6 人团队是负担。Jotai 原子模型优雅,但把现有 27 个字段拆成原子是一次大重构。Zustand 的 store 就是一个 hook,选择器天然支持细粒度订阅,迁移成本最低。

Vue 侧没有太多纠结:Pinia 是官方推荐,2.1 版本对 SSR 和 devtools 支持成熟,且 defineStore 的 setup 写法几乎就是把 reactive 搬过去,心智负担为零。备选的 Vuex 4 已经进入维护模式,不选。

架构上我们定了三条规则:

  1. 按业务域拆 store,不搞全局大 store。看板一个、用户一个、UI 偏好一个。
  2. 组件只订阅自己需要的字段,禁止 useStore() 不带选择器。
  3. 服务端数据不进 store,继续用 TanStack Query 4.36 管缓存,store 只放客户端状态。

核心实现:从 Context 到 Zustand

迁移前的 Context 长这样,27 个字段塞在一个 value 里:

// 迁移前:AppContext.tsx
const AppContext = createContext(null);

export function AppProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState(initialState);
  // 每次 setState 都生成新对象,所有消费者重渲染
  return {children};
}

// 组件里
const { filters, setFilters, page, setPage } = useContext(AppContext)!;

迁移后,store 按域拆分,筛选和分页归到 useFilterStore:

// stores/filterStore.ts
import { create } from 'zustand';
import { devtools, subscribeWithSelector } from 'zustand/middleware';

interface FilterState {
  keyword: string;
  status: 'all' | 'active' | 'archived';
  page: number;
  pageSize: number;
  selectedIds: string[];
  setKeyword: (v: string) => void;
  setStatus: (v: FilterState['status']) => void;
  setPage: (v: number) => void;
  toggleSelect: (id: string) => void;
  reset: () => void;
}

const initialState = {
  keyword: '',
  status: 'all' as const,
  page: 1,
  pageSize: 20,
  selectedIds: [] as string[],
};

export const useFilterStore = create()(
  devtools(
    subscribeWithSelector((set) => ({
      ...initialState,
      setKeyword: (keyword) => set({ keyword, page: 1 }, false, 'setKeyword'),
      setStatus: (status) => set({ status, page: 1 }, false, 'setStatus'),
      setPage: (page) => set({ page }, false, 'setPage'),
      toggleSelect: (id) =>
        set(
          (s) => ({
            selectedIds: s.selectedIds.includes(id)
              ? s.selectedIds.filter((x) => x !== id)
              : [...s.selectedIds, id],
          }),
          false,
          'toggleSelect'
        ),
      reset: () => set(initialState, false, 'reset'),
    })),
    { name: 'filterStore' }
  )
);

组件侧的关键是选择器必须返回稳定引用。下面这个列表组件只订阅 keyword 和 status,selectedIds 变化不会触发它:

// components/FilterBar.tsx
import { useShallow } from 'zustand/react/shallow';
import { useFilterStore } from '../stores/filterStore';

export function FilterBar() {
  // 多个字段用 useShallow,避免每次返回新对象导致重渲染
  const { keyword, status, setKeyword, setStatus } = useFilterStore(
    useShallow((s) => ({
      keyword: s.keyword,
      status: s.status,
      setKeyword: s.setKeyword,
      setStatus: s.setStatus,
    }))
  );

  return (

       setKeyword(e.target.value)}
        placeholder="搜索..."
      />
       setStatus(e.target.value as any)}>
        全部
        进行中
        已归档


  );
}

Vue 侧迁移更直接,provide/inject 换成 Pinia 的 setup store:

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

export const useFormStore = defineStore('form', () => {
  const fields = ref>({});
  const dirty = ref(false);

  const filledCount = computed(() => Object.values(fields.value).filter(Boolean).length);

  function setField(key: string, value: string) {
    fields.value[key] = value;
    dirty.value = true;
  }

  function reset() {
    fields.value = {};
    dirty.value = false;
  }

  return { fields, dirty, filledCount, setField, reset };
});

组件里 const store = useFormStore(),模板中 store.filledCount 自动按需追踪,比 inject 整个 reactive 对象精确得多。

踩坑与优化:三个真实问题

坑一:选择器返回新对象,重渲染比 Context 还猛。 最初我写 useFilterStore((s) => ({ a: s.a, b: s.b })),每次 store 更新都返回新对象,Zustand 用 Object.is 比较直接判定不等,组件疯狂重渲染。解法是 useShallow,或者拆成多次单字段订阅。这个坑在官方文档里写了,但很容易忽略。

坑二:异步 action 里拿不到最新 state。 在 setTimeout 回调里读 useFilterStore.getState() 是安全的,但如果闭包捕获了组件里的 page 变量,就是旧值。统一改成 get().page 或 useFilterStore.getState().page。

坑三:DevTools 中间件在 production 被打包。 devtools 中间件默认会检查 process.env.NODE_ENV,但 Vite 下需要显式配置。我们在 vite.config.ts 里加了 define: { 'process.env.NODE_ENV': JSON.stringify(mode) },否则生产包多出约 4KB。

优化方面做了两件事:一是给 subscribeWithSelector 加上持久化,把 pageSize 和 status 存 localStorage;二是把表格行的选中状态从数组改成 Set,200 行数据下 toggleSelect 从 O(n) 降到 O(1),实测点击响应从 12ms 降到 3ms。

效果数据

迁移前后各跑 5 次 Lighthouse,取中位数:

指标 迁移前(Context) 迁移后(Zustand) 变化
筛选操作重渲染次数 38 6 -84%
首屏可交互时间(TTI) 2.8s 1.9s -32%
Lighthouse Performance 61 82 +21
主包体积(gzip) 0(Context) +1.2KB 可接受
列表滚动 FPS 42 58 +38%

Vue 模块的表单联动场景,输入一个字符触发的组件更新从 17 个降到 4 个,输入延迟从 45ms 降到 11ms。这些数字不是 benchmark,是真实业务页面的采样,但足够说明问题:状态管理的核心收益不是「代码更好看」,而是订阅粒度变细带来的渲染次数下降。

总结

如果重来一次,我会更早划清边界:服务端数据交给 TanStack Query,客户端状态交给 Zustand/Pinia,Context 只用来传主题和 i18n 这类几乎不变的配置。迁移本身不难,难的是先承认「Context 撑不住了」——我们拖了两个月,代价是用户投诉了三次卡顿。

给准备迁移的团队三条建议:按业务域拆 store 而不是搞大仓库;选择器一律用 useShallow 或单字段订阅;迁移期间保留旧 Context 做灰度,用 feature flag 切换,别一次性删干净。工具是次要的,订阅粒度才是状态管理的第一性问题。