一、问题背景:Context用着用着就不够用了

项目是一个React 18.2 + TypeScript 4.9的中后台系统,日活大概3万,页面结构是典型的「左侧菜单 + 顶部用户信息 + 主内容区」。早期为了快速上线,全局状态就用了最朴素的两套方案:

  • 用户信息、权限、主题这类低频变更的数据,放在一个 AppContext 里;
  • 页面级数据靠 props 一层层往下传,最深的地方传了5层。

一开始没问题,毕竟页面不多。但随着业务迭代,AppContext 里塞进了越来越多东西:用户信息、权限列表、全局loading、通知未读数、当前选中的组织架构……任何一个字段变化,所有 useContext 的组件都会重渲染。最夸张的一次,通知未读数每30秒轮询更新一次,结果整个侧边栏菜单跟着闪了一下。

我们用 React DevTools Profiler 录了一次列表页的交互,数据很直观:

  • 打开列表页,Context Provider 的值变化触发了 47次 组件重渲染,其中真正需要更新的只有9个;
  • 列表页在「待办 / 已办 / 我发起」三个Tab之间切换,平均耗时 180ms,用户能明显感觉到卡顿;
  • 有个表单页 props 透传了5层,中间三层组件仅仅是为了把 onSubmit 传下去,代码里全是 ({ onSubmit }) => ... 这种噪音。

到这一步,结论很明确:Context 适合「低频、全局、读多写少」的场景,但我们把它当成了万能状态容器,props 又承担了本不该承担的跨层级通信。是时候引入一个真正的状态管理库了。

二、环境与版本:先盘点家底

迁移前先把环境钉死,避免后面排查问题时版本对不上:

{
  "react": "18.2.0",
  "react-dom": "18.2.0",
  "typescript": "4.9.5",
  "vite": "4.4.9",
  "zustand": "4.4.1",
  "immer": "10.0.2",
  "react-router-dom": "6.15.0"
}

选型阶段对比了三个候选方案,都是社区主流:

方案 包体积(gzip) 重渲染控制 学习成本 是否引入Provider 适合场景
React Context 0(内置) 差,值变全量更新 低 是 低频全局数据
Redux Toolkit 1.9 ~13KB 好,需selector 中高 是 大型团队、强约束
Zustand 4.4 ~1.2KB 好,天然selector 低 否 中小型项目、渐进迁移

我们最终选 Zustand,理由很实在:

  1. 不需要 Provider,store 就是个 hook,迁移时可以一个模块一个模块换,不用一次性改根组件;
  2. selector 天然支持细粒度订阅,useStore(s => s.user.name) 只在这个字段变的时候重渲染;
  3. 包体积小,1.2KB 对中后台来说几乎无感;
  4. 团队里没人写过 Redux 的 boilerplate,Zustand 的 API 半小时就能上手。

顺带提一句 Vue 侧:如果项目是 Vue 3,对应的选择通常是 Pinia。我们另一个 Vue 3.3 项目就是从 provide/inject 迁到 Pinia 的,思路和本文基本一致——provide/inject 的问题和 Context 一模一样:注入的值一变,所有注入方全更新,且没有 selector 机制。所以下面的迁移思路对 Vue 开发者同样有参考价值。

三、方案设计:按「变更频率」拆分 store

迁移不是把所有 Context 一股脑搬进一个 store,那样只是换了个地方制造重渲染。我们的拆分原则是按变更频率和生命周期切分:

  • useUserStore:用户信息、权限、token,登录后写一次,几乎不变;
  • useAppStore:主题、侧边栏折叠、全局loading,低频变更;
  • useNotifyStore:未读数、通知列表,30秒轮询,高频但只影响顶部铃铛;
  • 页面级状态(列表筛选条件、分页)不进全局 store,留在组件内或走 URL query,避免全局 store 膨胀。

关键设计点有三个:

第一,selector 必须精确。 禁止 const state = useAppStore() 这种全量订阅,ESLint 里加了一条自定义规则,code review 时也重点盯。

第二,异步逻辑放 store 内部。 之前用户信息是在组件 useEffect 里请求再 setState 到 Context,迁移后把 fetchUser 直接写进 store 的 action,组件只管调用。

第三,对外暴露的 hook 做一层封装。 不让业务组件直接 import useAppStore,而是从 stores/index.ts 导出语义化的 hook,比如 useUserInfo()、useUnreadCount(),这样以后换库(虽然不太可能)只改一处。

四、核心实现:从 Context 到 Zustand 的代码

先看迁移前的 Context 写法,这是问题根源:

// 迁移前:AppContext.tsx
import { createContext, useContext, useState, useEffect } from 'react';

interface AppState {
  user: { id: string; name: string; avatar: string } | null;
  permissions: string[];
  unreadCount: number;
  theme: 'light' | 'dark';
}

const AppContext = createContext({} as AppState);

export function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState(null);
  const [permissions, setPermissions] = useState([]);
  const [unreadCount, setUnreadCount] = useState(0);
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    fetch('/api/user').then(r => r.json()).then(data => {
      setUser(data.user);
      setPermissions(data.permissions);
    });
    // 30秒轮询未读数,这里就是重渲染的罪魁祸首
    const timer = setInterval(() => {
      fetch('/api/notify/unread').then(r => r.json()).then(d => setUnreadCount(d.count));
    }, 30000);
    return () => clearInterval(timer);
  }, []);

  // 任意一个字段变化,value 都是新对象,所有消费者全部重渲染
  return (

      {children}

  );
}

export const useAppContext = () => useContext(AppContext);

迁移后,按 store 拆分。先看用户 store,包含异步 action:

// stores/userStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface UserInfo {
  id: string;
  name: string;
  avatar: string;
}

interface UserState {
  user: UserInfo | null;
  permissions: string[];
  loading: boolean;
  fetchUser: () => Promise;
  hasPermission: (code: string) => boolean;
  logout: () => void;
}

export const useUserStore = create()(
  devtools(
    (set, get) => ({
      user: null,
      permissions: [],
      loading: false,

      fetchUser: async () => {
        set({ loading: true }, false, 'user/fetchStart');
        try {
          const res = await fetch('/api/user');
          const data = await res.json();
          set({ user: data.user, permissions: data.permissions, loading: false }, false, 'user/fetchSuccess');
        } catch (e) {
          set({ loading: false }, false, 'user/fetchError');
          throw e;
        }
      },

      hasPermission: (code) => get().permissions.includes(code),

      logout: () => set({ user: null, permissions: [] }, false, 'user/logout'),
    }),
    { name: 'UserStore', enabled: import.meta.env.DEV }
  )
);

// 语义化封装,业务组件只用这个
export const useUserInfo = () => useUserStore((s) => s.user);
export const usePermissions = () => useUserStore((s) => s.permissions);

再看通知 store,这里用了 selector + shallow 比较来处理对象/数组返回值,这是迁移里最容易踩坑的地方:

// stores/notifyStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { useShallow } from 'zustand/react/shallow';

interface NotifyState {
  unreadCount: number;
  list: Array;
  setUnread: (n: number) => void;
  markRead: (id: string) => void;
}

export const useNotifyStore = create()(
  subscribeWithSelector((set) => ({
    unreadCount: 0,
    list: [],
    setUnread: (n) => set({ unreadCount: n }),
    markRead: (id) =>
      set((state) => ({
        list: state.list.map((item) => (item.id === id ? { ...item, read: true } : item)),
        unreadCount: Math.max(0, state.unreadCount - 1),
      })),
  }))
);

// 只订阅 unreadCount,list 变化不会触发使用该 hook 的组件
export const useUnreadCount = () => useNotifyStore((s) => s.unreadCount);

// 返回对象时必须用 useShallow,否则每次都是新引用,等于没优化
export const useNotifyActions = () =>
  useNotifyStore(
    useShallow((s) => ({ setUnread: s.setUnread, markRead: s.markRead }))
  );

轮询逻辑从 Context 的 useEffect 移到了根布局里,只调用 action,不订阅状态:

// layouts/RootLayout.tsx 片段
import { useEffect } from 'react';
import { useNotifyStore } from '@/stores/notifyStore';

export function RootLayout({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    const timer = setInterval(async () => {
      const res = await fetch('/api/notify/unread');
      const data = await res.json();
      // getState() 直接取 action,不引起订阅
      useNotifyStore.getState().setUnread(data.count);
    }, 30000);
    return () => clearInterval(timer);
  }, []);

  return {children};
}

组件里的调用从 const { user } = useAppContext() 变成 const user = useUserInfo(),改起来是机械劳动,用 IDE 的正则替换加人工核对,一个下午能改完 60 多个文件。

五、踩坑与优化:三个必须说的细节

坑一:selector 返回新对象导致无限重渲染。 迁移第一天就遇到 Maximum update depth exceeded。原因是写了 useNotifyStore(s => ({ count: s.unreadCount })),每次 render 都返回新对象,Zustand 用 Object.is 比较永远不相等。解决办法就是上面代码里的 useShallow,或者干脆拆成多个单字段 selector。这个坑在 Vue 的 Pinia 里对应的是 storeToRefs,本质一样。

坑二:devtools 中间件在生产环境要关掉。 一开始没加 enabled: import.meta.env.DEV,打出来的包体积多了 8KB,而且 devtools 序列化在频繁更新时有额外开销。加上开关后,生产包 gzip 只比迁移前多了 1.1KB。

坑三:Context 里的「派生状态」不要直接搬。 原来 Context 里有个 canEdit 是 permissions.includes('edit') 算出来的,迁移时有人直接把它存进了 store,导致权限更新后 canEdit 不同步。正确做法是用 selector 派生:const canEdit = useUserStore(s => s.permissions.includes('edit')),或者用 hasPermission action。派生状态永远不要存。

还有一个优化点:列表页的筛选条件我们最终没放进 Zustand,而是同步到了 URL query。这样刷新页面能恢复状态,分享链接也带筛选条件,比塞全局 store 更合理。全局 store 只放真正跨页面共享的东西。

六、效果数据:Profiler 说了算

迁移上线后,用同样的 React DevTools Profiler 场景复测,对比数据如下:

指标 Context + Props Zustand 4.4 变化
列表页首屏重渲染次数 47次 6次 -87%
Tab切换平均耗时 180ms 52ms -71%
通知轮询触发的重渲染组件数 全部Context消费者(约23个) 1个(铃铛组件) -96%
生产包 gzip 体积 基准 +1.1KB 可接受
表单页 props 透传层数 5层 0层(直接读store) —

另外有两个体感上的变化:一是侧边栏不再因为未读数轮询而闪动,二是表单页的中间组件删掉了大量透传 props,代码行数少了大概 200 行,可读性明显提升。

需要说明的是,这些数字是在我们项目特定场景下测的,不代表 Zustand 一定比 Context 快多少倍——Context 在低频场景下完全够用。真正带来收益的是「细粒度订阅」这个机制,而不是库本身。

七、总结

回头看这次迁移,几点经验值得记下来:

  1. Context 不是不能用,是别当万能容器。 低频、全局、读多写少的数据,Context 依然是好选择,我们迁移后主题切换还留在 Context 里(因为要配合 CSS 变量,且变更极少)。
  2. 拆分 store 按变更频率来,不是按页面来。 一个巨大的 store 只是把 Context 的问题换了个马甲。
  3. selector 是性能的关键,不是库。 全量订阅 useStore() 的话,Zustand 一样会重渲染,useShallow 和单字段 selector 要写进团队规范。
  4. 渐进迁移完全可行。 因为 Zustand 不需要 Provider,我们是按模块一个个换的,两周内完成,期间新旧方案并存,没有大爆炸式重构的风险。
  5. Vue 项目同理,provide/inject 迁 Pinia 的思路基本一致,注意 storeToRefs 解构保持响应性即可。

如果你们项目也出现了「改一个未读数全页面闪」或者「props 传了五层」的症状,可以先用 Profiler 录一段看看重渲染次数,数据会告诉你答案。