一、问题背景:Context 不是不能用,是不该这么用

先说清楚,我不是来黑 Context 的。Context 解决的是"跨层级传值"的问题,它本身设计得很好。问题在于我们把它当成了状态管理库来用。

这个项目是一个运营中台,2022 年立项,技术栈 React 18.2 + TypeScript 4.9 + Vite 4.1。最初团队只有 3 个人,为了"不引入额外依赖",全局状态用了最朴素的方式:

  • 用户信息、权限、主题:UserContext
  • 全局配置、字典数据:ConfigContext
  • 通知、弹窗队列:NoticeContext
  • 当前工作区、选中的租户:WorkspaceContext

局部状态就靠 Props 一层层传。最深的组件树传了 6 层,中间层组件被迫接收一堆自己根本用不到的 props,我们内部管这叫"props 隧道"。

到了 2023 年底,问题集中爆发:

  1. 重渲染失控。WorkspaceContext 的 value 是一个对象,每次 Provider 重新渲染都会创建新引用,所有 useContext 的消费者全部重渲染。我们做过一次 Profile,在一个订单筛选页面输入关键字时,单次按键触发了 213 次组件渲染。
  2. Props 隧道难以维护。改一个叶子组件的入参,要顺着链路改 4 个中间组件,TypeScript 报错能帮你找出来,但改起来很烦。
  3. 逻辑分散。同一个业务状态(比如"当前选中的仓库")在 3 个地方有各自的 useState,同步靠 useEffect,偶尔出现不一致的 bug。

真正让我下决心的是有一次用户反馈:在 SKU 搜索框里打字,光标会卡顿。我用 React DevTools Profiler 录了一段,输入 "SKU12345" 这 8 个字符,总渲染耗时 1.4s,平均每个字符 175ms。这不能忍。

二、环境与版本

先把环境交代清楚,方便对照:

react: 18.2.0
react-dom: 18.2.0
typescript: 4.9.5
vite: 4.1.1
zustand: 4.4.1

选型阶段我对比了四个方案,都是当时(2023 年 12 月)的稳定版本:

方案 版本 包体积(gzip) 学习成本 是否需 Provider 选择器粒度
继续用 Context + useMemo - 0 低 是 组件级
Redux Toolkit 1.9.7 ~13KB 中高 是 选择器
Jotai 2.6.0 ~4KB 中 是(可选) 原子级
Zustand 4.4.1 ~1.2KB 低 否 选择器

最终选 Zustand,理由很实际:

  • 不需要包 Provider,可以直接在组件外调用 store.getState(),这对我们一些工具函数和 axios 拦截器很友好;
  • 选择器机制天然解决重渲染问题,useStore(s => s.xxx) 只在选中值变化时重渲染;
  • 迁移成本低,API 就 create 一个函数,团队 3 个人半天就能上手;
  • 包体积小,对中台这种本来就不在乎几 KB 的项目其实是次要因素,但没坏处。

Jotai 其实也不错,原子化模型更细,但我们现有状态大多是"按业务域聚合"的,用 Zustand 的 slice 模式更贴合。Redux Toolkit 太重,样板代码多,团队之前用过,评价一般。

三、方案设计:按业务域拆分 store

迁移不是把 Context 原样搬过去,得先重新设计状态边界。我们按业务域拆了 5 个 store:

src/stores/
  userStore.ts       // 用户信息、权限、登录态
  configStore.ts     // 全局配置、字典
  workspaceStore.ts  // 工作区、租户、仓库选择
  noticeStore.ts     // 通知、弹窗队列
  orderFilterStore.ts // 订单筛选页局部状态(从 useState 提升)

原则有三条:

  1. 全局状态进 store,页面局部状态仍用 useState。不要把所有状态都塞进 store,那只是把 Context 的问题换个地方。
  2. 每个 store 只暴露最小粒度的选择器。避免 useStore() 不带选择器直接拿整个 state。
  3. 异步逻辑写在 store 的 action 里,替代原来散落在组件里的 useEffect + fetch。

四、核心实现

先看一个典型的迁移前后对比。原来 WorkspaceContext 大概是这样:

// 迁移前:WorkspaceContext.tsx
const WorkspaceContext = createContext(null);

export function WorkspaceProvider({ children }: { children: ReactNode }) {
  const [workspace, setWorkspace] = useState(null);
  const [tenantId, setTenantId] = useState('');
  const [warehouseId, setWarehouseId] = useState('');

  // 每次渲染都创建新对象,所有消费者重渲染
  const value = { workspace, setWorkspace, tenantId, setTenantId, warehouseId, setWarehouseId };

  return {children};
}

export function useWorkspace() {
  const ctx = useContext(WorkspaceContext);
  if (!ctx) throw new Error('useWorkspace must be used within WorkspaceProvider');
  return ctx;
}

迁移后,workspaceStore.ts:

// 迁移后:src/stores/workspaceStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { workspaceApi } from '@/api/workspace';

interface WorkspaceState {
  workspace: Workspace | null;
  tenantId: string;
  warehouseId: string;
  loading: boolean;
  setTenant: (id: string) => void;
  setWarehouse: (id: string) => void;
  fetchWorkspace: (id: string) => Promise;
}

export const useWorkspaceStore = create()(
  devtools(
    persist(
      (set, get) => ({
        workspace: null,
        tenantId: '',
        warehouseId: '',
        loading: false,

        setTenant: (id) => {
          // 切换租户时清空仓库选择,避免脏数据
          set({ tenantId: id, warehouseId: '' }, false, 'workspace/setTenant');
        },

        setWarehouse: (id) => set({ warehouseId: id }, false, 'workspace/setWarehouse'),

        fetchWorkspace: async (id) => {
          set({ loading: true }, false, 'workspace/fetchStart');
          try {
            const data = await workspaceApi.get(id);
            set({ workspace: data, loading: false }, false, 'workspace/fetchSuccess');
          } catch (e) {
            set({ loading: false }, false, 'workspace/fetchError');
            throw e;
          }
        },
      }),
      {
        name: 'workspace-storage',
        // 只持久化必要的字段,workspace 对象太大不存
        partialize: (state) => ({ tenantId: state.tenantId, warehouseId: state.warehouseId }),
      }
    ),
    { name: 'WorkspaceStore', enabled: import.meta.env.DEV }
  )
);

组件里这样用:

// 迁移后:组件内使用
import { useWorkspaceStore } from '@/stores/workspaceStore';
import { useShallow } from 'zustand/react/shallow';

function WarehouseSelector() {
  // 只订阅需要的字段,tenantId 变化不会触发这个组件重渲染
  const warehouseId = useWorkspaceStore((s) => s.warehouseId);
  const setWarehouse = useWorkspaceStore((s) => s.setWarehouse);

  return (

  );
}

// 需要多个字段时用 useShallow,避免对象引用每次都变
function WorkspaceHeader() {
  const { workspace, loading } = useWorkspaceStore(
    useShallow((s) => ({ workspace: s.workspace, loading: s.loading }))
  );
  // ...
}

这里有个关键点:如果要选多个字段,必须用 useShallow。直接 useStore(s => ({ a: s.a, b: s.b })) 每次都会返回新对象,等于没优化,甚至比 Context 更糟。这是迁移时最容易踩的坑,后面会细说。

再说异步 action 替代 useEffect 的例子。订单筛选页原来是这样:

// 迁移前:组件内散落的异步逻辑
function OrderFilter() {
  const { tenantId } = useWorkspace();
  const [dict, setDict] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!tenantId) return;
    setLoading(true);
    fetchDict(tenantId).then(setDict).finally(() => setLoading(false));
  }, [tenantId]);
  // ...
}

迁移后把字典拉取收进 configStore,组件只订阅结果:

// src/stores/configStore.ts
export const useConfigStore = create()((set, get) => ({
  dictMap: {} as Record,
  dictLoading: false,

  fetchDict: async (tenantId: string) => {
    if (get().dictMap[tenantId]) return; // 缓存命中直接返回
    set({ dictLoading: true });
    const data = await dictApi.list(tenantId);
    set((s) => ({ dictMap: { ...s.dictMap, [tenantId]: data }, dictLoading: false }));
  },
}));

组件里 useEffect(() => { fetchDict(tenantId) }, [tenantId, fetchDict]),注意 action 引用是稳定的,可以直接进依赖数组,不会死循环。

五、踩坑与优化

迁移过程大概用了两周,其中踩坑占了 3 天,说几个典型的:

坑 1:选择器返回新对象导致无限重渲染。 一开始有同事写 useStore(s => ({ list: s.list, total: s.total })),结果组件疯狂重渲染,控制台刷屏。原因是每次渲染选择器都返回新对象,Zustand 用 Object.is 比较,永远不相等。解决办法就是用 useShallow,或者拆成两个 useStore 调用。我们在 ESLint 里加了一条自定义规则,检测 useStore 内返回对象字面量且外层没包 useShallow 的情况,CI 直接拦。

坑 2:persist 中间件把大对象写进 localStorage。 一开始没配 partialize,workspace 对象(含大量嵌套配置)被整个序列化进去,localStorage 单个 key 超过 2MB,某些浏览器直接抛 QuotaExceededError。加上 partialize 只存两个 id 字段后,体积降到 200 字节以内。

坑 3:devtools 在 production 下仍然打包。 devtools 中间件默认会引入一些调试代码,虽然不重,但我们还是用 enabled: import.meta.env.DEV 关掉了。另外提醒一句,zustand/middleware 的 devtools 需要配合 Redux DevTools 扩展,团队里用 Safari 的同事装不了,调试时得换 Chrome。

坑 4:SSR/测试环境里 store 单例问题。 我们有个内部工具用 Vitest 跑单测,多个测试文件共享同一个 store 实例,状态互相污染。解决办法是在 beforeEach 里调用 useWorkspaceStore.setState(initialState, true) 重置,或者用 createStore 工厂函数给每个测试建独立实例。

性能优化上还做了两件事:

  1. 用 subscribeWithSelector 中间件做跨 store 联动。比如租户切换时要清空订单筛选条件,原来靠 useEffect 监听,现在直接:
useWorkspaceStore.subscribe(
  (s) => s.tenantId,
  (tenantId, prevTenantId) => {
    if (tenantId !== prevTenantId) {
      useOrderFilterStore.getState().reset();
    }
  }
);
  1. 对高频更新的状态用 transient update。通知队列的倒计时每秒更新,如果走 React 渲染会触发很多次。我们用 useNoticeStore.subscribe 直接在回调里操作 DOM 的 textContent,绕过 React 渲染,这个场景下够用了。

六、效果数据

迁移上线后,我用 React DevTools Profiler 和 Lighthouse 各测了一轮,数据如下(同一台机器、同一份数据、Chrome 120):

指标 迁移前 迁移后 变化
SKU 搜索输入 8 字符总渲染次数 213 次 12 次 -94%
单次按键平均渲染耗时 175ms 41ms -77%
订单筛选页首次交互延迟(TTI) 2.8s 2.1s -25%
首屏 JS 体积(gzip) 186KB 187.2KB +1.2KB
Context Provider 嵌套层数 4 层 0 层 -

渲染次数从 213 降到 12 是我最满意的。原因很直接:原来输入框的 value 存在 Context 里,每次输入整个 Provider 子树重渲染;现在输入框用本地 useState,只有提交时才写 store,其他订阅 warehouseId 的组件完全不受影响。

包体积增加了 1.2KB(Zustand 本体),基本可以忽略。代码行数上,删掉了 4 个 Context 文件约 380 行,新增 5 个 store 约 420 行,净增 40 行,但逻辑集中度明显提高——原来找一个状态的修改点要全局搜 setTenantId,现在直接看 store 文件。

还有个小收益:新同事上手快了。之前要理解 Context + Props 混合的数据流,得画半天图;现在打开 stores/ 目录,5 个文件就是全部全局状态,一目了然。

七、总结

这次迁移整体是值得的,但我想说几句实话:

不是所有项目都需要迁。 如果你的项目 Context 用得规范(value 用 useMemo 包、按领域拆分、消费者少),其实没必要折腾。我们迁是因为 Context 被滥用成了全局状态容器,且出现了可测量的性能问题。

迁移的核心不是换库,是重新划分状态边界。 我们花在设计 store 拆分上的时间比写代码多。如果只是把 Context 的 value 原样搬进 store,性能问题可能解决一半,但维护性问题还在。

Zustand 不是银弹。 选择器写不好照样重渲染,useShallow 该用还得用。它的优势在于 API 简单、没有 Provider、能在组件外访问,适合中小型项目和团队。

如果让我重来一次,我会在项目立项时就引入 Zustand,而不是等到 Context 撑不住了再迁。但话说回来,不经历一次 Context 的性能坑,团队也不会真正理解"状态管理"和"跨层传值"的区别。这大概就是所谓的技术债的利息吧。


本文代码基于 zustand@4.4.1,不同版本 API 可能有差异,迁移前请对照官方 changelog。