一、问题背景:Context用着用着就变味了

项目是一个React 18.2 + TypeScript 5.0的中后台系统,大约120个页面组件。最初为了“少引依赖”,状态管理用的是最朴素的方式:就近用Props,跨层级用Context。刚开始很舒服,但随着业务膨胀,问题集中爆发:

  1. Provider地狱。AuthContext、ThemeContext、PermissionContext、GlobalConfigContext、NotificationContext五层嵌套包在App外层,每加一个全局状态就要动一次根组件,新同事看一眼main.tsx就头大。
  2. 重渲染失控。Context的value一旦变化,所有消费该Context的组件都会重渲染,哪怕它只关心value里的某一个字段。我们有个GlobalConfigContext存了主题、语言、侧边栏折叠、表格密度等7个字段,改一下侧边栏折叠,整个布局树全部重渲染。用React DevTools Profiler测了一次,单次切换触发了140+次组件render。
  3. 逻辑分散。每个Context都要手写Provider、useXxx hook、类型定义,异步请求逻辑散落在各个组件里,重复代码多。

于是决定迁移到专门的状态管理库。技术栈是React,所以对比范围锁定在React生态。

二、环境与版本

  • React 18.2.0 / ReactDOM 18.2.0
  • TypeScript 5.0.4
  • Vite 4.4.9
  • 迁移前:React Context(原生)
  • 候选库:Zustand 4.4.7、Redux Toolkit 1.9.5、Jotai 2.4.2
  • 最终选择:Zustand 4.4.7

方案对比我们拉了个表,重点看三件事:样板代码量、重渲染控制能力、学习成本。

维度 Context Redux Toolkit Jotai Zustand
样板代码 中(每个Context一套) 高(slice/store/provider) 低 低
重渲染控制 差(value变全量重渲) 好(useSelector) 好(原子级) 好(selector+浅比较)
异步支持 手写 createAsyncThunk 手写 action内直接async
包体积(gzip) 0 ~13KB ~4KB ~1.2KB
学习成本 低 中高 中 低

Redux Toolkit能力强但对这个项目偏重;Jotai的原子模型很优雅,但团队对“原子拆分粒度”没经验,容易拆过头;Zustand的API最接近我们原来写Context hook的习惯,迁移成本最低,且不需要Provider包裹(这点对减少嵌套很关键)。最终选Zustand。

三、方案设计:Store怎么切

迁移不是把Context一比一翻译成store,那样只是换了个壳。我们按“更新频率 + 消费范围”重新划分:

  • userStore:登录用户信息、token、权限列表。低频更新,全局消费。
  • appStore:主题、语言、侧边栏折叠、表格密度。中频更新,布局层消费。
  • notificationStore:全局消息、未读数。高频更新,但只有顶栏铃铛消费。

原则是:一个store内字段的更新频率尽量接近,避免高频字段拖累低频字段的订阅者。Context时代我们把这些全塞一个GlobalConfigContext里,正是重渲染的根源。

目录结构:

src/store/
  index.ts
  userStore.ts
  appStore.ts
  notificationStore.ts

四、核心实现

4.1 定义store(含selector与浅比较)

以appStore为例。关键是导出细粒度selector,组件只订阅自己用到的字段:

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

export type ThemeMode = 'light' | 'dark';
export type TableDensity = 'default' | 'middle' | 'small';

interface AppState {
  theme: ThemeMode;
  locale: 'zh-CN' | 'en-US';
  sidebarCollapsed: boolean;
  tableDensity: TableDensity;
  setTheme: (t: ThemeMode) => void;
  toggleSidebar: () => void;
  setTableDensity: (d: TableDensity) => void;
}

export const useAppStore = create()(
  devtools(
    persist(
      (set) => ({
        theme: 'light',
        locale: 'zh-CN',
        sidebarCollapsed: false,
        tableDensity: 'default',
        setTheme: (theme) => set({ theme }),
        toggleSidebar: () =>
          set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
        setTableDensity: (tableDensity) => set({ tableDensity }),
      }),
      {
        name: 'app-store',
        // 只持久化配置项,不持久化方法
        partialize: (s) => ({
          theme: s.theme,
          locale: s.locale,
          sidebarCollapsed: s.sidebarCollapsed,
          tableDensity: s.tableDensity,
        }),
        version: 1,
      }
    ),
    { name: 'AppStore', enabled: import.meta.env.DEV }
  )
);

// 细粒度selector,组件按需订阅
export const useTheme = () => useAppStore((s) => s.theme);
export const useSidebarCollapsed = () => useAppStore((s) => s.sidebarCollapsed);
// 需要一次取多个字段时,务必配合shallow,否则每次返回新对象会无限重渲
export const useLayoutConfig = () =>
  useAppStore(
    (s) => ({ theme: s.theme, sidebarCollapsed: s.sidebarCollapsed }),
    shallow
  );

这里有两个细节值得强调:partialize把方法和不需要持久化的字段排除,避免localStorage里存函数;shallow是防止“选择器返回新对象导致每次都判定为变化”的关键,后面踩坑部分会细说。

4.2 异步action与组件接入

userStore里放登录和拉取用户信息的异步逻辑,Zustand允许直接在action里写async:

// src/store/userStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { fetchProfile, login as loginApi } from '@/api/auth';

interface UserInfo {
  id: string;
  name: string;
  avatar: string;
}

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

export const useUserStore = create()(
  devtools(
    (set, get) => ({
      token: null,
      profile: null,
      permissions: [],
      loading: false,
      login: async (username, password) => {
        set({ loading: true });
        try {
          const { token } = await loginApi({ username, password });
          // 先落token,再拿profile,避免并发请求时token还没写进去
          set({ token });
          const { profile, permissions } = await fetchProfile(token);
          set({ profile, permissions, loading: false });
        } catch (e) {
          set({ loading: false });
          throw e;
        }
      },
      logout: () => set({ token: null, profile: null, permissions: [] }),
    }),
    { name: 'UserStore', enabled: import.meta.env.DEV }
  )
);

export const useUserProfile = () => useUserStore((s) => s.profile);
export const usePermissions = () => useUserStore((s) => s.permissions);

组件侧从原来的useContext(GlobalConfigContext).sidebarCollapsed改成useSidebarCollapsed(),改动很小:

// 迁移前
// const { sidebarCollapsed, toggleSidebar } = useContext(GlobalConfigContext);

// 迁移后
import { useSidebarCollapsed } from '@/store/appStore';
import { useAppStore } from '@/store/appStore';

function Sider() {
  const collapsed = useSidebarCollapsed();
  // 方法引用稳定,单独取不会引发额外订阅
  const toggle = useAppStore((s) => s.toggleSidebar);
  return ;
}

注意toggleSidebar这种函数在store创建时引用就固定了,单独select它不会因其他字段变化而重渲染,这是Context做不到的(Context里整个value对象一变,拿方法的组件也跟着重渲)。

五、踩坑与优化

坑1:选择器返回对象没加shallow,导致无限重渲染。 一开始写useAppStore(s => ({a: s.a, b: s.b})),每次render返回新对象,Zustand默认用Object.is比较,判定为变化,于是重渲染,再返回新对象……直接Maximum update depth exceeded。解决:要么拆成多个单字段selector,要么加shallow。我们统一规范是“多字段必加shallow”。

坑2:DevTools导致action参数序列化卡顿。 开了redux-devtools扩展后,某个批量更新action携带了上千条记录,序列化明显卡顿。用enabled: import.meta.env.DEV只在开发环境开,生产关闭,问题消失。

坑3:persist水合时机。 首屏从localStorage恢复状态前,组件已经用默认值渲染了一帧,出现主题闪烁。用persist的onRehydrateStorage配合一个hasHydrated标记,在根组件等水合完成再渲染,闪烁消除。

坑4:迁移顺序。 我们按“从叶子到根”迁:先迁notificationStore(只有顶栏用),再迁appStore(布局层),最后迁userStore(涉及路由守卫)。每迁一个跑一遍回归。切忌一次性全换,出问题难定位。

优化: 对高频更新的notificationStore,未读数用单独的selector,并把铃铛组件用React.memo包一层;列表数据不进store,留在组件本地state,只把“跨页面共享”的部分放进store,避免store变成第二个全局变量桶。

六、效果数据

用React DevTools Profiler和Lighthouse各测了3次取平均(Chrome 118,本地dev build关掉,production build):

指标 迁移前(Context) 迁移后(Zustand) 变化
首屏渲染耗时 420ms 280ms -33%
切换侧边栏触发render次数 142 45 -68%
Provider嵌套层数 5 1(仅主题/国际化Provider) -80%
每次全局状态更新的commit耗时 38ms 12ms -68%
状态相关样板代码行数 ~620 ~310 -50%

重渲染次数下降主要来自两点:一是细粒度selector让无关组件不再被牵连;二是去掉了Provider嵌套,减少了Context value变更的传播链路。首屏提升一部分来自包体积(Context方案里手写的Provider逻辑被精简)和渲染次数减少。

七、总结

Context不是不能用,它适合“低频、小范围、字段少”的共享状态。一旦出现“多字段混装 + 高频更新 + 全局消费”,就该考虑专用库了。Zustand对我们的价值不在于“新潮”,而在于selector带来的精确订阅和去掉Provider后的结构简化,迁移成本也低——大部分组件只改一行hook调用。

给准备迁移的同学几条建议:按更新频率拆store;多字段selector一定加shallow;异步action里注意set的时序;persist记得处理水合闪烁;迁移从叶子组件开始,逐个验证。别指望一次迁移解决所有性能问题,store里塞太多东西,照样会变成新的“全局变量地狱”。