一、问题背景:一个 Context 撑不住三个模块

项目是一个中后台管理系统,React 18.2 + TypeScript 5.3 + Vite 4.5。最初为了快速上线,全局状态用最朴素的方式解决:父子组件用 Props,跨层级用 Context。

问题出在业务迭代三个月后。我们有一个 AppContext,里面塞了用户信息、权限列表、主题配置、侧边栏折叠状态、通知未读数、当前租户 ID 等 17 个字段。Provider 放在根组件,任何字段变化都会生成新的 context value 对象,所有 useContext 的组件全部重渲染。

具体表现是:用户在 Profile 页切换一个开关,通知铃铛、侧边栏、权限按钮全部跟着重渲染。用 React DevTools Profiler 录了一次交互,单次操作触发了 42 次组件渲染,其中真正需要更新的只有 3 个。交互耗时 180ms 左右,在低端机上体感明显卡顿。

另一个问题是 Props 透传。一个表单组件嵌套了 5 层,中间三层纯粹为了把 tenantId 传下去,代码里全是 props.tenantId,改一个字段名要动 6 个文件。

这时候必须做决策:继续优化 Context(拆分、memo),还是引入状态管理库。

二、环境与版本

先把环境交代清楚,避免读者对数据产生误解:

  • React 18.2.0,ReactDOM 18.2.0
  • TypeScript 5.3.3
  • Vite 4.5.2
  • 状态管理库候选:Zustand 4.5.2、Jotai 2.6.4、Redux Toolkit 2.2.1
  • 性能测量工具:React DevTools Profiler 4.28、Chrome Performance 面板
  • 测试机型:MacBook Pro M1(开发机)、一台 2019 款 Windows 笔记本(低端机参照)

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

我们对比了三条路线,都是真实跑过 demo 的,不是拍脑袋:

方案 A:继续用 Context,做拆分 + memo

把 17 个字段拆成 4 个 Context(用户、权限、UI、通知),消费组件按需订阅。理论上可行,但有两个硬伤:一是 Provider 嵌套变多,根组件层级变深;二是 Context 的更新粒度仍然是「整个 value 对象」,字段之间如果有关联更新(比如切换租户同时更新权限和通知),依然会触发多个 Provider 更新。改造工作量不小,收益上限有限。

方案 B:Redux Toolkit

生态成熟,DevTools 强大,但模板代码多。我们的场景是中小型全局状态,不需要时间旅行调试和严格的 action 规范。引入 RTK 会让 slice、store、hook 三层结构增加心智负担,团队里两个初级开发需要额外学习成本。

方案 C:Zustand

API 极简,create 一个 store,组件用 selector 订阅。核心优势是 selector 级别的订阅粒度:只有 selector 返回值变化时才重渲染,配合 shallow 做浅比较可以订阅多个字段。没有 Provider 包裹,不需要改组件树结构。体积约 1.2KB gzip,对包体几乎无影响。

最终选 Zustand。Jotai 的原子化模型也很好,但我们的状态是「领域聚合」型(用户、租户、UI 配置),不是细粒度派生型,Zustand 的 store 模型更贴合。

四、核心实现:迁移步骤与代码

迁移分四步走,每步都可回滚。

第一步:定义 store 结构

按领域拆成三个 store,而不是一个大 store。这样职责清晰,也避免单 store 过大导致的 selector 复杂度。

// stores/userStore.ts
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';

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

interface UserState {
  userInfo: UserInfo | null;
  permissions: string[];
  tenantId: string;
  setUserInfo: (info: UserInfo) => void;
  setPermissions: (perms: string[]) => void;
  switchTenant: (tenantId: string) => void;
}

export const useUserStore = create((set) => ({
  userInfo: null,
  permissions: [],
  tenantId: '',
  setUserInfo: (info) => set({ userInfo: info }),
  setPermissions: (perms) => set({ permissions: perms }),
  switchTenant: (tenantId) =>
    set({ tenantId, permissions: [] }), // 切租户时清空权限,等待重新拉取
}));

UI 状态单独一个 store:

// stores/uiStore.ts
import { create } from 'zustand';

interface UIState {
  sidebarCollapsed: boolean;
  theme: 'light' | 'dark';
  unreadCount: number;
  toggleSidebar: () => void;
  setTheme: (theme: 'light' | 'dark') => void;
  setUnreadCount: (n: number) => void;
}

export const useUIStore = create((set) => ({
  sidebarCollapsed: false,
  theme: 'light',
  unreadCount: 0,
  toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
  setTheme: (theme) => set({ theme }),
  setUnreadCount: (unreadCount) => set({ unreadCount }),
}));

第二步:组件侧改造,用 selector 精确订阅

这是性能提升的关键。旧代码是 const { userInfo, permissions, tenantId } = useContext(AppContext),任何字段变化都重渲染。新代码按需订阅:

// components/ProfileSwitch.tsx
import { useUIStore } from '../stores/uiStore';
import { useUserStore } from '../stores/userStore';
import { shallow } from 'zustand/shallow';

export function ProfileSwitch() {
  // 只订阅 theme,theme 不变就不重渲染
  const theme = useUIStore((s) => s.theme);
  const setTheme = useUIStore((s) => s.setTheme);

  // 订阅多个字段时用 shallow 做浅比较,避免对象引用变化导致误重渲染
  const { userInfo, tenantId } = useUserStore(
    (s) => ({ userInfo: s.userInfo, tenantId: s.tenantId }),
    shallow
  );

  return (

      {userInfo?.name} @ {tenantId}
       setTheme(theme === 'light' ? 'dark' : 'light')}>
        当前主题:{theme}


  );
}

这里有个细节:如果不传 shallow,(s) => ({ userInfo, tenantId }) 每次都会返回新对象,Zustand 默认用 Object.is 比较,会认为值变了,导致每次 store 更新都重渲染。这是迁移中最常见的坑,后面细说。

第三步:替换 Provider

Zustand 不需要 Provider,直接把根组件的 `` 删掉。这一步要小心,如果有组件依赖 Context 的默认值(比如测试环境没包 Provider 时的兜底),需要改成 store 的初始值。

第四步:灰度与回滚

我们没有一次性替换所有组件。先在新页面试点,老页面保留 Context,两套并存约两周。AppContext 的 value 从 store 读取,保证数据源单一:

// 过渡期:Context 从 store 取值,老组件不用改
function AppContextBridge({ children }: { children: React.ReactNode }) {
  const value = useUserStore(
    (s) => ({ userInfo: s.userInfo, permissions: s.permissions, tenantId: s.tenantId }),
    shallow
  );
  return {children};
}

这样老组件行为不变,新组件直接用 store,迁移期间不会出现数据不一致。

五、踩坑与优化

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

上面提到的 shallow 问题。迁移第一版时,有个组件写了 useUserStore((s) => ({ a: s.a, b: s.b })),没加 shallow,结果每次任意 store 更新都触发该组件渲染,Profiler 里看到渲染次数不降反升。修复方式是所有返回对象/数组的 selector 都加 shallow,或者拆成多个单字段 selector。

坑 2:在 selector 里做计算

有同事写了 useUserStore((s) => s.permissions.filter(p => p.startsWith('admin'))),filter 每次返回新数组,即使权限没变也重渲染。正确做法是用 useMemo 包一层,或者把派生逻辑放到 store 外:

const permissions = useUserStore((s) => s.permissions);
const adminPerms = useMemo(
  () => permissions.filter((p) => p.startsWith('admin')),
  [permissions]
);

坑 3:异步 action 里的闭包陷阱

切换租户时需要先拉权限再更新 store,早期写成:

switchTenant: async (tenantId) => {
  const perms = await fetchPermissions(tenantId);
  set({ tenantId, permissions: perms }); // 这里读的是旧 state,没问题
}

但如果 action 里需要读当前 state 做判断,必须用 get() 而不是闭包捕获的变量,否则拿到的是创建时的快照。Zustand 的 create((set, get) => ...) 第二个参数就是 get,这个要养成习惯。

优化:批量更新合并

切换租户时会连续调用 setTenantId 和 setPermissions,两次 set 触发两轮订阅通知。Zustand 4.x 的 set 支持部分更新,我们把两个字段合并成一次 set({ tenantId, permissions }),减少一轮渲染。另外 React 18 的 automatic batching 对 store 更新也生效,但在事件回调外的异步代码里要注意手动合并。

六、效果数据

迁移完成后用 Profiler 重新测了同一交互(Profile 页切换主题开关),数据如下:

指标 Context 方案 Zustand 方案
单次交互组件渲染次数 42 6
交互总耗时(M1) 180ms 46ms
交互总耗时(2019 Win 笔记本) 410ms 112ms
Profile 页首屏 JS 执行 320ms 298ms
包体变化(gzip) - +1.2KB

渲染次数从 42 降到 6,是因为通知铃铛、侧边栏、权限按钮这些组件不再订阅 theme,主题切换只影响真正用到 theme 的 3 个组件(加上父级传递渲染共 6 次)。交互耗时下降约 74%,低端机上从 410ms 降到 112ms,体感从「明显卡顿」变成「无感知」。

代码层面,删掉了 3 个中间透传组件的 Props 定义,AppContext 从 17 个字段缩减到过渡期的桥接层,最终删除。新增代码主要是两个 store 文件,约 80 行。

七、总结

这次迁移的核心结论不是「Zustand 比 Context 好」,而是订阅粒度决定渲染成本。Context 的问题在于 value 是一个整体,任何字段变化都会通知所有消费者;Zustand 的 selector 让组件只关心自己用到的字段,这才是性能差异的根源。

如果项目状态简单、跨层级少,Context 完全够用,不必为了「先进」引入库。但如果出现以下信号,就该考虑迁移:单个 Context 字段超过 8 个、Profiler 里出现大量无关重渲染、Props 透传超过 3 层。迁移时务必保留过渡桥接层,灰度替换,别一次性全改。

最后提醒两个细节:selector 返回对象一定加 shallow,派生计算一定用 useMemo 或放 store 外。这两点做到,Zustand 的收益才能兑现。