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。
判断一个状态该不该提升,我现在会问:
- 是否有多个远距离组件同时依赖?
- 是否跨页面或跨模块存活?
- 是否代表一类业务上下文,而不是某个组件的交互过程?
如果答案都是否,那它留在局部更健康。
局部状态最大的好处是生命周期清楚。组件卸载,状态自然消失。放进 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 录一次真实操作:输入搜索词、切换筛选项、打开详情、勾选表格行。看清楚是哪一次交互慢、哪些组件在重渲染、耗时集中在哪里,再决定怎么拆。
排查顺序大概是:
- 找到高频交互。
- 看 Provider 的 value 是否高频变化。
- 看消费者是否读了过多字段。
- 先局部化状态,再拆 Context。
- 仍然不够,再上细粒度订阅。
这比一上来加一堆 memo 更可靠。memo 能挡住一部分渲染,但挡不住糟糕的状态归属。
回到开头那个卡输入的页面,最后治好它的是这条排查顺序走完之后的两刀,不是什么高级 API:先把 keyword 从 PageContext 里拎出来、留在搜索框自己的局部 state 里,只在用户停止输入后防抖同步一次到共享层;再把剩下的权限、选择、弹窗按职责拆成几个小 Context。改完我又在 Profiler 里录了一次同样的输入,之前一次按键扫二三十个组件,现在只剩搜索框自己重渲染。前后对比很直白:不是 React 慢,是我把不该一起变的东西绑在了一起。
这两年我对 React 状态的看法
React 生态这两年一直在往“减少不必要渲染成本”上发展,框架、编译器、服务端组件、缓存模型都在帮开发者少做一些机械优化。
但状态该归谁管这件事,工具很难替你完全决定。
一个状态到底属于页面、模块、应用,还是某个组件的临时交互,这仍然需要开发者根据业务判断。尤其是中后台系统,很多性能问题不是因为少用了某个高级 API,而是因为把业务状态、UI 状态、流程状态混在了一起。
Context 仍然很好用,我现在也会继续用。
只是我不再把它当成“全局状态省事方案”。它适合传递稳定的跨层级上下文,不适合承载所有频繁变化的页面状态。
如果一个页面开始变卡,我会先看状态放在哪里,而不是先看该不该换库。很多时候,把状态放回合适的位置,性能和可维护性都会一起变好。
这也是我现在给团队新人讲 Context 时最想先说清楚的一点:别把它当“全局变量”用,要当“跨层级依赖注入”用。当依赖注入看,你自然会问“谁需要注入什么”,而不是“反正大家都能读,先放进去再说”。这个视角一转过来,很多把状态往上堆的冲动,在写下第一行 createContext 之前就被挡住了。