一、问题背景:Context 不是银弹,我们把它用成了全局 store

先说结论:Context 的设计目标是「依赖注入」,不是「高频状态共享」。我们踩的坑,基本都源于把这两件事混为一谈。

项目是一个 React 18.2 + TypeScript 5.2 的中后台系统,包含权限、租户、字典、工作流、表格筛选等 12 个业务域。早期为了赶进度,全局状态用了一个 AppContext,把所有东西塞进同一个 value:

// 迁移前的写法(反面教材)
const AppContext = createContext(null);

export function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [tenant, setTenant] = useState(null);
  const [dicts, setDicts] = useState({});
  const [filters, setFilters] = useState({});
  const [collapsed, setCollapsed] = useState(false);

  const value = { user, tenant, dicts, filters, collapsed, setUser, setTenant, setDicts, setFilters, setCollapsed };
  return {children};
}

问题在三个地方爆发:

  1. value 每次都是新对象,任何一次 setCollapsed 都会让所有 useContext(AppContext) 的组件重渲染。我们用 React DevTools Profiler 抓了一次侧边栏折叠操作:单次渲染耗时 340ms,涉及 213 个组件,其中真正需要更新的只有 3 个。
  2. Provider 嵌套 7 层(AppProvider → ThemeProvider → I18nProvider → PermissionProvider → ...),新人接手时根本理不清状态归属。
  3. Props 透传深度达到 6 层,一个 tenantId 从页面传到表格单元格,中间 4 个组件纯粹是搬运工。

我们统计了一下:全项目 useContext 调用 156 处,Props 透传参数中 38% 是"只为传递、自身不用"的。这就是要动手的信号。

二、环境与版本

  • React 18.2.0,ReactDOM 18.2.0(并发特性已开启)
  • TypeScript 5.2.2,strict: true
  • Vite 4.4.9,构建产物 gzip 后 412KB
  • 现有状态方案:Context + Props,无任何状态库
  • 候选库版本:zustand 4.4.1、@reduxjs/toolkit 1.9.5、jotai 2.4.2
  • 性能采集:React DevTools Profiler 4.28 + 自建 PerformanceObserver 埋点(LCP、INP)

三、方案设计:为什么最终选 Zustand

我们拉了个对比表,维度是团队最关心的四项:

维度 Context Redux Toolkit Zustand 4.4 Jotai 2.4
重渲染控制 无(value 变即全量) useSelector 精确 selector + shallow 精确 atom 级精确
样板代码 少 多(slice/thunk) 极少 少
异步/副作用 手写 createAsyncThunk 直接在 action 里 async 需配合 utils
迁移成本 - 高(要改 dispatch 心智) 低(hook 直接调用) 中(要拆 atom)
包体积(gzip) 0 ~13KB ~1.2KB ~4KB

排除法:Redux Toolkit 对我们这种"只想把 Context 换掉"的诉求太重,团队 6 个人要重新建立 dispatch/action 心智,收益不匹配;Jotai 的 atom 模型很优雅,但我们需要把大量现成的"对象型 state + 方法"平移过来,atom 拆分会让迁移周期翻倍。Zustand 的 store 本质就是一个带订阅的闭包对象,useStore(selector) 天然支持精确订阅,且可以在组件外调用(比如 axios 拦截器里读 token),这点对我们特别关键。

迁移策略定为渐进式、按域拆分,不做一次性大爆炸:

  1. 先建 stores/ 目录,按业务域建 6 个 store:userStore、tenantStore、dictStore、uiStore、filterStore、permissionStore。
  2. 保持 Context 外壳不动,内部实现逐步替换,避免一次性改 156 处调用点。
  3. 每个 store 用 devtools + persist 中间件,persist 只对 userStore、uiStore 开启(partialize 白名单)。
  4. 建立 ESLint 规则:新代码禁止从 AppContext 导入,老代码加 // TODO(migrate) 标记。

四、核心实现:store 定义与 selector 拆分

先看一个典型的 store。这里用 create + devtools + persist,注意 partialize 只持久化必要字段,避免把 loading 这种瞬时态写进 localStorage:

// stores/userStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface UserState {
  token: string | null;
  profile: { id: string; name: string; roles: string[] } | null;
  loading: boolean;
  login: (username: string, password: string) => Promise;
  logout: () => void;
}

export const useUserStore = create()(
  devtools(
    persist(
      immer((set, get) => ({
        token: null,
        profile: null,
        loading: false,
        login: async (username, password) => {
          set({ loading: true }, false, 'user/loginStart');
          try {
            const res = await fetch('/api/login', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ username, password }),
            });
            if (!res.ok) throw new Error(`login failed: ${res.status}`);
            const data = await res.json();
            set({ token: data.token, profile: data.profile }, false, 'user/loginSuccess');
          } finally {
            set({ loading: false }, false, 'user/loginEnd');
          }
        },
        logout: () => set({ token: null, profile: null }, false, 'user/logout'),
      })),
      {
        name: 'app-user',
        partialize: (s) => ({ token: s.token, profile: s.profile }), // 不持久化 loading
        version: 1,
      }
    ),
    { name: 'UserStore', enabled: import.meta.env.DEV }
  )
);

关键点在 selector。迁移前组件里是 const { user, dicts, collapsed } = useContext(AppContext),迁移后必须改成细粒度订阅,否则等于没换。我们用 useShallow(zustand 4.4 内置的 shallow 比较 hook)处理"一次取多个字段"的场景:

// components/Sidebar.tsx —— 迁移后
import { useShallow } from 'zustand/react/shallow';
import { useUIStore } from '@/stores/uiStore';
import { useUserStore } from '@/stores/userStore';

export function Sidebar() {
  // 只订阅 collapsed 和 toggle,其它字段变化不触发本组件重渲染
  const { collapsed, toggle } = useUIStore(
    useShallow((s) => ({ collapsed: s.collapsed, toggle: s.toggle }))
  );
  // 只取 name,profile 其它字段变化不影响这里
  const userName = useUserStore((s) => s.profile?.name ?? '未登录');

  return (

      toggle
      {userName}

  );
}

配套的 uiStore 很简单,但要注意:不要把方法写进 selector 的返回对象里再 shallow 比较——函数引用是稳定的,没问题;但如果你在 selector 里 map/filter 生成新数组,必须用 useShallow 或 useMemo,否则每次返回新引用会导致无限重渲染。我们就在 filterStore 上踩过这个坑(见下一节)。

组件外读取(axios 拦截器)也顺手解决了,这是 Context 做不到的:

// utils/http.ts
import { useUserStore } from '@/stores/userStore';

http.interceptors.request.use((config) => {
  const token = useUserStore.getState().token; // 非 hook 调用,无需组件上下文
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

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

坑 1:selector 返回新对象导致无限渲染。 filterStore 里有个 getActiveFilters 方法,我一开始写成 useFilterStore((s) => s.filters.filter(f => f.enabled)),filter 每次返回新数组,Object.is 判定不等,React 直接报 "Maximum update depth exceeded"。解决:要么用 useShallow,要么把派生逻辑放进 store 用 useMemo 缓存。我们最终在 store 里维护了一个 activeFilters 派生字段,在 set 时同步更新,读的时候直接取引用。

坑 2:persist 的 hydration 时序。 首屏渲染时 localStorage 还没读完,token 是 null,导致路由守卫把已登录用户踢到登录页。Zustand persist 提供 onRehydrateStorage 和 hasHydrated,我们加了一个 useHydration hook,在 hasHydrated 为 false 时渲染骨架屏:

export function useHydration() {
  const [hydrated, setHydrated] = useState(useUserStore.persist.hasHydrated());
  useEffect(() => {
    const unsub = useUserStore.persist.onFinishHydration(() => setHydrated(true));
    return unsub;
  }, []);
  return hydrated;
}

坑 3:devtools 在生产环境拖慢性能。 一开始 enabled 没加环境判断,生产包里 devtools 中间件仍在跑序列化,INP 反而涨了 40ms。改成 enabled: import.meta.env.DEV 后恢复正常。另外 set 的第三个参数(action name)在 devtools 里很有用,但在生产会被 tree-shake 掉,不影响体积。

优化点:把 6 个 store 里高频更新的 filterStore 单独做了 subscribeWithSelector,在表格组件里用 subscribe 做命令式更新,绕开 React 渲染,滚动筛选时帧率从 42fps 回到 58fps。

六、效果数据

迁移分 3 个迭代、共 11 个工作日完成,改动 89 个文件。用同一台机器(M1 MacBook,Chrome 118,4x CPU 降速)跑 Profiler,取 10 次中位数:

指标 迁移前(Context) 迁移后(Zustand) 变化
侧边栏折叠单次渲染组件数 213 27 -87.3%
折叠操作渲染耗时 340ms 46ms -86.5%
首屏可交互延迟(INP) 620ms 190ms -69.4%
列表筛选滚动帧率 42fps 58fps +38%
全局状态相关代码行数 1,240 610 -50.8%
包体积(gzip) 412KB 413.2KB +1.2KB

包体积只涨了 1.2KB,基本可忽略。代码行数减半主要来自删掉了大量 Props 透传和 Provider 嵌套。团队反馈最直观的一点:新人现在看 stores/ 目录就知道全局状态有哪些,不用再顺着 Provider 往下扒。

七、总结

这次迁移给我最大的教训是:Context 适合"低频、稳定"的依赖注入(主题、i18n、权限常量),不适合"高频、多变"的共享状态。我们当初把两者混在一起,才付出了 620ms 的代价。

如果你们项目也在考虑类似迁移,几点建议:

  1. 先量化再动手,用 Profiler 抓一次典型操作的重渲染组件数,数据比直觉可靠。
  2. 按业务域拆 store,别造一个巨型 store,否则只是把 Context 的问题换个地方复现。
  3. selector 一定要细,useShallow 该用就用,否则迁移等于白做。
  4. 渐进式迁移,保留 Context 外壳做过渡,别一次性改上百个调用点。
  5. persist 的 hydration 时序一定要处理,这是上线后最容易出线上事故的点。

Zustand 不是唯一答案,Jotai 在细粒度场景下更优雅。但对我们这种"从 Context 平移对象型 state"的诉求,它的迁移成本、体积和心智负担的平衡点是最好的。选型没有最优,只有最匹配当下团队和代码库的那一个。