一、问题背景:Context 不是银弹
先说结论:Context 适合低频、粗粒度的全局数据(主题、i18n、用户信息),但绝不适合高频、细粒度的业务状态。
我们这个项目是一个数据看板类中台,核心页面结构大致是这样:
App
└─ Layout
└─ DashboardPage
├─ FilterBar(筛选条件)
├─ ChartPanel
│ ├─ LineChart
│ ├─ BarChart
│ └─ PieChart
└─ DataTable
└─ TableRow * N
状态管理用的是最朴素的方式:
// 一个巨大的 Context
const AppContext = createContext(null);
function AppProvider({ children }) {
const [filters, setFilters] = useState({});
const [userInfo, setUserInfo] = useState({});
const [theme, setTheme] = useState('light');
const [tableData, setTableData] = useState([]);
// ...还有 6 个 useState
const value = { filters, setFilters, userInfo, setUserInfo, theme, setTheme, tableData, setTableData };
return {children};
}
问题在哪?value 每次渲染都是新对象,任何一个字段变化,所有 useContext(AppContext) 的组件都会重渲染。我们用 React DevTools Profiler 抓了一次"修改筛选条件"的操作:
- 提交阶段耗时:32ms(平均帧)
- 重渲染组件数:43 个
- 其中真正依赖
filters的:只有 6 个
剩下的 37 个组件纯属陪跑。图表组件在低端机上直接肉眼可见卡顿。
我们也试过 useMemo 拆 value、拆多个 Context,但维护成本陡增,而且解决不了"跨层级订阅单个字段"的问题。
二、环境与版本
- React 18.2.0(Concurrent 特性已开启)
- TypeScript 5.3.3
- Vite 5.0.10
- 目标状态库:Zustand 4.5.2
- 对比参考:Redux Toolkit 2.2.1、Jotai 2.6.0
- 测试设备:MacBook Pro M1 / Chrome 122;移动端模拟:Redmi Note 11、Chrome 122
三、方案对比:为什么最后选了 Zustand
我把候选方案列了个表,按我们最关心的几个维度打分(满分 5):
| 维度 | Context | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|---|
| 学习成本 | 5 | 2 | 5 | 3 |
| 细粒度订阅 | 1 | 4 | 5 | 5 |
| 迁移成本(从 Context) | 5 | 2 | 4 | 2 |
| 包体积 (gzip) | 0 | ~13KB | ~1.2KB | ~4KB |
| DevTools | 无 | 强 | 中(有 devtools 中间件) | 中 |
| 是否需要 Provider | 是 | 是 | 否 | 是 |
| 异步/副作用 | 手动 | createAsyncThunk | 直接写在 store | 手动 |
我们的判断:
- Redux Toolkit 太重了,一个中台项目引入 slice、thunk、Provider 一整套,迁移周期至少两周,收益和成本不成正比。
- Jotai 的原子化模型很优雅,但要从 Context 迁移,等于把原本按"模块"组织的状态全部打散成 atom,重构量比 Zustand 大得多。
- Zustand 最贴合我们的场景:一个 store 就是一个 hook,按 selector 订阅,不需要 Provider,迁移时几乎可以一对一替换 Context 的字段。
最终选 Zustand 4.5.2。
四、核心实现:从 Context 到 Zustand
4.1 原 Context 结构
// store/AppContext.tsx(迁移前)
export const AppContext = createContext(null);
export function AppProvider({ children }) {
const [filters, setFilters] = useState({});
const [tableData, setTableData] = useState([]);
const [loading, setLoading] = useState(false);
// ...
return {children};
}
export function useApp() {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('useApp must be used within AppProvider');
return ctx;
}
4.2 迁移到 Zustand
// store/useAppStore.ts(迁移后)
import { create } from 'zustand';
import { devtools, subscribeWithSelector } from 'zustand/middleware';
interface Filters {
dateRange: [string, string];
channel: string;
}
interface AppState {
filters: Filters;
tableData: Row[];
loading: boolean;
setFilters: (f: Partial) => void;
fetchTableData: () => Promise;
}
export const useAppStore = create()(
devtools(
subscribeWithSelector((set, get) => ({
filters: { dateRange: ['', ''], channel: 'all' },
tableData: [],
loading: false,
setFilters: (f) =>
set((s) => ({ filters: { ...s.filters, ...f } }), false, 'setFilters'),
fetchTableData: async () => {
set({ loading: true }, false, 'fetchTableData/start');
const data = await api.getTable(get().filters);
set({ tableData: data, loading: false }, false, 'fetchTableData/done');
},
})),
{ name: 'AppStore' }
)
);
4.3 组件里怎么用
之前所有 useApp() 的地方都要改。关键是用 selector 精确订阅:
// 迁移前:任何字段变化都会重渲染
function FilterBar() {
const { filters, setFilters } = useApp();
// ...
}
// 迁移后:只订阅自己关心的字段
function FilterBar() {
const filters = useAppStore((s) => s.filters);
const setFilters = useAppStore((s) => s.setFilters);
// ...
}
// 图表组件:只订阅 channel 一个字段
function LineChart() {
const channel = useAppStore((s) => s.filters.channel);
// filters.dateRange 变化不会触发这个组件重渲染
}
这一步是整个迁移里性价比最高的:不用改任何业务逻辑,只改订阅方式,重渲染数量立刻从 43 降到 8。
4.4 迁移步骤
我按下面顺序做的,供参考:
- 安装依赖:
pnpm add zustand@4.5.2 - 建 store 文件,把 Context 里的 state + setter 一一搬过去,保持字段名一致
- 保留 Context 做兼容层:
AppProvider内部改为读 Zustand,useApp()返回 Zustand 的 state,让旧组件先能跑 - 逐个页面替换
useApp()为useAppStore(selector),替换完一个页面就把该页面从 Context 依赖里移除 - 删除 Context 和 Provider,清理 import
- 跑 Profiler 对比数据
第 3 步的兼容层大概长这样:
// 过渡期兼容层
export function useApp() {
return useAppStore(); // 返回整个 state,等价于旧 Context
}
这样旧代码不用一次性全改,可以灰度迁移。
五、踩坑与优化
坑 1:selector 返回新对象导致无限重渲染
// ❌ 每次都返回新对象,引用不等,触发重渲染
const { channel, dateRange } = useAppStore((s) => ({
channel: s.filters.channel,
dateRange: s.filters.dateRange,
}));
Zustand 4 默认用 Object.is 比较 selector 结果。返回新对象就会无限循环。
解法一:拆成多个 selector。
const channel = useAppStore((s) => s.filters.channel);
const dateRange = useAppStore((s) => s.filters.dateRange);
解法二:用 useShallow。
import { useShallow } from 'zustand/react/shallow';
const { channel, dateRange } = useAppStore(
useShallow((s) => ({ channel: s.filters.channel, dateRange: s.filters.dateRange }))
);
我一开始全用解法一,后来发现有些组件确实需要一次性拿 5、6 个字段,拆太碎可读性差,就改成 useShallow。注意:useShallow 是浅比较,字段值本身是对象/数组时仍然要注意引用变化。
坑 2:异步 action 里读到的 state 是旧的
fetchTableData 里如果用闭包里的 filters,会拿到旧值。必须用 get():
fetchTableData: async () => {
const { filters } = get(); // ✅ 拿最新值
const data = await api.getTable(filters);
set({ tableData: data });
}
坑 3:DevTools 里 action 名字乱
set 的第三个参数是 action 名,不填的话 Redux DevTools 里全是 setState,根本没法调试。我统一约定成 模块/动作 的格式,比如 fetchTableData/start。
优化:订阅粒度再往下切
图表组件的 props 里有个 data 数组,之前是从 tableData 里 filter 出来的。改成在 store 里存派生状态,或者在组件里用 selector 直接算:
const lineData = useAppStore(
useShallow((s) => s.tableData.filter((r) => r.channel === s.filters.channel))
);
这里要注意:selector 里的计算如果很重,要配合 useMemo 或把派生逻辑放 store。 我们的 filter 不重,就没额外处理。
六、效果数据
迁移前后用 React DevTools Profiler 各抓了 5 次"修改筛选条件"的操作,取平均:
| 指标 | 迁移前 (Context) | 迁移后 (Zustand) | 变化 |
|---|---|---|---|
| 单次交互重渲染组件数 | 43 | 8 | -81% |
| 提交阶段平均耗时 | 32ms | 9ms | -72% |
| 首屏可交互时间 (TTI) | 1.8s | 1.1s | -39% |
| 主 bundle gzip 体积 | 基线 | +1.2KB | +1.2KB |
| Redmi Note 11 上筛选卡顿 | 明显掉帧 | 基本流畅 | — |
首屏 TTI 的下降有点超预期,后来分析是因为删掉了 AppProvider 那一层,整个组件树的 Context 订阅开销没了,加上原 Context 里那个巨大的 value 对象每次都新建,导致子树大量 bailout 失败。
七、总结
几点体会:
- Context 不是状态管理库,它的定位是依赖注入。用它做高频业务状态,迟早要还债。
- 迁移不用一步到位。用兼容层让旧代码继续跑,按页面灰度替换,风险小很多。
- Zustand 的 selector 是核心。迁移时最容易犯的错就是
useAppStore()不带 selector,那和 Context 没区别,等于白迁。 - 数据要自己测。别信"感觉快了",用 Profiler 抓数字,写进周报里,说服力完全不一样。
如果你们项目也是 Context + Props 满天飞,且已经出现交互卡顿,我建议先花半天用 Profiler 抓一次数据。如果重渲染组件数明显大于实际依赖组件数,那 Zustand 值得一试,迁移成本比想象中低。