React Context 性能这件事:我不再把“全局状态”当成省事方案

Context 是我很长一段时间里最容易顺手用过头的 React 能力。

它太方便了。不想一层层传 props,就放 Context;多个组件都要读,就放 Context;以后可能别处会用,也先放 Context。这样写在项目早期很舒服,因为代码看起来集中、统一、好管理。

但我后来在一个中后台页面里吃过亏。

那个页面有筛选区、权限按钮、表格、右侧详情、图表和批量操作栏。我们把当前用户、权限、筛选条件、表格选中项、弹窗状态、刷新方法都放进了一个 PageContext。一开始没问题,直到数据量上来后,搜索框输入开始卡顿。用户每输入一个字,整个页面都像被拖了一下。

最早还不是用户报上来的,是我自己联调时手感不对。本地数据量小的时候完全没问题,敲字行云流水,等接了预发环境、表格一屏渲染三四十行带操作列的数据,输入框就开始“黏键”——你按下一个字母,光标得卡半拍才出来,连按几下还会丢字。我一开始甚至怀疑是输入法或者 antd 的受控 Input 有 bug,绕了一大圈才回到自己头上。

排查以后发现,我们把“共享”理解成了“全都共享”,问题出在这里,不是 Context 本身有毛病。

一次输入触发了半个页面更新

当时的 Provider 大概长这样:

1const PageContext = createContext(null);
2
3function PageProvider({ children }) {
4  const [keyword, setKeyword] = useState("");
5  const [selectedRows, setSelectedRows] = useState([]);
6  const [permission, setPermission] = useState({});
7  const [drawerOpen, setDrawerOpen] = useState(false);
8
9  const value = {
10    keyword,
11    setKeyword,
12    selectedRows,
13    setSelectedRows,
14    permission,
15    drawerOpen,
16    setDrawerOpen,
17  };
18
19  return <PageContext.Provider value={value}>{children}</PageContext.Provider>;
20}

问题很明显:keyword 每次变化,value 都会变。所有读这个 Context 的组件都会参与更新。哪怕某个按钮只关心权限,某个弹窗只关心开关,也会被卷进来。

我最开始的反应是加 useMemo

1const value = useMemo(() => ({
2  keyword,
3  setKeyword,
4  selectedRows,
5  setSelectedRows,
6  permission,
7  drawerOpen,
8  setDrawerOpen,
9}), [keyword, selectedRows, permission, drawerOpen]);

这一步是必要的,但没有解决根因。因为 keyword 真的在高频变化,value 也必须变化。useMemo 只能避免无意义的新对象,不能把一个大 Context 拆成细粒度订阅。

这里有个容易被忽略的细节:Context 的更新判定走的是 Object.is,比较的是 value 这个引用。只要引用变了,所有 useContext(PageContext) 的消费者都会被标记重渲染,而且这个过程绕过 React.memo。我当时还专门给几个重组件套了 memo,发现一点用都没有——memo 拦的是 props 变化,拦不住 Context 变化。一个组件只要订阅了某个 Context,那个 Context 的 value 一变,它就必重渲染,外面包多少层 memo 都没用。

更隐蔽的是,keyword 这种状态的变化是“击穿式”的。受控输入框每个按键都会 setKeyword,于是每个按键都会生成一个新的 value,每个按键都会把订阅了 Context 的表格、图表、详情面板全部刷一遍。表格本身渲染就重,于是按键和渲染叠在一起,主线程被占满,光标自然就卡了。我后来在 Profiler 里看一次输入的火焰图,单次按键能扫到二三十个组件,其中真正关心 keyword 的只有搜索框自己一个。

Context 适合稳定共享,不适合高频广播

我现在判断 Context,会先看三个问题:

  • 这个状态是不是很多组件都要读?
  • 它变化频率高不高?
  • 读它的组件是不是都关心同一块数据?

主题、语言、当前用户、权限配置,这些很适合 Context。它们跨层级、读多写少、变化频率低。

但输入框内容、拖拽坐标、滚动位置、当前表格选中项、局部弹窗状态,就不一定适合。尤其是“变化频繁 + 消费者很多 + 消费字段不同”这三件事同时出现时,大 Context 基本迟早会出问题。

这也是我后来形成的经验:Context 不是状态管理的默认答案,它更像是跨层级依赖注入。

第一刀先拆职责

我们那次没有马上换状态库,而是先把 Context 拆开。

1const PermissionContext = createContext(null);
2const FilterContext = createContext(null);
3const SelectionContext = createContext(null);
4const DrawerContext = createContext(null);

拆完以后,权限变化不会影响筛选区,筛选变化也不会影响只读权限的按钮。

更重要的是,归属清楚了。以前大家往 PageContext 里加字段没有心理负担,因为它已经是一个杂物箱。拆成几个 Context 后,每加一个状态都要问一句:它到底属于筛选、选择、权限,还是页面局部 UI?

这句追问本身就能挡住不少不合理的提升。

操作和数据也可以拆

有些组件只需要触发动作,不需要订阅数据。比如一个“刷新”按钮,只要拿到 reload,不一定要订阅整份列表状态。

这时可以把 state 和 actions 分开。

1const FilterStateContext = createContext(null);
2const FilterActionsContext = createContext(null);
3
4function FilterProvider({ children }) {
5  const [filter, setFilter] = useState({ keyword: "", status: "all" });
6
7  const actions = useMemo(() => ({
8    updateKeyword(keyword) {
9      setFilter((prev) => ({ ...prev, keyword }));
10    },
11    updateStatus(status) {
12      setFilter((prev) => ({ ...prev, status }));
13    },
14  }), []);
15
16  return (
17    <FilterActionsContext.Provider value={actions}>
18      <FilterStateContext.Provider value={filter}>
19        {children}
20      </FilterStateContext.Provider>
21    </FilterActionsContext.Provider>
22  );
23}

这不是每个页面都必须这么写。小页面这么拆会显得啰嗦。但在状态多、组件重、交互频繁的页面里,这种拆分能减少很多隐性更新。

拆 actions 出来还有个前提常被忽略:actions 这个对象自己必须稳定。我上面用 useMemo(() => ({...}), []) 把依赖数组留空,就是要保证它从头到尾是同一个引用——只要用了 setFilter 的函数式更新 prev => ...,actions 里就不需要闭包捕获最新的 filter,也就不必把 filter 放进依赖,引用自然能一直不变。我早期图省事写成 updateKeyword(k) { setFilter({ ...filter, keyword: k }) },为了拿到最新的 filter 就得把它加进 useMemo 依赖,结果 actions 跟着 filter 一起变,拆了等于没拆。这个坑很典型:拆 state/actions 的收益,全押在 actions 引用稳不稳上,一旦 actions 每次都是新对象,只订阅 actions 的那些组件照样被全刷一遍。

拆到几个 Context 合适,也要有个度

拆职责是对的,但我也见过拆过头的反面:一个中等页面挂了七八个 Context,Provider 在根组件里叠成一座“金字塔”,缩进十几层,读代码的人得先数括号才知道谁包着谁。

1<UserContext.Provider value={user}>
2  <PermissionContext.Provider value={permission}>
3    <FilterStateContext.Provider value={filter}>
4      <FilterActionsContext.Provider value={filterActions}>
5        <SelectionContext.Provider value={selection}>
6          {children}
7        </SelectionContext.Provider>
8      </FilterActionsContext.Provider>
9    </FilterStateContext.Provider>
10  </PermissionContext.Provider>
11</UserContext.Provider>

拆分的目的是隔离“变化频率不同、消费者不同”的状态,不是把每个字段都单独供起来。我现在的判断是:按“会不会一起变、会不会被同一批组件消费”来归组——同生同灭、同一批人读的,放一个 Context 就够了;变化频率明显不同、消费者也不重叠的,才值得拆开。真到了嵌套太深,我会把这堆 Provider 收进一个 AppProviders 组件里,让页面本身干净一点,至少读业务代码时不用先趟过一层缩进墙。

局部状态优先,不是架构不成熟

我以前有个误区:状态放得越高,架构越统一。

后来发现这句话不对。状态应该放在“需要它的最小范围”。能留在输入框附近,就不要提升到页面;能留在页面,就不要提升到应用;能通过 props 清楚传递,就不一定要 Context。

判断一个状态该不该提升,我现在会问:

  1. 是否有多个远距离组件同时依赖?
  2. 是否跨页面或跨模块存活?
  3. 是否代表一类业务上下文,而不是某个组件的交互过程?

如果答案都是否,那它留在局部更健康。

局部状态最大的好处是生命周期清楚。组件卸载,状态自然消失。放进 Context 后,它就变成了页面公共资产,后续每个使用者都会增加约束。

真需要细粒度订阅时,再考虑 store

有些场景拆 Context 也不够,比如表格选择、图表联动、低代码编辑器画布、实时协同状态。这类状态变化频繁,订阅粒度又很细,继续用 Context 承载数据会很重。

这时我会考虑把 Context 退回到“提供 store 引用”的角色,数据订阅交给更细粒度的模型。

极简思路类似这样:

1function createStore(initialState) {
2  let state = initialState;
3  const listeners = new Set();
4
5  return {
6    getSnapshot: () => state,
7    subscribe(listener) {
8      listeners.add(listener);
9      return () => listeners.delete(listener);
10    },
11    setState(updater) {
12      state = typeof updater === "function" ? updater(state) : updater;
13      listeners.forEach((listener) => listener());
14    },
15  };
16}

React 里的 useSyncExternalStore 就是理解这类模型的一个入口。真实项目不一定要自己造 store,但要明白:当你需要的是“谁关心哪一小块数据,谁才更新”时,Context 的广播模型就不够细了。

社区里那套 useContextSelector 的思路也是同一个方向:让消费者只订阅 Context 里的某个切片,切片没变就不重渲染。它能缓解大 Context 的广播问题,但我更愿意把它当成“已经拆到没法再拆、又暂时换不了 store”时的过渡手段,而不是拆分职责的替代品。因为选择器写多了,Context 的形状会越来越隐晦,新人很难一眼看出某个组件到底依赖了哪几块状态。真正干净的做法,还是先按归属把状态拆开,让每个 Context 本身就足够小。

到了真要上外部 store 的时候,我一般不会自己从头造这套订阅机制,而是直接用 Zustand 或者 Jotai 这类库——它们内部就是 useSyncExternalStore 加选择器订阅那一套,帮你把“谁订阅哪一小块、哪一块变了才通知谁”做掉了。但我想强调的顺序是:先把状态该归谁管理清楚,再决定要不要上库。库能给你更细的订阅粒度,却替你决定不了哪个状态该属于哪一层。归属没理顺就换库,只是把混乱从 Context 搬到了 store 里,页面照样会因为一次输入抖动半屏。

use 读 Context 之后,我对读取时机更敏感了

React 19 落地后,读 Context 多了一个 use 的写法。和 useContext 不同,use 允许写在条件分支里:

1function Cell({ editable }) {
2  const filter = use(FilterStateContext);
3  const actions = editable ? use(FilterActionsContext) : null;
4  // ...
5}

这在表格这种“同一个组件,有的行只读、有的行可编辑”的场景里很实用——只读的行根本不去订阅 actions,自然也不会被 actions 的变化牵连。但我用下来最大的体会不是语法糖本身,而是它逼我重新意识到一件事:订阅了哪个 Context,就等于给自己接上了那个 Context 的重渲染广播。 以前 useContext 只能写在顶层,很多组件顺手把好几个 Context 全读进来,其实用不上;换成 use 能按条件读之后,我反而会更克制地问一句“这个分支真的需要订阅它吗”。

不过 use 并不会把“value 变了消费者就重渲染”这条基本规则改掉。它改变的是“在哪里、要不要读”,不改变“读了就订阅”。所以它不是性能银弹,真正拉开差距的还是前面那几刀——状态归属拆得够不够干净、value 引用稳不稳定。

顺带记一个我踩过的 Provider 结构坑:不要在渲染函数体里就地定义 Provider 组件,或者让 Provider 的位置随条件跳来跳去。我有一次把某个 Provider 塞进了一个会条件切换的分支里,结果分支一变,整棵子树连着 Context 里的 state 一起被卸载重建,用户填了一半的筛选条件平白丢了。Context 的性能问题大多在“value 变化太频繁”,但这类“Provider 本身被重建”的问题更隐蔽,一旦中招是直接丢状态,不只是多渲染几次。

性能优化要先量,不要凭感觉拆

这次排查也提醒我,不要看到 Context 就紧张。

有些 Context 更新范围很大,但组件很轻,用户没有感觉。另一些页面看起来只更新了几个组件,但表格、图表、富文本编辑器很重,实际卡顿明显。

我现在会先用 React DevTools Profiler 录一次真实操作:输入搜索词、切换筛选项、打开详情、勾选表格行。看清楚是哪一次交互慢、哪些组件在重渲染、耗时集中在哪里,再决定怎么拆。

排查顺序大概是:

  1. 找到高频交互。
  2. 看 Provider 的 value 是否高频变化。
  3. 看消费者是否读了过多字段。
  4. 先局部化状态,再拆 Context。
  5. 仍然不够,再上细粒度订阅。

这比一上来加一堆 memo 更可靠。memo 能挡住一部分渲染,但挡不住糟糕的状态归属。

回到开头那个卡输入的页面,最后治好它的是这条排查顺序走完之后的两刀,不是什么高级 API:先把 keywordPageContext 里拎出来、留在搜索框自己的局部 state 里,只在用户停止输入后防抖同步一次到共享层;再把剩下的权限、选择、弹窗按职责拆成几个小 Context。改完我又在 Profiler 里录了一次同样的输入,之前一次按键扫二三十个组件,现在只剩搜索框自己重渲染。前后对比很直白:不是 React 慢,是我把不该一起变的东西绑在了一起。

这两年我对 React 状态的看法

React 生态这两年一直在往“减少不必要渲染成本”上发展,框架、编译器、服务端组件、缓存模型都在帮开发者少做一些机械优化。

但状态该归谁管这件事,工具很难替你完全决定。

一个状态到底属于页面、模块、应用,还是某个组件的临时交互,这仍然需要开发者根据业务判断。尤其是中后台系统,很多性能问题不是因为少用了某个高级 API,而是因为把业务状态、UI 状态、流程状态混在了一起。

Context 仍然很好用,我现在也会继续用。

只是我不再把它当成“全局状态省事方案”。它适合传递稳定的跨层级上下文,不适合承载所有频繁变化的页面状态。

如果一个页面开始变卡,我会先看状态放在哪里,而不是先看该不该换库。很多时候,把状态放回合适的位置,性能和可维护性都会一起变好。

这也是我现在给团队新人讲 Context 时最想先说清楚的一点:别把它当“全局变量”用,要当“跨层级依赖注入”用。当依赖注入看,你自然会问“谁需要注入什么”,而不是“反正大家都能读,先放进去再说”。这个视角一转过来,很多把状态往上堆的冲动,在写下第一行 createContext 之前就被挡住了。