一、问题背景: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 迁移步骤

我按下面顺序做的,供参考:

  1. 安装依赖:pnpm add zustand@4.5.2
  2. 建 store 文件,把 Context 里的 state + setter 一一搬过去,保持字段名一致
  3. 保留 Context 做兼容层:AppProvider 内部改为读 Zustand,useApp() 返回 Zustand 的 state,让旧组件先能跑
  4. 逐个页面替换 useApp() 为 useAppStore(selector),替换完一个页面就把该页面从 Context 依赖里移除
  5. 删除 Context 和 Provider,清理 import
  6. 跑 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 失败。

七、总结

几点体会:

  1. Context 不是状态管理库,它的定位是依赖注入。用它做高频业务状态,迟早要还债。
  2. 迁移不用一步到位。用兼容层让旧代码继续跑,按页面灰度替换,风险小很多。
  3. Zustand 的 selector 是核心。迁移时最容易犯的错就是 useAppStore() 不带 selector,那和 Context 没区别,等于白迁。
  4. 数据要自己测。别信"感觉快了",用 Profiler 抓数字,写进周报里,说服力完全不一样。

如果你们项目也是 Context + Props 满天飞,且已经出现交互卡顿,我建议先花半天用 Profiler 抓一次数据。如果重渲染组件数明显大于实际依赖组件数,那 Zustand 值得一试,迁移成本比想象中低。