一、问题背景:Context 不是不能用,是撑不住了

我们这个项目是一个数据看板类的后台系统,React 18.2 + TypeScript 5.3 + Vite 5.0,页面大概 40 多个,全局状态主要包括:当前用户信息、权限列表、主题配置、全局筛选条件(时间范围、业务线、区域)、以及若干弹窗的开关状态。

早期项目规模小,状态管理方案很朴素:

  • 父子组件之间用 Props 传;
  • 跨层级用 Context,一个 AppContext 塞了用户、主题、筛选条件;
  • 再深一点的用 Context 嵌套 Provider 解决。

这套方案在 10 个页面以内是没问题的。但项目长到 40 多个页面之后,问题开始集中爆发:

  1. Context 的"全量广播"特性。只要 Provider 的 value 变了,所有 useContext 的消费者都会重新渲染,哪怕它只关心 value 里的某一个字段。我们的 AppContext value 是一个大对象,筛选条件一变,侧边栏、面包屑、图表容器全部重渲染。
  2. Props 穿透严重。有个页面从顶层到叶子组件传了 6 层 props,中间层组件根本不用这些数据,但每次上层变化都要跟着渲染。
  3. 状态逻辑分散。筛选条件的更新逻辑散落在 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:用户信息、权限、token
  • useFilterStore:全局筛选条件(时间范围、业务线、区域)
  • 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 }
  )
);

这里有几个刻意的设计点:

  1. 用 devtools 中间件,第三个参数传 action name,Redux DevTools 里能看到 filter/setFilters 这样的动作名,调试体验接近 Redux。
  2. enabled: import.meta.env.DEV,生产环境关掉 devtools,避免额外开销。
  3. 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 迁移步骤

我们按这个顺序推进,两周内完成:

  1. 第一周:搭骨架。新建 stores/ 目录,把三个 store 写好,先不删 Context,让两者共存。
  2. 第二周前半:改消费端。用 codemod 思路批量替换 useContext(AppContext) 为对应的 selector 订阅,人工 review 每一处订阅粒度。这一步大概改了 60 多个文件。
  3. 第二周后半:清理。确认没有残留引用后,删除 AppContext 和 AppProvider,移除 main.tsx 里的 Provider 包裹。
  4. 回归测试:重点测筛选条件联动、权限控制、主题切换这三条链路。

有一点值得说:渐进迁移的关键是 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 规则兜住了。

七、总结

这次迁移的核心结论有三条:

  1. Context 适合低频、小范围的全局状态(主题、用户信息这种一年变不了几次的),一旦状态更新频繁且消费者多,它的全量广播就是性能瓶颈。
  2. 迁移要渐进。Context 和 Zustand 共存不冲突,按页面分批切,比憋大 PR 稳得多。我们两周完成,中途没有停过需求迭代。
  3. 选型看团队。Zustand 不是"最好"的状态库,但它在我们这个 8 人团队、中型项目、需要快速迁移的约束下,是性价比最高的选择。如果团队本来就重度使用 Redux 生态,RTK 也完全合理。

如果你们项目也在用 Context 硬扛全局状态,且 Profiler 里已经看到大面积重渲染,可以按这个路径试一次。迁移成本比想象中低,收益比想象中直接。