第四章: 跨组件通信与逻辑复用

第四章: 跨组件通信与逻辑复用

摘要: 在前几章,我们掌握了通过 Props 进行父子通信,以及在组件内部管理状态和副作用的核心能力。然而,当应用变得复杂,跨越多个层级的“远距离”通信和在组件间共享相似的逻辑就成了新的挑战。本章将直面这两个痛点,首先引入 Context 机制,彻底解决“属性钻探”问题;接着,我们将学习 useRef,掌握在 React 中与 DOM 交互及存储持久化变量的能力;最后,我们将所有知识融会贯通,学习 React 最强大的模式——自定义 Hooks,将组件逻辑提升到前所未有的可复用高度。


在本章中,我们将沿着一条清晰的“问题-解决方案”路径,解锁 React 的高级能力:

  1. 首先,我们将重新审视在 2.2.3 节提出的 “属性钻探” (Prop Drilling) 问题。
  2. 接着,我们将引入 React 官方的解决方案 Context APIuseContext Hook,学习如何在组件树中进行“大范围”的状态共享,这精确对标 Vue 的 provide/inject
  3. 然后,我们将解决另一个常见需求:如何在 React 中直接操作 DOM 元素。我们将学习 useRef Hook 来应对这类场景,它对标 Vue 的 模板引用 (template refs)
  4. 最后,也是本章的最高潮,我们将学习如何将前面学到的所有 Hooks 组合起来,创建属于我们自己的 自定义 Hooks (Custom Hooks),实现优雅、彻底的逻辑复用。

4.1. 解决属性钻探:Context API 与 useContext

本小节核心知识点:

  • Context 提供了一种在组件树中共享“全局”数据的方式,而无需手动地在每一层组件中传递 props。
  • 它精确对标 Vue 的 provide 和 inject
  • React.createContext(): 用于创建一个 Context 对象。
  • <MyContext.Provider value={...}>: Provider 组件,用于“提供”数据。它包裹的任何子组件都能访问到这个 value
  • useContext(MyContext): Hook,用于在子组件中“注入”并读取 Provider 提供的 value

4.1.1. 痛点重现:语义化场景下的属性钻探

我们在 2.2.3 节已经理论上了解了属性钻探。现在,让我们在一个真实场景中感受它的痛苦。假设我们有以下组件结构,App 组件获取了当前登录的用户信息,但只有最深层的 Greeting 组件需要显示用户名。

1
2
3
4
5
6
# src/
├── components/
│ ├── Greeting.tsx # <-- 最终需要 user 数据的组件
│ ├── UserInfoCard.tsx # <-- 中间组件 B
│ └── UserProfilePage.tsx # <-- 中间组件 A
└── App.tsx # <-- 提供 user 数据的顶层组件

App.tsx (数据源)

1
2
3
4
5
6
7
8
import UserProfilePage from './components/UserProfilePage';

function App() {
const currentUser = { name: 'Prorise' };

// App 必须把 currentUser 传给 UserProfilePage
return <UserProfilePage user={currentUser} />;
}

UserProfilePage.tsx (中间人 A)

1
2
3
4
5
6
7
8
9
10
11
import UserInfoCard from './UserInfoCard';

// UserProfilePage 自己不用 user,但必须接收并继续向下传递
function UserProfilePage({ user }) {
return (
<div>
<h1 className="text-2xl font-bold">欢迎来到个人资料页</h1>
<UserInfoCard user={user} />
</div>
);
}

UserInfoCard.tsx (中间人 B)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import Greeting from './Greeting'

// UserInfoCard 自己也不用 user,但必须接收并继续向下传递
function UserInfoCard({ user }: { user: { name: string } }) {
return (
<div className="p-4 border rounded-lg shadow-md">
<p>用户信息卡片</p>
<Greeting user={user} />
</div>
)
}

export default UserInfoCard;

Greeting.tsx (最终消费者)

1
2
3
4
// 只有 Greeting 组件真正使用了 user.name
function Greeting({ user }) {
return <p className="text-lg">你好, {user.name}!</p>;
}

这种层层传递让中间组件变得臃肿且高度耦合,维护起来就是一场噩梦。

4.1.2. 解决方案:三步构建 Context

现在,我们用 Context 来彻底重构这个流程。

第一步:创建 Context 对象

最佳实践是为你的 Context 创建一个单独的文件。

文件路径: src/contexts/UserContext.ts (新建)

1
2
3
4
5
6
7
8
9
import { createContext } from 'react';

// 定义我们希望在 Context 中共享的数据的类型
interface User {
name: string;
}

// 创建 Context 对象,可以提供一个默认值
export const UserContext = createContext <User | null>(null);

第二步:在顶层提供 (Provide) Context

回到我们的 App.tsx,使用 <UserContext.Provider> 来包裹整个应用,并通过 value prop 将数据“注入”到组件树中。

文件路径: src/App.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useState } from 'react';
import { UserContext } from './contexts/UserContext';
import UserProfilePage from './components/UserProfilePage';

function App() {
const [currentUser] = useState({ name: 'Prorise' });

return (
// 1. 用 Provider 包裹子组件
// 2. 将要共享的数据通过 value 属性传递下去
<UserContext.Provider value={currentUser}>
<UserProfilePage />
</UserContext.Provider>
);
}

export default App;

现在,被 Provider 包裹的所有后代组件,无论嵌套多深,都具备了直接访问 currentUser 数据的能力。

第三步:在深层组件中消费 (Consume) Context

这是最激动人心的一步。我们现在可以直接在 Greeting 组件中获取数据,而完全绕过中间组件。

文件路径: src/components/Greeting.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
import { useContext } from 'react';
import { UserContext } from '../contexts/UserContext';

function Greeting() {
// 使用 useContext Hook 直接“注入” UserContext 的值
const user = useContext(UserContext);

// user 可能为 null (如果我们没有提供 Provider),最好做个判断
return <p className="text-lg"> 你好, {user ? user.name : '游客'}! </p>;
}

export default Greeting;

useContext 是迄今为止最简洁、最直观的消费 Context 的方式。

文件路径: src/components/Greeting.tsx (旧版写法,仅作了解)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { UserContext } from '../contexts/UserContext'

function Greeting() {
return (
// 使用 Consumer 组件,它的子元素必须是一个函数
<UserContext.Consumer>
{(user) => (
// 这个函数接收 Context 的值,并返回 JSX
<p className="text-2xl"> 你好, {user ? user.name : '游客'}! </p>
)}
</UserContext.Consumer>
)
}
export default Greeting;

<Context.Consumer> 是一种基于 Render Props 模式的旧方法。当需要消费多个 Context 时,它会导致多层嵌套(俗称“回调地狱”),可读性很差。在现代 React 开发中,应 始终优先使用 useContext Hook


最终成果:解耦的中间组件

现在,我们的中间组件 UserProfilePageUserInfoCard 不再需要关心 user prop,它们变得干净、独立且高度可复用。

UserProfilePage.tsx (重构后)

1
2
3
4
5
6
7
8
9
10
11
import UserInfoCard from './UserInfoCard';

// 不再需要接收和传递 user prop!
function UserProfilePage() {
return (
<div>
<h1 className="text-2xl font-bold"> 欢迎来到个人资料页 </h1>
<UserInfoCard />
</div>
);
}

Context 总结:
Context 是解决 React 中“跨级组件通信”问题的官方标准答案。它允许我们将一些“全局性”的数据(如用户身份、主题、语言设置等)从顶层注入,让任何深度的子组件都能按需、直接地获取,从而实现组件间的彻底解耦。


4.2. 引用与命令式操作:useRef 的双重角色

在 React 的声明式世界里,我们通常不直接操作 DOM。但总有一些场景,我们必须“命令式地”与 DOM 元素交互,比如让一个输入框聚焦。useRef 就是 React 为这类场景提供的官方“后门”。

本小节核心知识点:

  • useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为您传入的参数 (useRef(initialValue))。
  • useRef 有两大核心用途:
    1. 访问 DOM 节点,这精确对标 Vue 的 模板引用
    2. 存储一个不触发组件重新渲染的可变值,类似于 Vue 3 script setup 中一个普通的、非响应式的变量。
  • 改变 ref.current 的值 不会 引起组件的重新渲染。这是它与 useState 的根本区别。

4.2.1. 核心用途一:访问 DOM 元素

痛点背景: 在 Vue 中,我们可以通过给元素添加 ref="myInput" 属性,然后在 <script setup> 中通过 const myInput = ref(null) 来获取该 DOM 元素的引用,并调用它的方法,如 myInput.value.focus()。React 如何实现同样的功能?

范式转变:从模板字符串到 Ref 对象

React 的实现方式思想一致,但语法上更贴近 JavaScript 的对象引用。总共分三步:创建 Ref -> 附加 Ref -> 访问 Ref。

让我们通过一个“点击按钮聚焦输入框”的经典案例来掌握它。

文件路径: src/components/FocusableInput.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { useRef } from "react";

function FocusableInput() {
// 第一步:创建一个 Ref 对象来持有 DOM 节点
// 最佳实践:使用 TypeScript 泛型来指定它将持有的元素类型
const inputRef = useRef<HTMLInputElement>(null);

const handleFocusClick = () => {
// 第三步:通过 .current 属性访问真实的 DOM 节点
// 最佳实践:在使用前检查 .current 是否存在
if (inputRef.current) {
inputRef.current.focus(); // 调用 DOM 元素的 focus() 方法
inputRef.current.value = "Prorise 教程真棒!";
}
};

return (
<div className="text-center p-8">
{/* 第二步:使用 ref 属性将创建的 Ref 对象附加到 DOM 元素上 */}
<input
ref={inputRef}
type="text"
className="border-2 border-gray-400 p-2 rounded"
placeholder="点击按钮来聚焦"
/>
<button
className="ml-2 bg-blue-500 text-white p-2 rounded hover:bg-blue-700"
onClick={handleFocusClick}
>
聚焦并写入内容
</button>
</div>
);
};

export default FocusableInput;

4.2.2. 核心用途二:存储持久化的可变值

痛点背景: 假设我们需要在一个组件中设置一个 setInterval 定时器。我们需要在某处存储这个定时器的 ID,以便在组件卸载或用户点击“停止”按钮时能够调用 clearInterval(timerId) 来清除它。

如果我们用 useState 来存储 timerId,会发生什么?const [timerId, setTimerId] = useState(null)setInterval 返回 ID 后调用 setTimerId(id) 会导致组件不必要地重新渲染。我们只是想存个值,并不想因为这个值的改变而刷新界面。

解决方案:将 useRef 用作“实例变量”

useRef 完美地解决了这个问题。它创建的 ref 对象在组件的整个生命周期内都是同一个对象。我们可以把需要持久化,但又与渲染无关的值,存放在它的 .current 属性中。

文件路径: src/components/IntervalTimer.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { useRef, useEffect, useState } from "react";

function IntervalTimer() {
const [count, setCount] = useState(0);
// 使用 useRef 来存储 interval ID,它的变化不会触发重渲染
const intervalRef = useRef<number | null>(null);

useEffect(() => {
// 在 .current 中保存 setInterval 返回的 ID
intervalRef.current = window.setInterval(() => {
setCount((prevCount) => prevCount + 1);
}, 1000);

// 组件卸载时,从 .current 中取出 ID 并执行清理
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []); // 空依赖数组,确保 effect 只在挂载和卸载时运行一次

const handleStopTimer = () => {
// 用户点击按钮时,同样可以从 .current 中取出 ID 并清除
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};

return (
<div className="text-center p-8">
<h1 className="text-2xl">计时器: {count} 秒</h1>
<button
className="mt-4 bg-red-500 text-white p-2 rounded hover:bg-red-700"
onClick={handleStopTimer}
>
停止计时器
</button>
</div>
);
};

export default IntervalTimer;

在这个例子中,intervalRef 就像一个忠诚的管家,它默默地为我们保管着 intervalId,无论组件因为 count 的变化重新渲染多少次,它都稳定地持有那个值,直到我们需要用它为止。
useState vs. useRef 何时使用?

  • 当你希望值的改变能够触发界面更新时 -> 使用 useState。这是驱动 React 声明式 UI 的核心。
  • 当你需要访问 DOM 元素,或者需要一个值在多次渲染之间保持不变,但又不希望它的改变触发渲染时 -> 使用 useRef

4.3. 逻辑复用的最佳实践:自定义 Hooks

到目前为止,我们已经掌握了 React 提供的所有基础 Hooks。但 React 最强大的地方在于,它允许我们将这些基础工具组合起来,创造出属于我们自己的、可复用的逻辑单元——这就是自定义 Hooks (Custom Hooks)

本小节核心知识点:

  • 自定义 Hook 是一个以 use 开头的 JavaScript 函数,其内部可以调用其他的 Hooks (如 useState, useEffect)。
  • 它是 React 中 实现状态逻辑复用 的首选方式,完美替代了旧有的 HOC 和 Render Props 模式。
  • 自定义 Hook 使得我们将组件中与 UI 无关的逻辑(如:数据请求、事件监听、表单处理)抽离成独立的、可测试的、可在多个组件间共享的单元。

4.3.1. 痛点背景:当组件逻辑开始重复

想象一下,我们应用中的很多组件都需要从 API 获取数据。按照我们已有的知识,每个组件可能都会包含类似下面这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useState, useEffect } from "react";

function PostList() {
const [posts, setPosts] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/posts")
.then((res) => res.json())
.then((data) => setPosts(data))
.catch((err) => setError(err))
.finally(() => setLoading(false));
}, []);

if (loading) return <p>加载中...</p>;
if (error) return <p>错误: {error.message}</p>;

// ... 渲染 posts ...
}

现在,如果另一个组件 CommentList 也需要获取评论数据,我们就得把上面这一大段 useStateuseEffect 的逻辑原封不动地复制粘贴过去,只改一下 URL。这显然违反了 DRY (Don’t Repeat Yourself) 原则,难以维护。

4.3.2. 解决方案:创建你的第一个自定义 Hook

自定义 Hook 就是为了解决这类问题而生的。它让我们能将这部分可复用的 状态逻辑 封装到一个函数中。
自定义 Hook 的两大黄金法则:

  1. 必须以 use 开头: 这是 React Linter 用来识别一个函数是否为 Hook 的硬性规定,例如 useFetchuseToggle
  2. 内部可以调用其他 Hooks: 这是自定义 Hook 的超能力所在,它能组合 useStateuseEffect 等,创造出新的、更强大的 Hook。

现在,让我们根据以下三个示例,由简到繁,一步步构建并使用三个实用的自定义 Hook。

示例一:useToggle - 封装最简单的状态切换

这是自定义 Hook 的 “Hello World”。它封装了一个常见的布尔值切换逻辑。

文件路径: src/hooks/useToggle.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useState } from "react";

// 一个自定义 Hook,用于管理一个布尔状态
const useToggle = (initialValue = false) => {
// 内部使用了 useState
const [value, setValue] = useState(initialValue);

// 封装了状态切换的逻辑
const toggle = () => setValue((prevValue) => !prevValue);

// 返回状态和操作函数,其 API 模仿了 useState
return [value, toggle];
};

export default useToggle;

文件路径: src/components/ToggleComponent.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import useToggle from '../hooks/useToggle'

const ToggleComponent = () => {
// 像使用 useState 一样使用我们自己的 Hook
const [isToggled, toggle] = useToggle(false)

return (
<div className="p-4 border rounded-md my-4">
<button
className="bg-blue-500 text-white p-2 rounded-b-2xl"
onClick={toggle}
>
{isToggled ? '显示' : '隐藏'} 信息
</button>
{isToggled && <p className="mt-2">这是一条可以切换显示的消息!</p>}
</div>
)
}

export default ToggleComponent

示例二:useInput - 简化表单处理

这个 Hook 封装了处理受控输入框 valueonChange 的通用逻辑。

文件路径: src/hooks/useInput.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useState } from 'react'

// 一个自定义 Hook,用于管理输入框状态
const useInput = (initialValue: string = '') => {
const [value, setValue] = useState(initialValue)

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value)
}

// 返回一个对象,包含 input 元素需要的所有 props
// 这样我们可以用 ... 扩展操作符方便地绑定
return {
value,
onChange: handleChange,
}
}

export default useInput

当你写 {…name} 时,实际上是在展开 useInput Hook 返回的对象。让我们看看这个过程:

1
2
3
4
5
6
7
8
// useInput 返回的对象
const name = {
value: "当前输入值",
onChange: handleChange函数
}

// 当你写 <input {...name} /> 时,相当于:
<input value={name.value} onChange={name.onChange} />

文件路径: src/components/FormComponent.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import useInput from '../hooks/useInput'

const FormComponent = () => {
// 为每个输入框独立使用 useInput Hook
const name = useInput('')
const email = useInput('')

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
alert(`姓名: ${name.value}, 邮箱: ${email.value}`)
}

return (
<form
onSubmit={handleSubmit}
className="p-4 border rounded-md my-4 space-y-2"
>
<label>
姓名: <input type="text" {...name} className="border p-1" />
</label>
<label>
邮箱: <input type="email" {...email} className="border p-1" />
</label>
<button type="submit" className="bg-blue-500 text-white p-2 rounded">
提交
</button>
</form>
)
}

export default FormComponent;

示例三:useFetch - 封装异步数据获取(最强示例)

这正是我们最初那个痛点的完美解决方案。它封装了数据、加载状态、错误状态以及数据获取的整个 useEffect 逻辑。

文件路径: src/hooks/useFetch.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { useEffect, useState } from 'react'

// 一个自定义 Hook,用于从 URL 获取数据
const useFetch = (url: string) => {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)

useEffect(() => {
const fetchData = async () => {
setLoading(true)
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error('网络响应失败')
}
const data = await response.json()
setData(data)
} catch (error) {
setError(error as Error)
} finally {
setLoading(false)
}
}
fetchData()
}, [url])

return { data, loading, error }
}

export default useFetch

文件路径: src/components/FetchComponent.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import useFetch from '../hooks/useFetch'

const FetchComponent = () => {
// 一行代码,就获得了数据、加载和错误状态
const { data , loading, error } = useFetch(
'https://jsonplaceholder.typicode.com/posts?_limit=10'
)

if (loading) return <p>加载中...</p>
if (error) return <p>错误: {error.message}</p>

return (
<div className="p-4 border rounded-md my-4">
<h2 className="font-bold text-lg">文章列表</h2>
<ul className="list-disc pl-5">
{data?.map((post: any) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}

export default FetchComponent

4.3.3. 组合与应用

最后,我们可以在 App.jsx 中轻松地将这些由自定义 Hook 驱动的组件组合在一起,每个组件都只关心自己的 UI 呈现,而将复杂的逻辑“外包”给了 Hooks。

文件路径: src/App.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import FetchComponent from "./components/FetchComponent";
import FormComponent from "./components/FormComponent";
import ToggleComponent from "./components/ToggleComponent";

function App() {
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-4">React 自定义 Hooks 示例</h1>
<ToggleComponent />
<FormComponent />
<FetchComponent />
</div>
);
}

export default App;

自定义 Hooks 总结:
自定义 Hooks 是 React 逻辑复用的基石。通过将有状态的逻辑从组件中抽离出来,我们获得了:

  • 高度的可复用性: useFetch 可以在任何需要获取数据的组件中使用。
  • 清晰的关注点分离: 组件可以专注于“做什么”(渲染 UI),而 Hook 则负责“怎么做”(状态管理的细节)。
  • 更强的可读性和可维护性: 组件代码变得极其简洁和声明式。
  • 独立的可测试性: 我们可以脱离 UI,单独对自定义 Hook 的逻辑进行单元测试。

掌握自定义 Hooks,是真正从 React “使用者”迈向“精通者”的关键一步。