useContext 怎样用?Context 变化为什么会让所有消费组件重新渲染?
用 createContext 创建 Context,在上层用 <SomeContext value={...}> 提供值(React 19 之前写 SomeContext.Provider),下层组件调用 useContext 读取离它最近的那个 Provider 的值。Provider 的 value 用 Object.is 比较出变化后,React 会让所有读这个 Context 的组件重新渲染,memo 也拦不住。常见的优化是用 useMemo、useCallback 稳定 value,把变化频率不同的数据拆成多个 Context。
简答
应用要支持深色模式,按钮、卡片、弹窗分布在很深的层级里,每一层都传
themeprop 太麻烦。可以createContext建一个ThemeContext,在根部用<ThemeContext value={theme}>包住,任何深层组件里调用useContext(ThemeContext)就能拿到当前主题。
useContext会往上找离它最近的同一个 Context 的 Provider,读它的value;一个都找不到,才用createContext时给的默认值。React 19 起可以直接把<SomeContext>当 Provider 渲染,之前的版本写<SomeContext.Provider>。为什么一变就全部重新渲染?因为 React 用
Object.is比较 Provider 新旧两次的value,只要不同,就会找出下面所有读了这个 Context 的组件,让它们重新渲染。它不知道组件只用了value里的哪个字段,而且这个更新绕过了memo,memo只比较父组件传来的 props。最容易踩的坑是每次渲染都写
value={{ user, login }},对象每次都是新的,比较结果永远不同,所有消费组件跟着 Provider 所在组件一起重新渲染。改法是用useMemo、useCallback让 value 在数据没变时保持同一个引用;变化频率不同的数据拆成几个 Context;数据变化非常频繁时,换成支持按字段订阅的状态库。
基本用法
下面是一个主题切换的完整写法,注意 Provider 放在读取它的组件的上层:
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext('light');
export default function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext value={theme}>
<Toolbar />
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
切换主题
</button>
</ThemeContext>
);
}
function Toolbar() {
return <Button>保存</Button>;
}
function Button({ children }) {
const theme = useContext(ThemeContext);
return <button className={`btn-${theme}`}>{children}</button>;
}
Toolbar 没有接收也没有传递 theme,Button 怎么拿到的?useContext 不走 props,它沿着组件树往上找最近的 ThemeContext Provider。中间隔多少层都没关系。点切换按钮后,theme 状态变化,Provider 的 value 变了,Button 自动用新值重新渲染。
有两个容易出错的地方。一是 Provider 必须在调用 useContext 的组件上面,同一个组件自己渲染的 Provider 对自己无效。二是默认值只在完全找不到 Provider 时使用:如果上层写了 <ThemeContext value={undefined}>,读到的就是 undefined,不是默认值。默认值也永远不会变,要让 Context 能变化,就要像上面这样配合 state。
为什么 memo 拦不住 Context 更新
先看现象,下面的 Panel 用 memo 包住了,父组件也没有给它传任何 props:
const ThemeContext = createContext(null);
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext value={theme}>
<Panel />
</ThemeContext>
);
}
const Panel = memo(function Panel() {
const theme = useContext(ThemeContext);
return <section className={theme}>...</section>;
});
theme 变化时,Panel 还是会重新渲染。React 文档在 memo 的说明里写得很明确:即使用了 memo,组件自己的 state 变了,或者它用到的 Context 变了,它还是会重新渲染;memo 只管从父组件传来的 props。
原因在于更新的路径不同。props 变化是父组件重新渲染后把新 props 交给子组件,memo 在这一步比较新旧 props 决定要不要跳过。Context 变化时,React 拿 Provider 新旧两个 value 用 Object.is 比较,不同就让下面所有读了这个 Context 的组件重新渲染,这条路径不经过 memo 的 props 比较。
而且 useContext 读的是整个 value。如果 value 是 { user, theme, cart },一个只用了 theme 的组件,在 cart 变化时也会重新渲染,因为 React 只知道“这个组件读了这个 Context”,不知道它用了哪个字段。
每次都是新对象的问题
最常见的性能问题不是数据真的变了,而是 value 每次都是新的:
function MyApp() {
const [currentUser, setCurrentUser] = useState(null);
function login(response) {
storeCredentials(response.credentials);
setCurrentUser(response.user);
}
return (
<AuthContext value={{ currentUser, login }}>
<Page />
</AuthContext>
);
}
MyApp 因为别的原因重新渲染时,比如路由变化,{ currentUser, login } 是一个新对象,login 也是一个新函数,Object.is 比较结果不同,所有读 AuthContext 的组件都要重新渲染,即使 currentUser 根本没变。
React 文档给的改法是把函数用 useCallback 包起来,把对象用 useMemo 包起来:
const login = useCallback((response) => {
storeCredentials(response.credentials);
setCurrentUser(response.user);
}, []);
const contextValue = useMemo(() => ({ currentUser, login }), [currentUser, login]);
return (
<AuthContext value={contextValue}>
<Page />
</AuthContext>
);
这样 MyApp 再重新渲染,只要 currentUser 没变,contextValue 还是同一个对象,消费组件就不会因此重新渲染。文档也提醒,小应用里这不是问题,属于按需做的性能优化。
把 Context 拆开
另一个办法是按变化频率拆分。比如任务列表,tasks 经常变,dispatch 函数从不变。放进同一个 Context,只用 dispatch 的“添加按钮”也会在每次任务变化时重新渲染。拆成 TasksContext 和 TasksDispatchContext 两个,只读 dispatch 的组件就不受 tasks 变化影响。这种 Context 加 useReducer 的组合,详见《Context 与 useReducer 适合管理什么状态?》。
如果数据变化很频繁,又有很多组件只关心其中一小部分,拆分也不够用了。这时更适合 Zustand、Redux 这类支持选择器的状态库,组件只订阅自己用到的字段,见《Zustand、Jotai 和 Redux Toolkit 应该怎样选择?》。
用 Context 之前先想想 props
React 文档建议先别急着用 Context。先试着直接传 props,即使要传好几层,数据流也一目了然。如果中间层只是转手,往往说明少抽了组件,可以让中间组件接收 children,比如把 <Layout posts={posts} /> 改成 <Layout><Posts posts={posts} /></Layout>,层数自然就少了。这两种都不合适时,再用 Context。
常见追问
useContext 能写在条件语句里吗?
不能,它和其他 Hook 一样只能在组件顶层调用。React 19 新增的
useAPI 也能读取 Context,而且可以写在if和循环里,适合只在某个分支里才需要 Context 的情况。
为什么组件读不到 Provider 提供的值?
常见原因有三个。Provider 和
useContext写在了同一个组件里,或者 Provider 在它下面;Provider 根本没包住这个组件;或者构建工具的问题(比如用了符号链接)让 Provider 和useContext拿到的是两个不同的 Context 对象。
Context 适合放什么数据?
适合放变化不频繁、很多组件都要用的数据,比如主题、语言、当前登录用户、路由信息。高频变化的数据,比如输入框内容、拖拽坐标,放进 Context 会让大量组件跟着重新渲染,更适合放在局部 state 或带选择器的状态库里。