首页 / React 学习笔记 / 15

REACT · Vol.II · LESSON 15 · Hooks 精讲

useContext:跨层传递的广播机制

进阶#状态#Context

当 props 开始「层层透传」

上一篇(第 14 篇)用「状态提升」解决了两个组件共享数据的问题:状态搬去最近的公共祖先,props 一人一半发下去。但如果真正需要数据的组件,藏在很深的地方呢?

PropDrilling.tsx · 主题数据从顶层一路「人肉快递」到最底层
import { useState } from "react";

type ThemeProps = {
  theme: "light" | "dark";
  onToggleTheme: () => void;
};

function App() {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  return (
    <Page theme={theme}
      onToggleTheme={() => setTheme(t => (t === "light" ? "dark" : "light"))} />
  );
}

// Page、Section 自己不用,却必须「接过再传下」
function Page({ theme, onToggleTheme }: ThemeProps) {
  return <Section theme={theme} onToggleTheme={onToggleTheme} />;
}
function Section({ theme, onToggleTheme }: ThemeProps) {
  return <ThemedButton theme={theme} onToggleTheme={onToggleTheme} />;
}

// 真正的消费者,在最底层
function ThemedButton({ theme, onToggleTheme }: ThemeProps) {
  return <button className={theme} onClick={onToggleTheme}>切换主题</button>;
}

这个现象叫 props drilling(props 钻井)——数据从顶层凿穿中间所有层才到目的地。三个实打实的维护成本:

  1. 中间层被迫「认识」不属于自己的数据——Page 和 Section 对主题毫无兴趣,签名里却有主题字段;
  2. 加字段 = 改一条链——以后再加个语言 lang,从 AppThemedButton 每一层的类型都得动;
  3. 重构变危险——调整中间层前,得先分清哪些 props 是「过路的」、哪些是「自用的」。
你在 Spring 里想读一个全局配置项,是把 HttpServletRequest 从 Controller 一路传进 Service、再传进 DAO 吗?从来不会——Environment 这类「容器级上下文」本来就是在任意一层直接取的。那组件树里,有没有对应的机制?

Context 三件套:createContext、Provider、useContext

Context 的 API 一共三样:createContext(default) 创建一条「广播频道」;<Ctx.Provider value={x}> 在某个子树「开播」,向所有后代广播 value;useContext(Ctx) 让任意后代「收听」。分别对应后端的三个动作:声明全局资源、往 Environment 装载配置、任意层注入读取。把钻井代码改写成 Context 版本,注意中间的 PageSection 彻底退场了:

ThemeContext.tsx · 主题:一条典型的广播频道
import { createContext, useContext, useState } from "react";

type Theme = "light" | "dark";

// 1. 创建频道:参数是「默认值」,上层没 Provider 时兜底用
const ThemeContext = createContext<Theme>("light");

function App() {
  const [theme, setTheme] = useState<Theme>("light");
  // 2. Provider 开播:value 一变,广播给整棵子树
  return (
    <ThemeContext.Provider value={theme}>
      <Page />
    </ThemeContext.Provider>
  );
}

// 中间层干干净净,不再沾任何主题 props
function Page() { return <Section />; }
function Section() { return <ThemedButton />; }

function ThemedButton() {
  // 3. 收听:不管隔着多少层,直接拿到当前值
  const theme = useContext(ThemeContext);
  return <button className={theme}>切换主题</button>;
}
后端类比:ServletContext / RequestContext 与 Environment

Servlet 规范里,ServletContext 是应用级上下文、RequestContext 是请求级上下文,而 React Context 的作用域由 Provider 包的位置决定——包多大,管多大。「上下文由容器持有、代码按需读取、参数签名保持干净」的模型,两边完全一致。

第二个高频场景——当前登录用户,它和主题的唯一区别是值可能为「空」(未登录),正好演示默认值的另一种用法:

UserContext.tsx · 当前登录用户
type User = { id: number; name: string; role: "ADMIN" | "USER" };

// 默认值 null:没包 Provider 就等于「未登录」
const UserContext = createContext<User | null>(null);

function UserCard() {
  const user = useContext(UserContext);
  if (!user) return <p>未登录</p>;
  return <p>{user.name}({user.role})</p>;
}

嵌套 Provider:就近覆盖

同一条频道可以在组件树的不同高度「多次开播」:当消费者上方有多个 Provider 时,useContext 取的是离自己最近的那一层的值,外层被内层覆盖:

App.tsx · 内层 Provider 覆盖外层
<ThemeContext.Provider value="light">
  <Header />          // useContext 读到 "light"

  <ThemeContext.Provider value="dark">
    <SettingsPanel /> // 读到 "dark"——离它最近的 Provider 说了算
  </ThemeContext.Provider>

  <Footer />          // 仍是 "light",不受里面那段覆盖影响
</ThemeContext.Provider>
后端类比:配置源的就近覆盖

Spring Boot 里 application-dev.yml 会覆盖 application.yml 的同名配置项——越具体、越靠近使用者的配置源优先级越高。Context 的嵌套覆盖同款思路:全局开播一个默认值,局部子树想特例,就地再包一层 Provider 即可,不必改全局。

一个必须现在就说清的机制:Provider 的 value 一旦变化,所有调用了 useContext 的组件都会强制重新渲染——这是广播,不是点播,代价在第 4 站细讲。(React 19 可简写成 <ThemeContext value={theme}>,本文沿用 .Provider 兼容 React 18。)

规范做法:自定义 Hook 包装 + 忘了 Provider 就报错

直接暴露 createContext / useContext 有两个隐患:

  • 默认值静默兜底createContext<Theme>("light") 意味着哪天忘了包 Provider,组件不报错,只是「永远亮色主题」——bug 藏得很深。默认值只适合「未登录」这种有业务含义的空态,不适合当「忘记配置」的挡箭牌;
  • 消费方耦合实现细节:每个组件都得 import 两样东西,将来想换实现(第 20 篇)全项目都要动。

社区规范做法:Context 与消费 Hook 同文件,只对外暴露 Provider 和一个自定义 Hook(写法见第 13 篇),Hook 里判空抛错——快速失败(fail-fast),和后端参数校验不过就抛 IllegalArgumentException 同一个信条:

theme-context.tsx · Context 与 useTheme 同文件,只暴露 Provider + Hook
import { createContext, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";

type Theme = "light" | "dark";
type ThemeContextValue = { theme: Theme; toggleTheme: () => void };

// 故意不给默认值:用 null 明确表达「必须在 Provider 内使用」
const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  // useMemo 稳定 value 引用——为什么必须,下一站细讲
  const value = useMemo(
    () => ({
      theme,
      toggleTheme: () => setTheme(t => (t === "light" ? "dark" : "light")),
    }),
    [theme]
  );
  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  );
}

// 对外唯一的「读接口」:忘了包 Provider,第一时间炸出来
export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (ctx === null) {
    throw new Error("useTheme 必须在 <ThemeProvider> 内部使用");
  }
  return ctx;
}

消费端从此很干净,组件甚至不知道 Context 的存在:

ThemedButton.tsx · 消费方只 import 一个 Hook
import { useTheme } from "./theme-context";

function ThemedButton() {
  const { theme, toggleTheme } = useTheme();
  return <button className={theme} onClick={toggleTheme}>切换主题</button>;
}
把「能不能取到」的判断收敛进 Hook 还有附带好处:类型从 ThemeContextValue | null 收窄成 ThemeContextValue,消费端不用再写 if (!ctx) 防御——和后端封装「保证非空的 findOrFail()」一个思路:把不变式守在门口
核心要点
  • Context 与消费 Hook 放同一文件;对外只暴露 XxxProvideruseXxx()
  • 默认值给 null,Hook 里判空并 throw——让「忘包 Provider」在开发期就炸出来。
  • 组件只 import 自定义 Hook,不感知 Context 实现,将来换方案只动一个文件。

性能陷阱:广播是「全频段」的

先说结论:Provider 的 value 引用一变,它子树里所有 useContext 的消费者全部重渲染——而且这条更新不走 props 比较链memo 也拦不住(见第 37 篇)。所以头号大坑是让 value 的引用「无故」变化——下面例子里哪怕只是敲了个字符(keyword 变了),主题订阅者也会被连累白白重渲染:

❌ App.tsx · value 内联对象:每次渲染都是新引用
function App() {
  const [theme, setTheme] = useState<Theme>("light");
  const [keyword, setKeyword] = useState("");  // 毫不相干的搜索词

  // ❌ value 是内联对象:App 每次渲染都产生新引用
  return (
    <ThemeContext.Provider
      value={{ theme, toggleTheme: () => setTheme(t => (t === "light" ? "dark" : "light")) }}
    >
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <Page />
    </ThemeContext.Provider>
  );
}
✅ App.tsx · useMemo 把引用钉住
const value = useMemo(
  () => ({
    theme,
    toggleTheme: () => setTheme(t => (t === "light" ? "dark" : "light")),
  }),
  [theme]   // theme 不变 → value 引用不变 → 广播静默
);

return <ThemeContext.Provider value={value}><Page /></ThemeContext.Provider>;

这条规则你在第 4 篇(引用相等判断 state 变没变)和第 11 篇(useMemo 稳定引用)都见过,Context 只是把它搬到了 value 上。另有两个配套缓解手段:

  1. 按领域拆多个 Context:主题一个、用户一个。拆开后「用户信息变了」只广播给订阅用户的组件,主题订阅者毫发无伤——和后端「按业务域拆消息 Topic」一个道理;
  2. children 透传不变:像第 3 站的 ThemeProvider 那样把 children 从外部接进来。Provider 内部状态变化时,只有订阅者重渲染,children 里的其余子树因元素引用不变而原样复用。整棵应用写死在 Provider 的 JSX 里,就吃不到这层豁免。
ThemeContext.Provider Header useTheme ✅ 重渲染 Footer useTheme ✅ 重渲染 SearchBox 未订阅 · 不受影响
图 1Context 是全频段广播:value 一变,所有订阅者强制重渲染(memo 也拦不住);未订阅的组件原地不动。
核心要点
  • Context 更新绕过 memo,直接命中所有订阅者——「订阅面」就是你的重渲染面。
  • value 用 useMemo 稳定引用;状态与操作函数一起打包,依赖只写状态本身。
  • 按领域拆多个 Context,各播各的;Provider 通过 children 接收子树,缩小波及范围。

用还是不用:Context 的适用边界

最后回答开头的承诺:什么时候该用 Context,什么时候千万别。判断标准就两个词:更新频率数据所有权

数据放 Context?原因
主题、语言(国际化)✅ 适合全局一致,切换才变,一天动不了几次
当前登录用户、权限角色✅ 适合只在登录 / 登出 / 切角色时更新
搜索框输入、hover、拖拽位置❌ 别放高频更新,每敲一个字就全树广播一次
接口返回的列表、详情❌ 别放真源在服务端,需要缓存与失效重取(第 17、33 篇)
相邻两三个组件的共享状态❌ 杀鸡用牛刀状态提升(第 14 篇)就够了

高频更新怎么办?两条路:留在最近的公共祖先(状态提升),或用支持细粒度订阅的第三方 store——第 18 篇的 Zustand 只让订阅了切片的组件重渲染,天生缓解广播问题(选型见第 20 篇)。

本章回顾

props drilling 的本质是「人人皆快递员」;Context 换了个模型——Provider 开播,任意后代直接收听,中间层彻底退场。嵌套开播时就近覆盖:内层 Provider 的值赢,局部特例不动全局。规范做法:Context 与 useXxx() 同文件、默认值给 null、Hook 里 fail-fast。性能口诀:订阅面就是重渲染面——value 用 useMemo 稳定、按领域拆分、children 透传不变。

下一篇是卷二收官——第 16 篇「Hooks 规则与十大常见陷阱」。本篇里 useTheme 为什么必须写在组件顶层、不能塞进 if?答案指向同一个底层机制:React 靠调用顺序识别每一个 Hook。想透这个机制,十个常见的坑会同时揭开谜底。

Comments · 评论