一、问题背景:Context 不是不能用,是撑不住了
我们这个项目是一个数据看板类的后台系统,React 18.2 + TypeScript 5.3 + Vite 5.0,页面大概 40 多个,全局状态主要包括:当前用户信息、权限列表、主题配置、全局筛选条件(时间范围、业务线、区域)、以及若干弹窗的开关状态。
早期项目规模小,状态管理方案很朴素:
- 父子组件之间用 Props 传;
- 跨层级用 Context,一个
AppContext塞了用户、主题、筛选条件; - 再深一点的用 Context 嵌套 Provider 解决。
这套方案在 10 个页面以内是没问题的。但项目长到 40 多个页面之后,问题开始集中爆发:
- Context 的"全量广播"特性。只要 Provider 的 value 变了,所有
useContext的消费者都会重新渲染,哪怕它只关心 value 里的某一个字段。我们的AppContextvalue 是一个大对象,筛选条件一变,侧边栏、面包屑、图表容器全部重渲染。 - Props 穿透严重。有个页面从顶层到叶子组件传了 6 层 props,中间层组件根本不用这些数据,但每次上层变化都要跟着渲染。
- 状态逻辑分散。筛选条件的更新逻辑散落在 5 个组件里,有的用
useState+useEffect同步,有的直接改 Context,出现了两次"筛选条件和列表不一致"的线上 bug。
我们用 React DevTools Profiler 抓了一次典型交互:点击"近7天"筛选按钮,commit 阶段有 37 个组件重新渲染,其中真正需要更新的只有 4 个(按钮本身、列表、图表、统计卡片)。单次交互的 scripting 时间从理想的 16ms 涨到 90ms 左右,低端机上能明显感觉到卡顿。
到这一步,结论很明确:不是 Context 有错,是我们的用法已经超出了它适合的规模。该上状态管理库了。
二、环境与版本
先把环境交代清楚,避免读者对不上号:
- React 18.2.0,React DOM 18.2.0
- TypeScript 5.3.3
- Vite 5.0.10
- 原方案:React Context + useState/useReducer
- 候选库:
- Redux Toolkit 2.0.1 + react-redux 9.1.0
- Zustand 4.4.7
- Jotai 2.6.0
- 性能测量工具:React DevTools Profiler(React 18.2 对应版本)、Chrome Performance 面板
三、方案设计:为什么最后选了 Zustand
我们对比了四个方案,评估维度是:迁移成本、重渲染控制粒度、样板代码量、团队上手难度、生态成熟度。
| 维度 | Context | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|---|
| 重渲染粒度 | 全量广播 | 靠 selector | selector 精确订阅 | 原子级 |
| 样板代码 | 少 | 多(slice/store/provider) | 很少 | 少 |
| 迁移成本 | - | 高 | 低 | 中 |
| 学习曲线 | 低 | 中高 | 低 | 中 |
| DevTools | 无 | 极好 | 好(devtools 中间件) | 好 |
| 适合场景 | 小规模 | 大型团队协作 | 中小型、渐进迁移 | 细粒度派生状态多 |
决策过程:
- Redux Toolkit 能力没问题,但我们团队 8 个人,真正熟悉 Redux 范式的只有 2 个,迁移 40 个页面的成本太高,而且大量样板代码会让 review 变慢。它的优势(时间旅行调试、严格的 action 规范)对我们这个规模来说收益不明显。
- Jotai 的原子模型很优雅,但我们的状态大多是"块状"的(用户、筛选、UI 状态),拆成原子反而增加心智负担,而且从 Context 迁移过来需要重新设计状态结构。
- Zustand 胜在三点:第一,API 极简,
create一个 store 就能用,不需要 Provider 包裹;第二,selector 订阅天然解决重渲染问题;第三,可以渐进迁移——新代码用 store,老代码继续用 Context,两者共存不冲突。
最终选 Zustand 4.4.7。下面说具体怎么迁的。
四、核心实现:Store 拆分与渐进迁移
4.1 Store 结构设计
我们没有搞一个大 store,而是按领域拆成三个:
useUserStore:用户信息、权限、tokenuseFilterStore:全局筛选条件(时间范围、业务线、区域)useUIStore:主题、侧边栏折叠、全局弹窗状态
以筛选条件为例,原来的 Context 是这样的:
// 迁移前:AppContext.tsx
interface AppContextValue {
user: UserInfo;
theme: 'light' | 'dark';
filters: Filters;
setFilters: (f: Partial) => void;
sidebarCollapsed: boolean;
toggleSidebar: () => void;
}
const AppContext = createContext(null);
export function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(defaultUser);
const [theme, setTheme] = useState('light');
const [filters, setFiltersState] = useState(defaultFilters);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const setFilters = useCallback((f: Partial) => {
setFiltersState(prev => ({ ...prev, ...f }));
}, []);
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(v => !v);
}, []);
const value = useMemo(
() => ({ user, theme, filters, setFilters, sidebarCollapsed, toggleSidebar }),
[user, theme, filters, setFilters, sidebarCollapsed, toggleSidebar]
);
return {children};
}
问题很明显:value 里任何字段变化,所有消费者全量重渲染。
迁移后,用 Zustand 拆成独立 store。以筛选 store 为例:
// stores/filterStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
export interface Filters {
timeRange: '7d' | '30d' | '90d' | 'custom';
startDate?: string;
endDate?: string;
businessLine: string;
region: string;
}
interface FilterState {
filters: Filters;
setFilters: (patch: Partial) => void;
resetFilters: () => void;
}
const defaultFilters: Filters = {
timeRange: '7d',
businessLine: 'all',
region: 'all',
};
export const useFilterStore = create()(
devtools(
(set) => ({
filters: defaultFilters,
setFilters: (patch) =>
set(
(state) => ({ filters: { ...state.filters, ...patch } }),
false,
'filter/setFilters'
),
resetFilters: () =>
set({ filters: defaultFilters }, false, 'filter/reset'),
}),
{ name: 'FilterStore', enabled: import.meta.env.DEV }
)
);
这里有几个刻意的设计点:
- 用
devtools中间件,第三个参数传 action name,Redux DevTools 里能看到filter/setFilters这样的动作名,调试体验接近 Redux。 enabled: import.meta.env.DEV,生产环境关掉 devtools,避免额外开销。set的第二个参数传false,表示不替换整个 state(默认就是 merge,这里显式写出来是团队约定,避免有人误改)。
4.2 组件中的订阅方式
迁移后,组件不再 useContext,而是按需 selector 订阅:
// 迁移前
const { filters, setFilters } = useContext(AppContext)!;
// 迁移后
const timeRange = useFilterStore((s) => s.filters.timeRange);
const setFilters = useFilterStore((s) => s.setFilters);
关键点:只订阅需要的字段。setFilters 是稳定引用(Zustand 里函数不会变),所以单独订阅它不会引起额外渲染。而如果写成 const { filters, setFilters } = useFilterStore(),那就退化成全量订阅,等于白迁——这是最常见的坑,后面细说。
对于需要一次取多个字段的场景,用 useShallow(Zustand 4.4 提供的浅比较 hook):
import { useShallow } from 'zustand/react/shallow';
const { businessLine, region } = useFilterStore(
useShallow((s) => ({ businessLine: s.filters.businessLine, region: s.filters.region }))
);
4.3 迁移步骤
我们按这个顺序推进,两周内完成:
- 第一周:搭骨架。新建
stores/目录,把三个 store 写好,先不删 Context,让两者共存。 - 第二周前半:改消费端。用 codemod 思路批量替换
useContext(AppContext)为对应的 selector 订阅,人工 review 每一处订阅粒度。这一步大概改了 60 多个文件。 - 第二周后半:清理。确认没有残留引用后,删除
AppContext和AppProvider,移除main.tsx里的 Provider 包裹。 - 回归测试:重点测筛选条件联动、权限控制、主题切换这三条链路。
有一点值得说:渐进迁移的关键是 Context 和 Zustand 可以共存。我们第一周上线时,新 store 已经可用,老页面还在用 Context,没有任何冲突。这让我们可以按页面分批切换,而不是憋一个大 PR。
五、踩坑与优化
坑 1:全量订阅导致迁移后性能没变化
最开始有两三个页面,开发同学图省事写成了:
const store = useFilterStore(); // 错误:订阅整个 store
结果这些页面依然在筛选变化时全量重渲染。我们在 code review 里定了个规矩:禁止无 selector 调用 store hook,并在 ESLint 里加了一条自定义规则(用 no-restricted-syntax 匹配 CallExpression[callee.name=/^use.*Store$/][arguments.length=0])来拦截。
坑 2:selector 返回新对象导致无限渲染
这个坑很经典:
// 错误:每次渲染都返回新对象,触发无限循环
const { a, b } = useFilterStore((s) => ({ a: s.filters.businessLine, b: s.filters.region }));
Zustand 默认用 Object.is 比较 selector 返回值,每次返回新对象必然不相等。解法就是上面说的 useShallow。这个问题在 React 18 StrictMode 下会表现为"Maximum update depth exceeded",比较好排查,但第一次遇到会懵。
坑 3:异步 action 里的状态读取
我们有个"刷新列表"的逻辑需要在 action 里读当前 filters。Zustand 的 set 回调里能拿到最新 state,但如果在 set 外部读,要用 get():
// stores/filterStore.ts 补充
export const useFilterStore = create()((set, get) => ({
// ...
refreshWithCurrentFilters: async () => {
const { filters } = get(); // 注意用 get() 而不是闭包里的 state
await fetchList(filters);
},
}));
如果用组件里闭包捕获的 filters,会遇到 stale closure 问题,拿到的可能是旧值。
优化:selector 粒度再细化
迁移完成后我们做了一轮二次优化。比如列表组件原来订阅 s.filters 整个对象,改成只订阅 s.filters.timeRange 和 s.filters.businessLine,这样改 region 时列表不会重渲染(虽然业务上 region 也会影响列表,但改成了由列表内部按需请求)。这一步把筛选交互的重渲染组件数从 12 个压到 5 个。
六、效果数据
用 React DevTools Profiler 在同一台机器(MacBook Pro M1, 16G)、同一份 mock 数据下对比,测试场景是"点击近7天筛选按钮":
| 指标 | 迁移前(Context) | 迁移后(Zustand) | 变化 |
|---|---|---|---|
| 重渲染组件数 | 37 | 6 | -83.8% |
| commit 阶段耗时 | 42ms | 9ms | -78.6% |
| 单次交互总耗时(scripting) | 90ms | 18ms | -80% |
| 首屏 JS 体积(gzip) | 基准 | +2.3KB | 基本无感 |
| 代码行数(状态相关) | 约 420 行 | 约 260 行 | -38% |
补充几个定性收益:
- 状态更新逻辑集中到 store,之前"筛选条件和列表不一致"的 bug 没有再出现;
- 新页面接状态几乎零成本,
useFilterStore((s) => s.filters.xxx)一行搞定,不用再改 Provider; - Redux DevTools 里能看到完整的状态变更历史,排查线上问题时能复现状态流转。
也有代价:团队需要理解 selector 和浅比较的概念,前两周有 2 次因全量订阅导致的性能回退,靠 review 和 ESLint 规则兜住了。
七、总结
这次迁移的核心结论有三条:
- Context 适合低频、小范围的全局状态(主题、用户信息这种一年变不了几次的),一旦状态更新频繁且消费者多,它的全量广播就是性能瓶颈。
- 迁移要渐进。Context 和 Zustand 共存不冲突,按页面分批切,比憋大 PR 稳得多。我们两周完成,中途没有停过需求迭代。
- 选型看团队。Zustand 不是"最好"的状态库,但它在我们这个 8 人团队、中型项目、需要快速迁移的约束下,是性价比最高的选择。如果团队本来就重度使用 Redux 生态,RTK 也完全合理。
如果你们项目也在用 Context 硬扛全局状态,且 Profiler 里已经看到大面积重渲染,可以按这个路径试一次。迁移成本比想象中低,收益比想象中直接。