React 组件通信进阶:useContext 与自定义 Hook 实战详解

React 组件通信进阶:用 useContext 跨层传值,用自定义 Hook 复用逻辑

在 React 应用中,组件很少是完全孤立的。页面通常由许多组件组成,数据也需要在这些组件之间流动。

父组件向直接子组件传值时,props 足够简单、直观。但当组件层级越来越深,数据需要从顶层传到孙组件、曾孙组件时,如果仍然逐层传递 props,中间组件即使不使用这份数据,也必须负责继续向下转交。这种"层层搬运"会让组件关系变得啰嗦。

这时可以使用 Context:数据由组件树上层统一提供,任意后代组件只要连接到对应的 Context,就能直接读取数据,不必关心自己与提供者之间隔了多少层。

除了组件通信,React 开发中还有另一类常见需求:多个组件可能需要复用同一套带状态、带副作用的逻辑。普通函数适合复用纯计算,而自定义 Hook 可以继续使用 useStateuseEffectuseContext 等 Hook,因此更适合封装 React 中的响应式逻辑和副作用逻辑。

本文通过两个小例子讲清楚:

  1. 使用 createContextProvideruseContext 实现跨层级主题共享;
  2. 使用 useStateuseEffect 封装一个监听鼠标位置的自定义 Hook。

一、先理解组件通信为什么需要 Context

React 遵循单向数据流。最常见的父子组件通信方式,是父组件通过 props 把数据传给子组件。子组件若要影响父组件中的状态,则通常由父组件传入一个函数,子组件在合适的时机调用它。

直接父子关系下,这种方式非常清晰。但假设组件结构逐渐加深:

jsx 复制代码
<Parent>
  <Child>
    <GrandChild>
      <GreatGrandChild />
    </GrandChild>
  </Child>
</Parent>

如果 GreatGrandChild 需要读取 Parent 中的主题数据,使用 props 就意味着数据要依次经过 ChildGrandChild。这两个中间组件可能完全不使用主题,却仍要接收并转交它。

Context 解决的正是这类问题:

  • 上层组件负责提供数据;
  • Provider 范围内的后代组件负责消费数据;
  • 数据可以跨越中间层级,不必逐层传递 props

这里要注意,Context 并不等于"所有数据都必须全局共享"。Provider 可以放在任意合适的组件层级,只有被它包裹的组件树会接收到它提供的值。

二、使用 Context 的三个步骤

使用 Context 可以概括为三个步骤:

  1. createContext 创建上下文;
  2. 用 Context 的 Provider 包裹需要共享数据的组件树;
  3. 在后代组件中用 useContext 消费上下文。

下面按照这个顺序实现一个主题共享示例。

三、创建主题上下文

先创建一个专门保存主题信息的 Context:

js 复制代码
import { createContext } from "react";

export const ThemeContext = createContext("light");

createContext("light") 创建了一个 Context 对象,并把 "light" 设置为默认值。将它导出后,其他模块便可以用同一个 ThemeContext 提供或读取数据。

这里的默认值需要准确理解:当组件读取 ThemeContext,但它上方没有匹配的 ThemeContext.Provider 时,读取到的才是这个默认值。如果上方存在 Provider,就会读取 Provider 的 value

四、用 Provider 提供共享数据

主题值通常需要变化,因此可以在上层组件中用 useState 保存它,再通过 Provider 向后代组件提供:

jsx 复制代码
import { useState } from "react";
import { ThemeContext } from "./ThemeContext";
import Page from "./components/page";

function App() {
  const [theme, setTheme] = useState("light");

  return (
    <ThemeContext.Provider value={theme}>
      <Page />
      <button onClick={() => setTheme("dark")}>切换主题</button>
    </ThemeContext.Provider>
  );
}

export default App;

这一段代码中,真正把 Context 与状态连接起来的是:

jsx 复制代码
<ThemeContext.Provider value={theme}>

Provider 是上下文的提供者,也是一个容器。它通过 value 接收当前要共享的数据。Page 和按钮都位于 Provider 内部,而 Page 的所有后代组件也处在这个 Provider 的作用范围中。

初次渲染时,theme"light",所以后代消费者读取到 "light"。点击按钮后执行:

js 复制代码
setTheme("dark");

状态更新会触发组件重新渲染,Provider 的 value 随之变成 "dark",读取该 Context 的后代组件也会得到新值。

这个示例中的按钮准确来说是"把主题设置为深色",而不是在浅色与深色之间来回切换。如果希望按钮文字与行为完全一致,可以根据当前值进行切换:

jsx 复制代码
<button
  onClick={() => setTheme(theme === "light" ? "dark" : "light")}
>
  切换主题
</button>

这只是对原有状态更新方式的小幅补全,Context 的使用方式没有变化。

五、用 useContext 消费共享数据

Provider 负责提供,后代组件则通过 useContext 读取。

最直接的写法是:

js 复制代码
import { useContext } from "react";
import { ThemeContext } from "../ThemeContext";

const theme = useContext(ThemeContext);

调用 useContext 时传入的必须是之前创建并导出的同一个 ThemeContext 对象。React 会沿组件树向上寻找距离当前组件最近的对应 Provider,并返回它的 value

如果页面组件和更深层的子组件都需要主题,它们可以分别消费同一份上下文数据,不需要由页面组件继续通过 props 转交。

六、把 useContext 再封装成 useTheme

如果多个组件都要读取主题,每处都重复导入 ThemeContext 并调用 useContext 也可以工作。不过,可以进一步把这项操作封装成一个语义更明确的自定义 Hook:

js 复制代码
import { ThemeContext } from "../ThemeContext";
import { useContext } from "react";

export function useTheme() {
  return useContext(ThemeContext);
}

React 自定义 Hook 按约定以 use 开头。useTheme 内部调用了 useContext,并直接返回当前主题。这样,组件不需要关心主题来自哪个 Context,只需要表达自己的需求:我要使用主题。

js 复制代码
const theme = useTheme();

这种封装带来了两点直接好处:

  • 调用处更简洁,useTheme()useContext(ThemeContext) 更能表达业务含义;
  • Context 的读取逻辑集中在一个 Hook 中,多个消费组件不再重复编写相同代码。

七、不同层级的组件读取同一份主题

页面组件可以读取主题:

jsx 复制代码
import Child from "./Child";
import { useTheme } from "../hooks/useTheme";

const Page = () => {
  const theme = useTheme();
  console.log(theme);

  return (
    <>
      Page{theme}
      <br />
      <Child />
    </>
  );
};

export default Page;

Page 调用 useTheme() 后,会在页面上显示 PagelightPagedark,同时继续渲染 Child。关键在于,Page 没有把 theme 作为属性传给 Child

jsx 复制代码
<Child />

子组件依然可以自行连接上下文:

jsx 复制代码
import { useTheme } from "../hooks/useTheme";

function Child() {
  const theme = useTheme();
  console.log(theme);

  return (
    <>
      Child
      <button className={theme}>按钮{theme}</button>
    </>
  );
}

export default Child;

此时组件之间的数据路径可以表示为:

text 复制代码
App 中的 theme 状态
        ↓
ThemeContext.Provider 的 value
        ↓
   Page 调用 useTheme()
        ↓
   Child 调用 useTheme()

更准确地说,PageChild 是各自从 Context 中读取同一个 Provider 的值,并不是 Page 读取后再传给 Child。这就是 Context 省去逐层传值的核心。

Child 还把主题值用于按钮的类名:

jsx 复制代码
<button className={theme}>按钮{theme}</button>

主题为 "light" 时类名是 light,主题为 "dark" 时类名是 dark。当前示例重点在于共享数据,若没有为 .light.dark 编写对应样式,类名虽然会变化,按钮外观却不会因此自动改变。

八、什么是自定义 Hook

前面的 useTheme 是最轻量的一类自定义 Hook:它包装另一个 Hook,并提供更清晰的业务名称。

自定义 Hook 还可以封装更完整的 React 逻辑,例如:

  • useState 管理响应式数据;
  • useEffect 注册和清理副作用;
  • 向组件返回它真正需要的状态。

普通工具函数不能在任意位置调用 React Hook,而自定义 Hook 本身遵守 Hook 的调用规则,因此可以组合 React 提供的 Hook。接下来用鼠标坐标示例观察这种封装方式。

九、封装 useMouse:让鼠标坐标成为响应式数据

需求很直接:监听鼠标移动,并把鼠标在浏览器可视区域中的横纵坐标展示到页面上。

首先把状态与事件监听封装进 useMouse

js 复制代码
import { useState, useEffect } from "react";

export const useMouse = () => {
  const [x, setX] = useState(null);
  const [y, setY] = useState(null);

  useEffect(() => {
    document.addEventListener("mousemove", handleMouseMove);

    return () => {
      document.removeEventListener("mousemove", handleMouseMove);
    };

    function handleMouseMove(e) {
      setX(e.clientX);
      setY(e.clientY);
    }
  });

  return { x, y };
};

这段 Hook 可以拆成三部分理解。

1. 用 useState 保存坐标

js 复制代码
const [x, setX] = useState(null);
const [y, setY] = useState(null);

初始值为 null,表示鼠标移动事件尚未产生,当前还没有可展示的坐标。每次坐标变化后,调用 setXsetY 更新状态,使用这个 Hook 的组件就会重新渲染。

2. 用 useEffect 注册鼠标事件

js 复制代码
document.addEventListener("mousemove", handleMouseMove);

事件对象的 clientXclientY 分别表示鼠标相对于浏览器可视区域的横坐标和纵坐标:

js 复制代码
function handleMouseMove(e) {
  setX(e.clientX);
  setY(e.clientY);
}

事件每触发一次,就把最新坐标写入状态。

3. 在清理函数中移除监听

useEffect 可以返回一个清理函数:

js 复制代码
return () => {
  document.removeEventListener("mousemove", handleMouseMove);
};

事件监听属于需要主动清理的副作用。组件卸载时,应移除之前注册的监听,避免组件已经不存在,监听函数却仍被保留。定时器、Worker 实例等持续存在的资源,也需要关注相应的清理工作。

注册和移除时使用的是同一个 handleMouseMove 函数引用,因此 removeEventListener 能正确找到并移除对应监听。

十、useEffect 依赖数组对这个示例的影响

上面的代码没有给 useEffect 传入依赖数组:

js 复制代码
useEffect(() => {
  // 注册监听并返回清理函数
});

这意味着每次渲染后 Effect 都会执行。鼠标移动会更新状态并触发渲染;下一次 Effect 执行前,React 会先运行上一次的清理函数,再重新注册监听。因此它不会一直无上限地叠加同一个事件,但会发生没有必要的反复移除和注册。

这个监听只需要在组件挂载后注册一次,并在卸载时清理一次,因此更合适的写法是在末尾传入空依赖数组:

js 复制代码
useEffect(() => {
  document.addEventListener("mousemove", handleMouseMove);

  return () => {
    document.removeEventListener("mousemove", handleMouseMove);
  };

  function handleMouseMove(e) {
    setX(e.clientX);
    setY(e.clientY);
  }
}, []);

空数组表示这个 Effect 不依赖组件渲染过程中会变化的值。这样,监听的生命周期就与使用该 Hook 的组件生命周期对应起来。

十一、组件如何使用 useMouse

自定义 Hook 返回一个包含 xy 的对象,组件可以直接解构:

jsx 复制代码
import { useMouse } from "./hooks/useMouse";

function App() {
  const { x, y } = useMouse();

  return (
    <div
      style={{
        height: "100vh",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      {x && y ? `x:${x},y:${y}` : "鼠标未移动"}
    </div>
  );
}

export default App;

组件只负责两件事:调用 useMouse() 获取数据,以及决定如何展示数据。至于事件怎么注册、坐标怎么更新、监听怎么清理,都被收进了 Hook 内部。

这体现了自定义 Hook 的价值:它复用的不是一段界面,而是一套带状态和副作用的逻辑。

外层 div 使用行内样式占满视口高度,并通过 Flex 布局让文字水平、垂直居中:

js 复制代码
{
  height: "100vh",
  display: "flex",
  alignItems: "center",
  justifyContent: "center"
}

鼠标尚未移动时,xy 都是 null,页面显示"鼠标未移动";移动后显示类似:

text 复制代码
x:428,y:236

一个边界值细节

原判断使用了:

jsx 复制代码
x && y ? `x:${x},y:${y}` : "鼠标未移动"

JavaScript 中的数字 0 是假值。当鼠标位于视口最左侧使 x0,或位于最上侧使 y0 时,即使鼠标已经移动,判断结果仍可能显示"鼠标未移动"。

由于初始状态明确使用 null 表示"没有坐标",可以直接判断是否为 null

jsx 复制代码
{x !== null && y !== null
  ? `x:${x},y:${y}`
  : "鼠标未移动"}

这样既保持了原来的状态设计,也能正确显示坐标 0

十二、应用入口与 StrictMode

React 应用通过 createRoot 挂载到 HTML 中的根节点:

jsx 复制代码
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.jsx";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

页面中预先准备了容器:

html 复制代码
<div id="root"></div>

document.getElementById("root") 找到它,createRoot(...).render(...) 再把 React 组件树渲染进去。全局样式也在入口处导入。

应用被 StrictMode 包裹。对于鼠标监听这样的 Effect,清理函数尤其重要:开发阶段对副作用进行检查时,正确成对的"注册---清理"可以保证监听资源始终受控。

十三、两个自定义 Hook 的共同思路

useThemeuseMouse 看起来用途不同,但抽象方式是一致的。

useTheme 隐藏的是 Context 的读取细节:

js 复制代码
export function useTheme() {
  return useContext(ThemeContext);
}

useMouse 隐藏的是状态管理与 DOM 事件副作用:

js 复制代码
export const useMouse = () => {
  // 保存坐标
  // 注册并清理事件
  // 返回组件所需数据
};

组件最终得到的都是简单、直接的调用接口:

js 复制代码
const theme = useTheme();
const { x, y } = useMouse();

因此,自定义 Hook 不只是"把代码搬到另一个文件"。更重要的是,它把一项 React 业务能力包装成明确的接口,让组件专注于渲染,让状态、上下文或副作用的细节集中管理。

十四、完整知识链路回顾

Context 解决跨层级通信

text 复制代码
createContext 创建上下文
        ↓
Provider 通过 value 提供数据
        ↓
useContext 在任意后代层级读取数据

在主题示例中,theme 由上层状态管理,PageChild 各自通过 useTheme 消费同一个上下文。中间不需要逐层传递 props

自定义 Hook 复用 React 逻辑

text 复制代码
useState 保存响应式数据
        ↓
useEffect 管理事件副作用
        ↓
清理函数释放事件监听
        ↓
Hook 返回组件真正需要的数据

在鼠标示例中,组件不再直接处理 mousemove 事件,只需调用 useMouse 并渲染坐标。

总结

当数据只需要在直接父子组件之间流动时,props 依然是简单清晰的选择;当同一份数据要跨越较深的组件层级时,可以创建 Context,由合适层级的 Provider 提供,再由后代通过 useContext 读取。

当多个组件需要复用带状态或副作用的逻辑时,可以把它封装成以 use 开头的自定义 Hook。useTheme 让上下文读取更有语义,useMouse 则把状态、事件监听和资源清理组合成一项可直接使用的能力。

这两个例子共同展示了一种清晰的组件设计方向:组件负责展示,自定义 Hook 负责组织可复用逻辑,Context 负责在合适的组件树范围内共享数据。

相关推荐
用户921080262861 小时前
ChatComposer 输入区改造:基于技能市场实现 Agent 输入框
前端
用户45989204565161 小时前
一套 KMP 多仓库版本治理工作流:用 Version Catalog + CI 把发版流水线自动化
前端·kotlin
小赵同学WoW1 小时前
1-4 TypeScript 中的 `number`、`bigint` 与 `string`
前端
小赵同学WoW1 小时前
1-3 `boolean` 与字面量类型
前端
光影少年1 小时前
react navite 长列表优化:FlatList / SectionList 原理与优化属性
前端·react native·react.js
cindershade1 小时前
鼠标 3D 倾斜卡片
前端
wordbaby1 小时前
从双端内卷到一库多端:基于 pnpm + Taro 4 + RNOH 鸿蒙的 Monorepo 跨端架构实战
前端·微信小程序·app
子兮曰1 小时前
DeepSeek Harness 架构深潜:一个把 Agent 运行时做成纯插件树的开源 Harness
前端·后端·deepseek
子兮曰2 小时前
AI Agent 完整入门指南:从 LLM 到生产落地的 30+ 个核心概念
前端·后端·agent