React 组件通信进阶:用 useContext 跨层传值,用自定义 Hook 复用逻辑
在 React 应用中,组件很少是完全孤立的。页面通常由许多组件组成,数据也需要在这些组件之间流动。
父组件向直接子组件传值时,props 足够简单、直观。但当组件层级越来越深,数据需要从顶层传到孙组件、曾孙组件时,如果仍然逐层传递 props,中间组件即使不使用这份数据,也必须负责继续向下转交。这种"层层搬运"会让组件关系变得啰嗦。
这时可以使用 Context:数据由组件树上层统一提供,任意后代组件只要连接到对应的 Context,就能直接读取数据,不必关心自己与提供者之间隔了多少层。
除了组件通信,React 开发中还有另一类常见需求:多个组件可能需要复用同一套带状态、带副作用的逻辑。普通函数适合复用纯计算,而自定义 Hook 可以继续使用 useState、useEffect、useContext 等 Hook,因此更适合封装 React 中的响应式逻辑和副作用逻辑。
本文通过两个小例子讲清楚:
- 使用
createContext、Provider和useContext实现跨层级主题共享; - 使用
useState和useEffect封装一个监听鼠标位置的自定义 Hook。
一、先理解组件通信为什么需要 Context
React 遵循单向数据流。最常见的父子组件通信方式,是父组件通过 props 把数据传给子组件。子组件若要影响父组件中的状态,则通常由父组件传入一个函数,子组件在合适的时机调用它。
直接父子关系下,这种方式非常清晰。但假设组件结构逐渐加深:
jsx
<Parent>
<Child>
<GrandChild>
<GreatGrandChild />
</GrandChild>
</Child>
</Parent>
如果 GreatGrandChild 需要读取 Parent 中的主题数据,使用 props 就意味着数据要依次经过 Child 和 GrandChild。这两个中间组件可能完全不使用主题,却仍要接收并转交它。
Context 解决的正是这类问题:
- 上层组件负责提供数据;
- Provider 范围内的后代组件负责消费数据;
- 数据可以跨越中间层级,不必逐层传递
props。
这里要注意,Context 并不等于"所有数据都必须全局共享"。Provider 可以放在任意合适的组件层级,只有被它包裹的组件树会接收到它提供的值。
二、使用 Context 的三个步骤
使用 Context 可以概括为三个步骤:
- 用
createContext创建上下文; - 用 Context 的
Provider包裹需要共享数据的组件树; - 在后代组件中用
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() 后,会在页面上显示 Pagelight 或 Pagedark,同时继续渲染 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()
更准确地说,Page 和 Child 是各自从 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,表示鼠标移动事件尚未产生,当前还没有可展示的坐标。每次坐标变化后,调用 setX 和 setY 更新状态,使用这个 Hook 的组件就会重新渲染。
2. 用 useEffect 注册鼠标事件
js
document.addEventListener("mousemove", handleMouseMove);
事件对象的 clientX 和 clientY 分别表示鼠标相对于浏览器可视区域的横坐标和纵坐标:
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 返回一个包含 x、y 的对象,组件可以直接解构:
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"
}
鼠标尚未移动时,x、y 都是 null,页面显示"鼠标未移动";移动后显示类似:
text
x:428,y:236
一个边界值细节
原判断使用了:
jsx
x && y ? `x:${x},y:${y}` : "鼠标未移动"
JavaScript 中的数字 0 是假值。当鼠标位于视口最左侧使 x 为 0,或位于最上侧使 y 为 0 时,即使鼠标已经移动,判断结果仍可能显示"鼠标未移动"。
由于初始状态明确使用 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 的共同思路
useTheme 和 useMouse 看起来用途不同,但抽象方式是一致的。
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 由上层状态管理,Page 与 Child 各自通过 useTheme 消费同一个上下文。中间不需要逐层传递 props。
自定义 Hook 复用 React 逻辑
text
useState 保存响应式数据
↓
useEffect 管理事件副作用
↓
清理函数释放事件监听
↓
Hook 返回组件真正需要的数据
在鼠标示例中,组件不再直接处理 mousemove 事件,只需调用 useMouse 并渲染坐标。
总结
当数据只需要在直接父子组件之间流动时,props 依然是简单清晰的选择;当同一份数据要跨越较深的组件层级时,可以创建 Context,由合适层级的 Provider 提供,再由后代通过 useContext 读取。
当多个组件需要复用带状态或副作用的逻辑时,可以把它封装成以 use 开头的自定义 Hook。useTheme 让上下文读取更有语义,useMouse 则把状态、事件监听和资源清理组合成一项可直接使用的能力。
这两个例子共同展示了一种清晰的组件设计方向:组件负责展示,自定义 Hook 负责组织可复用逻辑,Context 负责在合适的组件树范围内共享数据。