一、问题背景:Context 不是不能扛,是扛不住“高频小状态”
我们做的是一个中后台数据看板,React 18.2 + TypeScript 5.2 + Vite 4.5。早期为了快,全局状态用两套东西:一层是 App 级别的 ConfigContext(主题、权限、字典),另一层是页面级的 FilterContext(时间范围、筛选条件、分页)。组件之间靠 Props 往下传,最深的地方传了 5 层。
真正出问题是在页面组件数超过 120 个之后。典型场景是顶部筛选栏切换“近 7 天”,这个动作会更新 FilterContext 的 value,而 Provider 下的所有消费者都会重渲染。我们用 React DevTools Profiler 录了一次:一次筛选变更触发了 43 个组件 render,其中真正需要更新的只有 9 个,提交阶段耗时 180ms 左右,低端机上体感明显卡顿。
更麻烦的是 Props 钻取带来的维护成本。一个 useFilter() 的返回值被拆成 6 个 Props 往下传,中间层组件根本不用这些值,但每次 Filter 变化它们也跟着 render。我们试过 React.memo 包中间层,但 Props 里有对象字面量,memo 基本失效,最后变成到处写 useMemo,代码可读性急剧下降。
结论很明确:Context 适合“低频、全局、整体消费”的状态(主题、用户信息),不适合“高频、局部、细粒度”的状态(筛选条件、表单草稿)。于是开始评估状态管理库。
二、环境与版本:先把基线钉死
迁移前把关键依赖固定下来,避免“换库顺便升级框架”导致问题归因困难:
- react 18.2.0 / react-dom 18.2.0
- typescript 5.2.2
- vite 4.5.0
- zustand 4.5.2(最终选择)
- jotai 2.6.0、valtio 1.13.2(对比试用)
- @reduxjs/toolkit 2.0.1(团队已有 Redux 经验,作为对照)
基线性能数据(Chrome 120,MacBook Pro M1,4x CPU 降速模拟中端机):
- 筛选变更触发的 render 组件数:43
- Profiler 提交阶段耗时:180ms
- 首屏可交互时间(TTI):2.1s
- 包体(gzip 后,业务 chunk):312KB
这些数字后面会拿来对比,所以先记下来。
三、方案设计:为什么最后选 Zustand
我们对比了四个维度:重渲染粒度、样板代码量、TypeScript 体验、迁移成本。
Context 的问题是粒度粗,Provider value 一变全量消费者更新。Redux Toolkit 粒度可以靠 useSelector 控制,但样板代码多,一个 slice 要写 action、reducer、store 注册,团队里前端水平参差,容易写出一堆重复代码。Jotai 的原子模型很优雅,粒度最细,但我们的状态是“按页面聚合”的,拆原子会把一个 Filter 对象拆成十几个 atom,心智负担反而变大。Valtio 的 proxy 方案写起来最舒服,但团队对“可变数据”有心理障碍,且 devtools 支持不如 Zustand 成熟。
Zustand 胜出的点很实际:store 就是一个 hook,selector 天然支持细粒度订阅,shallow 比较可以避免对象引用变化导致的误更新,而且不需要 Provider 包裹,迁移时可以一个模块一个模块地换,不用一次性改根节点。
Store 设计上我们定了两条规则:
- 按“变更频率”拆 store,不按“业务模块”拆。FilterStore(高频)、DictStore(低频,初始化后基本不变)、UserStore(低频)分开,避免高频更新污染低频数据。
- 所有派生数据用 selector 计算,不往 store 里塞冗余字段。比如“是否有激活筛选”用
state => state.startDate !== null || state.endDate !== null,而不是存一个hasFilter布尔值。
四、核心实现:从 Context 到 Zustand 的分阶段迁移
迁移没有一刀切,分三步走:先建 store 并行运行,再逐组件替换消费方式,最后删掉旧 Context。
第一步,建 FilterStore,保持和原 Context value 同构,降低替换成本:
// stores/filterStore.ts
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
export interface FilterState {
startDate: string | null;
endDate: string | null;
status: 'all' | 'active' | 'inactive';
page: number;
pageSize: number;
setDateRange: (start: string | null, end: string | null) => void;
setStatus: (status: FilterState['status']) => void;
setPage: (page: number) => void;
reset: () => void;
}
const initialState = {
startDate: null,
endDate: null,
status: 'all' as const,
page: 1,
pageSize: 20,
};
export const useFilterStore = create((set) => ({
...initialState,
setDateRange: (startDate, endDate) => set({ startDate, endDate, page: 1 }),
setStatus: (status) => set({ status, page: 1 }),
setPage: (page) => set({ page }),
reset: () => set(initialState),
}));
// 派生 selector:只关心布尔结果,避免组件订阅整个对象
export const useHasActiveFilter = () =>
useFilterStore(
(s) => s.startDate !== null || s.endDate !== null || s.status !== 'all'
);
// 需要多个字段时用 shallow,避免对象字面量每次新建引用导致重渲染
export const useFilterSummary = () =>
useFilterStore(
(s) => ({ startDate: s.startDate, endDate: s.endDate, status: s.status }),
shallow
);
第二步,替换消费端。原来组件里是 const filter = useContext(FilterContext),现在改成按需订阅。这里有个关键点:不要图省事写 const store = useFilterStore(),那等于订阅整个 store,任何字段变化都会重渲染,等于把 Context 的问题原样搬过来。必须写 selector。
// components/FilterBar.tsx
import { useFilterStore } from '../stores/filterStore';
export function FilterBar() {
// 只订阅 action,action 引用稳定,不会触发额外渲染
const setDateRange = useFilterStore((s) => s.setDateRange);
const setStatus = useFilterStore((s) => s.setStatus);
const reset = useFilterStore((s) => s.reset);
return (
重置
);
}
// components/FilterSummary.tsx
import { useFilterSummary } from '../stores/filterStore';
export function FilterSummary() {
const { startDate, endDate, status } = useFilterSummary();
return (
{startDate ?? '不限'} ~ {endDate ?? '不限'} / {status}
);
}
第三步,处理异步数据和副作用。原来 Context 里有个 useEffect 在 filter 变化时拉列表数据,迁移后我们把“订阅变化 + 请求”放到一个自定义 hook 里,用 subscribeWithSelector 中间件精确监听需要的字段,避免 page 变化触发全量请求这种误伤:
// hooks/useTableData.ts
import { useEffect, useState } from 'react';
import { subscribeWithSelector } from 'zustand/middleware';
import { create } from 'zustand';
import { useFilterStore } from '../stores/filterStore';
export function useTableData() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const unsub = useFilterStore.subscribe(
(s) => ({ startDate: s.startDate, endDate: s.endDate, status: s.status, page: s.page }),
async (filter) => {
setLoading(true);
try {
const res = await fetchTable(filter);
setData(res.list);
} finally {
setLoading(false);
}
},
{ equalityFn: shallow, fireImmediately: true }
);
return unsub;
}, []);
return { data, loading };
}
注意 subscribeWithSelector 需要加到 store 创建时:create(subscribeWithSelector((set) => ({...})))。这个中间件在 4.5 版本是独立导出的,别漏了。
五、踩坑与优化:三个真实问题
坑一:selector 返回新对象导致无限重渲染。 最开始写了 useFilterStore(s => ({ a: s.a, b: s.b })),没加 shallow,每次 store 更新都生成新对象引用,组件疯狂 render,React 直接报 "Maximum update depth exceeded"。解决办法就是上面代码里的 shallow,或者拆成两个独立 selector。我们后来定了个团队规范:selector 返回对象必须配 shallow,Code Review 卡这一条。
坑二:getState() 在渲染期间读值导致不响应更新。 有同事为了“避免订阅”,在组件里写 const page = useFilterStore.getState().page,结果 page 变了组件不更新。getState 只适合在事件回调、异步逻辑里读快照,渲染期间必须用 hook 订阅。
坑三:devtools 序列化大对象拖慢开发环境。 开了 devtools 中间件后,每次 set 都会序列化整个 state,我们的列表数据一度也塞进了 store,开发环境每次操作卡 200ms+。后来把“服务端数据”全部移出 store(用 React Query 管),store 只留 UI 状态,问题消失。这也印证了那条老话:状态管理库管的是客户端状态,不是缓存。
优化方面还做了两件事:一是给高频更新的 store 加了 subscribeWithSelector,让副作用订阅精确到字段;二是把 pageSize 这类几乎不变的配置从 store 挪到常量文件,减少 state 体积。
六、效果数据与总结
迁移完成后用同样的 Profiler 场景复测(Chrome 120,4x CPU 降速):
- 筛选变更触发的 render 组件数:43 → 6
- Profiler 提交阶段耗时:180ms → 45ms
- 首屏 TTI:2.1s → 1.9s(store 无 Provider 包裹,少了一层树)
- 业务 chunk gzip:312KB → 318KB(zustand 4.5 约 1.2KB gzip,增加主要来自新抽的 hook 文件)
代码层面,删掉了 3 个 Context 文件、约 400 行 Props 传递代码,useMemo 的数量从 27 处降到 11 处。维护成本的变化更明显:新增一个筛选字段,原来要改 Context 类型、Provider、消费组件三层,现在只改 store 和对应组件。
总结几条可复用的经验:第一,Context 不是原罪,低频全局状态继续用 Context 没问题,我们保留了 ThemeContext;第二,迁移要并行运行、逐组件替换,别想着一次性重构;第三,selector 的粒度决定性能上限,返回对象必须配 shallow;第四,服务端数据和 UI 状态分开放,store 只装后者。如果你们项目也处在“Context 还能跑但越来越卡”的阶段,建议先用 Profiler 量一下重渲染组件数,超过 20 个且大部分是无关更新,就可以考虑动手了。