一、问题背景:Context 不是不能用,是撑不住

项目是一个中后台管理系统,React 18.2.0 + TypeScript 4.9 + Vite 4.3。最初全局状态只有用户信息、主题、权限三块,用 Context + useReducer 完全够用,代码也干净。

转折点在半年前。业务扩张,全局状态陆续加到了 30 多个字段:用户信息、权限列表、全局 loading、通知中心、字典缓存、多标签页状态、侧边栏折叠……全部塞在一个 AppContext 里。

问题随之而来。React DevTools Profiler 显示,我在顶部搜索框输入一个字符,会触发 43 个组件重渲染,其中大部分跟搜索框毫无关系。为什么?因为 Context 的 value 是一个新对象,任何字段变化都会让所有 useContext(AppContext) 的消费者重渲染。哪怕我只改了 notification.count,订阅了 theme 的组件也照样重渲染。

实测数据很直观:

  • 输入框按键到 UI 更新:平均 118ms(目标
    {children}

);
}

export const useApp = () => useContext(AppContext);

组件里用 `const { state } = useApp()`,然后 `state.user.name`。问题就在这——只要 state 里任何字段变,这个组件就重渲染。

### 4.2 迁移后的 Zustand store

以用户 store 为例:

```ts
// 迁移后:store/useUserStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface UserState {
  userInfo: { id: string; name: string; avatar: string } | null;
  token: string;
  setUserInfo: (info: UserState['userInfo']) => void;
  setToken: (token: string) => void;
  logout: () => void;
}

export const useUserStore = create()(
  subscribeWithSelector((set) => ({
    userInfo: null,
    token: '',
    setUserInfo: (userInfo) => set({ userInfo }),
    setToken: (token) => set({ token }),
    logout: () => set({ userInfo: null, token: '' }),
  })),
);

组件里按需订阅,这是性能提升的关键:

// 只订阅 name,userInfo 其他字段变化不会重渲染
function UserName() {
  const name = useUserStore((s) => s.userInfo?.name);
  return {name};
}

// 订阅多个字段时用 shallow 比较,避免对象引用变化导致重渲染
import { shallow } from 'zustand/shallow';

function UserCard() {
  const { name, avatar } = useUserStore(
    (s) => ({ name: s.userInfo?.name, avatar: s.userInfo?.avatar }),
    shallow,
  );
  return {name};
}

注意 shallow 这个参数。Zustand 4.x 的 useStore 第二个参数是比较函数,不传的话返回对象每次都是新引用,会一直重渲染。我一开始就踩了这个坑,后面细说。

4.3 组件外访问 store

这是 Zustand 比 Context 香的地方。原来在 axios 拦截器里拿 token 得靠模块变量,现在直接:

// request.ts
import { useUserStore } from '@/store/useUserStore';

axios.interceptors.request.use((config) => {
  const token = useUserStore.getState().token;
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

getState() 不订阅、不触发渲染,工具函数里随便调。

五、踩坑与优化

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

这是最常见的坑。下面这种写法,每次渲染 selector 都返回新对象,useStore 认为状态变了:

// 错误:每次都返回新数组,组件无限重渲染
const list = useDictStore((s) => s.dicts.filter((d) => d.type === 'status'));

解决方式二选一。要么用 shallow

import { shallow } from 'zustand/shallow';
const list = useDictStore(
  (s) => s.dicts.filter((d) => d.type === 'status'),
  shallow,
);

要么把派生逻辑放到 store 里,用 useMemo 缓存 selector:

// 把过滤结果做成一个稳定引用的 selector
const statusDictSelector = (s: DictState) =>
  s.dicts.filter((d) => d.type === 'status');

我最后统一规定:selector 返回对象或数组时,必须加 shallow,code review 卡这一条。

坑 2:Context 残留导致状态双写

迁移是渐进的,有一段时间两套并存。某个组件同时用了 Context 的 user 和 Zustand 的 user,改了一边另一边没同步,出现"改了名字头像没变"的诡异 bug。教训是:一个状态字段只能有一个 source of truth。我按 store 为单位迁移,迁完一个 store 就删掉对应的 Context 字段,不做字段级混用。

坑 3:持久化配置

用户信息和主题需要持久化,用了 persist 中间件:

import { persist, createJSONStorage } from 'zustand/middleware';

export const useAppStore = create(
  persist(
    (set) => ({
      theme: 'light',
      collapsed: false,
      setTheme: (theme: string) => set({ theme }),
      toggleCollapsed: () => set((s) => ({ collapsed: !s.collapsed })),
    }),
    {
      name: 'app-storage',
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ theme: state.theme }), // 只持久化 theme
    },
  ),
);

partialize 很关键,不然 collapsed 这种 UI 状态也被持久化,用户下次进来侧边栏状态很怪。默认持久化 key 是 app-storage,存的是 JSON,注意别把大对象塞进去,localStorage 有 5MB 限制。

优化:批量更新

多个 set 连续调用会触发多次订阅通知。Zustand 的 set 是同步的,需要合并时手动组织:

// 一次 set 更新多个字段,只通知一次
set({ userInfo: info, token, permissions });

React 18 的自动批处理对 Zustand 外部 store 也生效,但前提是更新发生在 React 事件里。如果在 setTimeout 或 Promise 里连续 set,建议合并成一次。

六、效果数据

迁移用了两周,分四个 store 逐步切换。对比数据(Chrome 118,MacBook Pro M1,React DevTools Profiler 采样 100 次取平均):

指标 Context 方案 Zustand 方案 变化
输入框单次按键渲染耗时 118ms 18ms ↓ 85%
单次按键重渲染组件数 43 5 ↓ 88%
首屏 TTI 2.8s 2.1s ↓ 25%
全局状态相关代码行数 约 620 行 约 380 行 ↓ 39%
包体积增量 0 +1.2KB gzip +1.2KB

重渲染组件数从 43 降到 5 是最直接的收益。剩下那 5 个是真正依赖该状态的组件,属于合理渲染。

七、总结

几点真实感受:

  1. Context 不是性能问题的原罪,滥用才是。低频、子树范围的状态继续用 Context 完全没问题,别为了迁移而迁移。
  2. Zustand 的核心价值是细粒度订阅。selector + shallow 用对了,性能提升立竿见影;用错了(返回新对象不加 shallow),反而比 Context 还容易出 bug。
  3. 迁移按 store 为单位,别按字段。状态源唯一,是避免双写 bug 的铁律。
  4. 选型别只看热度。Redux Toolkit 生态更全,但对我们的场景太重;Jotai 原子化很好,但我们的状态是集中式的。Zustand 的"够用且轻"才是它赢的原因。

如果你们项目全局状态字段超过 20 个、已经出现输入卡顿或无关组件频繁重渲染,可以认真评估迁移。但如果只有三五个全局字段,Context 挺好,别折腾。