前端知库

useContext 怎样用?Context 变化为什么会让所有消费组件重新渲染?

用 createContext 创建 Context,在上层用 <SomeContext value={...}> 提供值(React 19 之前写 SomeContext.Provider),下层组件调用 useContext 读取离它最近的那个 Provider 的值。Provider 的 value 用 Object.is 比较出变化后,React 会让所有读这个 Context 的组件重新渲染,memo 也拦不住。常见的优化是用 useMemo、useCallback 稳定 value,把变化频率不同的数据拆成多个 Context。

参考答案

简答

应用要支持深色模式,按钮、卡片、弹窗分布在很深的层级里,每一层都传 theme prop 太麻烦。可以 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 放在读取它的组件的上层:

JSX
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:

JSX
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 每次都是新的:

JSX
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 包起来:

JSX
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 新增的 use API 也能读取 Context,而且可以写在 if 和循环里,适合只在某个分支里才需要 Context 的情况。

为什么组件读不到 Provider 提供的值?

常见原因有三个。Provider 和 useContext 写在了同一个组件里,或者 Provider 在它下面;Provider 根本没包住这个组件;或者构建工具的问题(比如用了符号链接)让 Provider 和 useContext 拿到的是两个不同的 Context 对象。

Context 适合放什么数据?

适合放变化不频繁、很多组件都要用的数据,比如主题、语言、当前登录用户、路由信息。高频变化的数据,比如输入框内容、拖拽坐标,放进 Context 会让大量组件跟着重新渲染,更适合放在局部 state 或带选择器的状态库里。