一、问题背景: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 个是真正依赖该状态的组件,属于合理渲染。
七、总结
几点真实感受:
- Context 不是性能问题的原罪,滥用才是。低频、子树范围的状态继续用 Context 完全没问题,别为了迁移而迁移。
- Zustand 的核心价值是细粒度订阅。selector + shallow 用对了,性能提升立竿见影;用错了(返回新对象不加 shallow),反而比 Context 还容易出 bug。
- 迁移按 store 为单位,别按字段。状态源唯一,是避免双写 bug 的铁律。
- 选型别只看热度。Redux Toolkit 生态更全,但对我们的场景太重;Jotai 原子化很好,但我们的状态是集中式的。Zustand 的"够用且轻"才是它赢的原因。
如果你们项目全局状态字段超过 20 个、已经出现输入卡顿或无关组件频繁重渲染,可以认真评估迁移。但如果只有三五个全局字段,Context 挺好,别折腾。