一、为什么要动 Context

项目是一个运营后台,2022 年立项时只有 8 个页面,全局状态用 Context + useReducer 就够了。到 2024 年初膨胀到 60 多个页面,问题集中爆发:

  • AppContext 一个 Provider 里塞了用户信息、权限、主题、字典缓存、全局 loading、通知列表,任何字段变化都会让所有 useContext 的组件重渲染。用 React DevTools Profiler 抓一次「切换侧边栏折叠」的操作,重渲染组件 214 个,耗时 380ms。
  • 一个字典下拉框组件订阅了整个 Context,用户打字过滤时每次按键触发 6 次无关列表重渲染。
  • useReducer 的 action 类型已经有 90 多个,reducer.ts 单文件 1200 行,改一个字段要翻半天。

当时有两条路:继续拆 Context,或者引入状态管理库。我们决定先做方案对比,再迁一个模块试点。

二、环境与候选方案

版本约束:

  • React 18.2.0、TypeScript 5.3.3、Vite 5.0.12
  • 已有依赖:react-router-dom@6.22.0@tanstack/react-query@5.20.5(服务端状态已交给它)
  • 包体预算:gzip 后新增不超过 5KB

候选三个:

方案 版本 gzip 体积 心智模型 适用场景
拆分 Context + useMemo 0 手动优化 状态少、更新低频
Zustand 4.5.0 1.2KB 单 store + selector 全局共享、读写频繁
Jotai 2.6.2 3.8KB 原子化、自底向上 细粒度、派生状态多

注意我们没有考虑 Redux Toolkit,因为 @tanstack/react-query 已经接管了异步数据,剩下的纯客户端状态不值得上 Redux 那套模板代码。

试点选了「用户偏好 + 字典缓存」这个模块,因为它同时有高频读(字典)和低频写(主题)。

三、方案设计:两套并存

最终不是二选一,而是按状态特征分工:

  • Zustand:全局单例、需要 getState() 在组件外读取的状态。比如权限判断要在路由守卫(非组件环境)里用,Zustand 的 store.getState() 天然合适。
  • Jotai:组件树内、派生关系复杂的状态。比如字典下拉框,keywordAtom 派生 filteredOptionsAtom,只有订阅了这个 atom 的组件才更新。

这个划分不是拍脑袋,是试点后根据调试数据定的。

四、核心实现

4.1 Zustand 承接权限与用户偏好

迁移前 Context 写法(简化):

// 旧: AppContext.tsx
const AppContext = createContext(null);

export function AppProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return {children};
}

// 组件里
const { user, permissions } = useContext(AppContext)!;

迁移后 Zustand:

// store/appStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface AppState {
  user: User | null;
  permissions: string[];
  theme: 'light' | 'dark';
  setUser: (u: User | null) => void;
  setTheme: (t: 'light' | 'dark') => void;
  hasPermission: (code: string) => boolean;
}

export const useAppStore = create()(
  persist(
    (set, get) => ({
      user: null,
      permissions: [],
      theme: 'light',
      setUser: (user) => set({ user, permissions: user?.permissions ?? [] }),
      setTheme: (theme) => set({ theme }),
      hasPermission: (code) => get().permissions.includes(code),
    }),
    {
      name: 'app-store',
      storage: createJSONStorage(() => localStorage),
      // 只持久化 theme,user 每次登录重新拉
      partialize: (s) => ({ theme: s.theme }),
      version: 2,
    }
  )
);

关键点是用 selector 订阅,不要解构整个 store

// ❌ 错误:任何字段变化都重渲染
const { theme, user } = useAppStore();

// ✅ 正确:只订阅需要的字段
const theme = useAppStore((s) => s.theme);
const user = useAppStore((s) => s.user);

路由守卫里直接读,不依赖组件:

// router/guard.ts
import { useAppStore } from '@/store/appStore';

export function checkPermission(code: string) {
  // 非组件环境也能读,这是选 Zustand 的核心原因
  return useAppStore.getState().hasPermission(code);
}

4.2 Jotai 处理字典与派生状态

字典模块迁移前是 Context 里一个大对象,迁移后拆成原子:

// store/dictAtoms.ts
import { atom } from 'jotai';
import { atomWithQuery } from 'jotai-tanstack-query';

// 基础原子:字典原始数据
export const dictMapAtom = atom>({});

// 派生原子:当前选中的字典 key
export const activeDictKeyAtom = atom('');

// 派生原子:搜索关键词
export const keywordAtom = atom('');

// 派生原子:过滤结果,只有订阅它的组件才更新
export const filteredOptionsAtom = atom((get) => {
  const map = get(dictMapAtom);
  const key = get(activeDictKeyAtom);
  const kw = get(keywordAtom).trim().toLowerCase();
  const list = map[key] ?? [];
  if (!kw) return list;
  return list.filter((i) => i.label.toLowerCase().includes(kw));
});

组件里按需订阅:

// components/DictSelect.tsx
import { useAtomValue, useSetAtom } from 'jotai';
import { filteredOptionsAtom, keywordAtom, activeDictKeyAtom } from '@/store/dictAtoms';

export function DictSelect({ dictKey }: { dictKey: string }) {
  const options = useAtomValue(filteredOptionsAtom);
  const setKeyword = useSetAtom(keywordAtom);
  const setKey = useSetAtom(activeDictKeyAtom);

  useEffect(() => {
    setKey(dictKey);
  }, [dictKey, setKey]);

  return (

       setKeyword(e.target.value)} />

        {options.map((o) => (
          {o.label}
        ))}


  );
}

filteredOptionsAtom 是派生原子,Jotai 内部做了依赖追踪,keywordAtom 变化只会让读了这个派生原子的组件重渲染,其他组件纹丝不动。

五、迁移步骤

我们按模块灰度迁移,没有一次性大改:

  1. 建 store 目录,先写 Zustand 的 appStore,与旧 Context 并存。
  2. 加适配层:旧 Context 里从 Zustand 读值,保证老组件不报错。
    tsx // 过渡期:AppProvider 内部转发 export function AppProvider({ children }: { children: ReactNode }) { const user = useAppStore((s) => s.user); const permissions = useAppStore((s) => s.permissions); const theme = useAppStore((s) => s.theme); const value = useMemo(() => ({ user, permissions, theme }), [user, permissions, theme]); return {children}; }
  3. 按页面替换 useContext(AppContext)useAppStore(selector),每替换一个页面跑一次单测。
  4. 字典模块单独迁 Jotai,先迁读,再迁写。
  5. 删除旧 ContextAppContext.tsx 从 480 行删到 0。

整个过程用了 3 个迭代,约 6 人日。

六、踩坑与优化

坑 1:Zustand 里放非序列化对象导致 persist 报错。 一开始把 Date 对象直接塞进 store 并持久化,JSON.stringify 后变成字符串,读回来类型不对。解决:partialize 只持久化必要字段,或自定义 serialize/deserialize

坑 2:Jotai 的 atomWithQuery 版本不匹配。 jotai-tanstack-query@0.8.0 要求 @tanstack/react-query@5.x,我们锁的是 5.20.5 没问题,但同事装了 4.x 的 query 直接白屏。解决:在 package.jsonoverrides 锁定。

坑 3:selector 返回新对象导致无限重渲染。

// ❌ 每次返回新数组,触发重渲染
const list = useAppStore((s) => s.permissions.filter(Boolean));

// ✅ 用 useShallow
import { useShallow } from 'zustand/react/shallow';
const list = useAppStore(useShallow((s) => s.permissions.filter(Boolean)));

坑 4:Jotai atom 在组件外读取。 有次在工具函数里想读 atom 值,发现 Jotai 没有 getState。解决:这类场景统一走 Zustand,这也印证了前面「按状态特征分工」的设计。

优化点:给 Zustand 的 devtools 中间件加上 enabled: import.meta.env.DEV,生产环境不加载,省了约 0.4KB。

七、效果数据

用 React DevTools Profiler 和 performance.mark 在同一个页面测了迁移前后各 10 次取中位数:

指标 迁移前 迁移后 变化
首屏渲染(FCP) 1.9s 1.2s -37%
侧边栏折叠重渲染组件数 214 31 -85%
字典搜索每次按键重渲染 6 次 0 次 -100%
包体 gzip 增量 +3.1KB 可接受
AppContext.tsx 行数 480 0 删除

首屏提升主要来自两处:一是 AppProvider 不再每次渲染创建新对象;二是字典模块的派生原子避免了整棵子树重渲染。

八、总结

如果重来一次,我的选择不会变:Zustand 管全局单例和组件外读取,Jotai 管组件树内的细粒度派生,两者不冲突。Context 不是不能用,而是当它承担了「全局状态中心」的角色时,性能和维护成本会随规模非线性上升。

给准备迁移的同学三条建议:

  1. 先做试点,用 Profiler 量化收益,别凭感觉。
  2. 用适配层灰度迁移,别一次性删 Context。
  3. selector 写法是性能关键,useShallow 和派生原子要提前和团队对齐规范。

代码已开源在内部仓库 fe-state-migration,有需要的同学可以对照着看。