使用 Effect 与外部系统同步:渲染后取数据与清理机制
恭喜你来到了这门课程的最后一站! 回顾前面的旅程,你学会了如何用组件描述界面、如何用 Props 传递数据、如何用 State 响应点击并驱动列表渲染。
现在,我们来攻克最后一个、也是实际开发中最为关键的拼图: 当组件显示在屏幕上之后,如何自动去向服务器获取一次数据,并把数据展示给用户?
这就是 React 中最为著名的 useEffect Hook 的用武之地。
1. 什么是 Effect?它与事件有什么不同?
在深入代码之前,我们必须分清 React 组件里的两类逻辑:
- 渲染代码(Rendering code): 位于组件函数的顶层。它只负责读取 props 和 state,进行纯粹的计算,并返回 JSX。渲染代码必须是纯净的,绝不能在里面发送网络请求!
- 事件处理函数(Event handlers): 组件内部由特定用户动作(如点击按钮、输入表单)引发的函数。它们可以包含副作用(如提交购买表单)。
但是,有一种情况上述两者都无法解决: 如果某个操作,不是由用户的某一次具体点击引起的,而是只要组件出现在屏幕上,就必须自动执行呢? 比如:
- 组件刚刚被打开,必须立即向后端获取商品详情;
- 聊天室界面一展开,必须立即连接聊天服务器。
Effect 允许你指定由“渲染自身”引起的副作用,它会在 DOM 提交完成、页面呈现在屏幕上之后执行!
2. 编写 Effect 的三步法
第一步:声明 Effect
从 React 中导入 useEffect,并在组件顶层调用它:
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// 这里的代码会在组件渲染呈现在屏幕上之后运行!
});
return <div />;
}
第二步:指定 Effect 的依赖项(Dependencies)
默认情况下,如果没有传依赖项,Effect 会在每一次重新渲染后都执行。 但绝大多数时候,你并不希望它这么频繁地运行。比如获取数据,你通常只希望在刚打开时拉取一次。
我们通过向 useEffect 传入第二个参数——依赖项数组(Dependency Array),来精确控制它的触发频率:
| 依赖项写法 | 执行时机与行为 |
|---|---|
不写依赖项 useEffect(() => {}) | 每次渲染完成后都会执行一遍 |
空依赖项数组 useEffect(() => {}, []) | 仅在组件初次挂载(出现)在屏幕上时执行一次! 后续重新渲染绝不再跑 |
指定依赖变量 useEffect(() => {}, [userId]) | 在初次挂载时执行一次,以及当 userId 发生改变重新渲染后才再次执行 |
第三步:添加清理函数(Cleanup Function)
某些 Effect 会启动一些需要被撤销的操作(比如打开了 WebSocket 连接、启动了定时器、或者发起了异步请求)。 如果需要清理,让你的 Effect 函数返回一个清理函数:
useEffect(() => {
// 建立连接...
return () => {
// 断开连接,在组件销毁或下一次 Effect 执行前执行!
};
}, []);
3. 核心实战:在操作或挂载之后安全获取数据并展示
现在,我们来实现课程最终目标的核心能力:使用 Effect 获取一次数据并更新到页面上。
在 React 中使用 Effect 获取数据,有一个极其经典且必须规避的陷阱——竞态条件(Race Condition): 假设用户飞快地先点击了“查看用户 A”,又点击了“查看用户 B”:
- 针对 A 的请求先发出,但网络延迟了 3 秒;
- 针对 B 的请求后发出,但网络很快,1 秒就返回了;
- 页面先显示了 B;但 2 秒后,A 的旧数据才姗姗来迟返回,如果不加控制,它就会把 B 的最新数据覆盖掉!
官方推荐的标准解法:使用 ignore 标记
在清理函数中,把一个布尔值 ignore 设为 true:
import { useState, useEffect } from 'react';
export default function UserProfile({ userId }) {
const [person, setPerson] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let ignore = false; // 1. 声明一个忽略标记
setLoading(true);
async function startFetching() {
// 模拟向远程 API 获取数据
const data = await fetchUserData(userId);
// 2. 如果组件在数据返回前已经被切换或销毁,忽略这次过期的数据
if (!ignore) {
setPerson(data);
setLoading(false);
}
}
startFetching();
// 3. 清理函数:当 userId 改变或组件卸载时触发
return () => {
ignore = true;
};
}, [userId]); // 仅在 userId 改变时重新拉取!
if (loading) {
return <p>正在飞速加载用户信息...</p>;
}
return (
<div className="profile-card">
<h2>{person.name}</h2>
<p>职业: {person.title}</p>
<p>简介: {person.bio}</p>
</div>
);
}
// 模拟异步网络接口
function fetchUserData(id) {
return new Promise(resolve => {
setTimeout(() => {
resolve({
id: id,
name: id === '1' ? '居里夫人' : '爱因斯坦',
title: id === '1' ? '物理学与化学先锋' : '理论物理学家',
bio: id === '1' ? '两度荣获诺贝尔奖的伟大科学家。' : '提出相对论,重塑了人类的时空观。'
});
}, 800);
});
}
发生了什么?
- 当
userId传入时,组件初次渲染显示“正在飞速加载…”; - 渲染提交后,
useEffect启动,异步拉取对应数据; - 数据返回时,
ignore依然是false,调用setPerson(data)触发重新渲染,卡片信息亮起! - 如果中途
userId突然切换,上一次 Effect 的清理函数立即执行将ignore设为true,旧网络请求即便稍后返回也不会产生任何污染!
4. 全课程终极融会贯通:打造你的完整小界面
现在,让我们把整门课程学过的所有积木,拼成一个真正完整的 React 小界面!
要求包含:
- 几个独立的组件(App、InputArea、ArticleList、ProfileCard);
- 使用 Props 传递数据;
- 点击按钮更新 State;
- 在列表中渲染一项一项的内容(map + key);
- 使用 Effect 在挂载后获取一次数据并展示到页面上。
import { useState, useEffect } from 'react';
// 组件 1:负责数据展示的作者名片(展示 Effect 异步获取的数据)
function AuthorCard() {
const [author, setAuthor] = useState(null);
useEffect(() => {
let ignore = false;
async function loadAuthor() {
// 模拟在挂载后取一次数据
const data = await new Promise(r => setTimeout(() => r({
name: 'React 研习者',
badge: '全栈筑基达成'
}), 500));
if (!ignore) {
setAuthor(data);
}
}
loadAuthor();
return () => { ignore = true; };
}, []); // 空数组,只在页面挂载后获取一次!
if (!author) return <p>加载作者信息中...</p>;
return (
<div style={{ background: '#eef2ff', padding: '10px', borderRadius: '6px', marginBottom: '15px' }}>
<strong>{author.name}</strong> —— <span style={{ color: '#4f46e5' }}>{author.badge}</span>
</div>
);
}
// 组件 2:输入控制区,通过 props 传递输入状态与添加回调
function InputArea({ text, onTextChange, onAdd }) {
return (
<div style={{ marginBottom: '15px' }}>
<input
placeholder="写下你今天学到的 React 概念..."
value={text}
onChange={e => onTextChange(e.target.value)}
style={{ padding: '8px', width: '260px', marginRight: '8px' }}
/>
<button onClick={onAdd} style={{ padding: '8px 16px' }}>
添加到复习清单
</button>
</div>
);
}
// 组件 3:列表展示区,展示列表渲染与删除更新
function NoteList({ items, onDelete }) {
if (items.length === 0) {
return <p style={{ color: '#888' }}>复习清单为空,快添加一条吧!</p>;
}
return (
<ul>
{items.map(item => (
<li key={item.id} style={{ marginBottom: '8px' }}>
<span>{item.content}</span>
<button
onClick={() => onDelete(item.id)}
style={{ marginLeft: '12px', color: '#ef4444' }}
>
删除
</button>
</li>
))}
</ul>
);
}
// 顶层主组件:协调状态与数据流动
export default function App() {
const [inputText, setInputText] = useState('');
const [notes, setNotes] = useState([
{ id: 1, content: '组件是大写开头的 JavaScript 纯函数' },
{ id: 2, content: '使用 Props 自顶向下单向传递数据' },
{ id: 3, content: '列表渲染必须指定稳定唯一的 key' }
]);
// 点击添加:更新 State 数组
function handleAdd() {
if (!inputText.trim()) return;
setNotes([
...notes,
{ id: Date.now(), content: inputText.trim() }
]);
setInputText('');
}
// 点击删除:用 filter 不可变更新
function handleDelete(id) {
setNotes(notes.filter(n => n.id !== id));
}
return (
<div style={{ maxWidth: '480px', margin: '40px auto', fontFamily: 'sans-serif' }}>
<h1>我的 React 知识看板</h1>
<AuthorCard />
<InputArea
text={inputText}
onTextChange={setInputText}
onAdd={handleAdd}
/>
<NoteList
items={notes}
onDelete={handleDelete}
/>
</div>
);
}
结语
你已经完成了整个系统性 React 官方课程的学习! 你从零开始,走过了组件声明、JSX、Props、条件与列表渲染,跨越了事件响应、useState、渲染流水线与不可变对象数组更新,掌握了状态机、状态提升、Reducer 与 Context 的全套状态管理架构,并最终掌握了 Ref 与 Effect 这一走出 React 同步外部世界的强大脱围机制。
现在的你,已经完全有能力亲手独立搭建出架构健壮、性能优良、可读性极佳的现代 React 应用程序!