一、问题背景: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 设计上我们定了两条规则:

  1. 按“变更频率”拆 store,不按“业务模块”拆。FilterStore(高频)、DictStore(低频,初始化后基本不变)、UserStore(低频)分开,避免高频更新污染低频数据。
  2. 所有派生数据用 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 个且大部分是无关更新,就可以考虑动手了。