React(十一):深入理解 useEffect —— 副作用处理的终极指南
目录
一、核心定义:什么是 useEffect
1.1 通俗理解 useEffect 的定位
React 函数组件本身是一个"纯渲染函数"——给定 props 和 state,返回要渲染的 UI。但真实世界的应用远不止渲染这么简单:
- 你需要从服务器拉取数据显示在页面上
- 你需要监听浏览器窗口大小变化来调整布局
- 你需要订阅 WebSocket 消息实现实时通信
- 你需要操作 DOM 节点(比如设置焦点、测量元素尺寸)
- 你需要设置定时器轮询后台状态
这些操作在 React 中统称为 "副作用"(Side Effect)——因为它们发生在正常的渲染流程之外,会影响组件的外部世界或被外部世界影响。
useEffect 就是 React 为函数组件提供的"副作用处理通道"。 它让你在组件渲染完成后,安全地执行这些副作用操作,而不会阻塞浏览器绘制 UI。
一句话总结:useEffect 是 React 函数组件中处理副作用的唯一标准钩子,解决了函数组件"只会渲染、不会做杂活"的痛点。
1.2 useEffect 与 class 生命周期方法的对应关系
如果你有 class 组件的学习背景,可以把 useEffect 统一看作以下三个生命周期方法的合并体:
| class 组件生命周期 | useEffect 中的对应形态 | 触发时机 |
|---|---|---|
componentDidMount |
useEffect(() => {...}, []) 空依赖数组 |
组件首次渲染完成后执行一次 |
componentDidUpdate |
useEffect(() => {...}, [dep]) 指定依赖 |
依赖项变化后执行 |
componentWillUnmount |
useEffect(() => { ... return () => {...} }, []) 返回清理函数 |
组件卸载前执行清理逻辑 |
关键区别在于:class 组件是按 "生命周期阶段" 拆分的,而 useEffect 是按 "关注点" 拆分的。同一个关注点的挂载、更新、卸载逻辑可以写在一个 useEffect 里,代码更内聚。
1.3 官方语法格式逐行拆解
useEffect(setup: () => void | (() => void), dependencies?: DependencyList);
逐行拆解每个参数的含义:
第一个参数:setup 函数
() => void | (() => void)
- 它是一个函数,在组件渲染到屏幕之后异步执行(不会阻塞浏览器绘制)
- 函数体内部是你想要执行的副作用逻辑(发请求、绑事件、设定时器等)
- 这个函数可以返回另一个函数(称为"清理函数"或"销毁函数"),React 会在组件卸载时、或下一次副作用执行前调用这个清理函数
useEffect(() => {
// 这里是副作用逻辑 —— 相当于 "挂载" + "更新"
console.log("副作用执行了");
return () => {
// 这里是清理逻辑 —— 相当于 "卸载前清理"
console.log("清理上一次的副作用");
};
});
第二个参数:dependencies(可选依赖项数组)
dependencies?: DependencyList
- 类型:
any[],一个包含变量引用的数组,比如[count, userName] - 作用:它是控制副作用执行时机的核心开关。React 会用
Object.is()对数组中每一项做浅比较:- 如果依赖项没有变化,React 会跳过本次副作用执行
- 如果依赖项发生了变化,React 会先执行上一次的清理函数,再执行新的 setup 函数
- 三种传参情况(后续 2.5 节有详细对比表):
| 传参方式 | 含义 |
|---|---|
| 不传第二个参数 | 每次渲染后都执行副作用 |
传空数组 [] |
只在首次渲染后执行一次 |
传 [a, b] |
首次渲染后执行,且之后只有当 a 或 b 变化时才重新执行 |
1.4 React 19 严格模式下的执行规则变化
React 19 引入了并发特性(Concurrent Features),对严格模式(<StrictMode>)下的 useEffect 行为做了重要调整:
核心规则:在开发环境下,当组件被
<StrictMode>包裹时,useEffect 的 setup 函数会被额外执行一次(即:setup → cleanup → setup)。
这背后的设计初衷是什么?
React 19 支持"可中断渲染"——组件可能会被挂载、卸载、再挂载,而用户看到的只是最终状态。如果副作用没有正确的清理逻辑,这种"隐式卸载再挂载"就会导致内存泄漏或状态错乱。
React 通过开发环境下多执行一轮 setup → cleanup → setup,来模拟这种场景,帮助你提前暴露问题:
// 开发环境 + StrictMode 下的执行顺序:
// 1. 组件渲染
// 2. setup 执行(第一次)
// 3. cleanup 执行(模拟卸载)
// 4. setup 执行(第二次,模拟重新挂载)
// 5. 组件正常展示
// 生产环境下的执行顺序:
// 1. 组件渲染
// 2. setup 执行(只执行一次)
// 3. 组件正常展示
实践意义:如果你在开发环境看到 useEffect 里的 console.log 打印了两次,不要惊慌——这是 React 19 刻意为之,说明你的组件被 StrictMode 包裹,React 正在帮你检查副作用是否正确清理。
二、实践使用:四大高频场景
2.1 仅执行一次的副作用(类似 componentDidMount)
场景描述:组件首次渲染完成后,从后端接口获取文章列表数据并显示,整个生命周期只请求一次。
import { useState, useEffect } from "react";
interface Article {
id: number;
title: string;
content: string;
}
function ArticleList() {
// 状态管理:文章列表、加载状态、错误信息
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// 标记组件是否已卸载,用于避免在卸载后更新状态
let cancelled = false;
// 定义异步获取数据的函数
async function fetchArticles() {
try {
setLoading(true);
setError(null);
// 发起网络请求
const response = await fetch("/api/articles");
// 检查响应状态
if (!response.ok) {
throw new Error(`请求失败,状态码:${response.status}`);
}
const data: Article[] = await response.json();
// 只在组件未卸载时更新状态(避免内存泄漏警告)
if (!cancelled) {
setArticles(data);
}
} catch (err) {
// 捕获网络异常、JSON 解析异常等
if (!cancelled) {
setError(err instanceof Error ? err.message : "未知错误");
}
} finally {
// 无论成功或失败,都结束 loading
if (!cancelled) {
setLoading(false);
}
}
}
fetchArticles();
// 清理函数:组件卸载时将 cancelled 置为 true
return () => {
cancelled = true;
};
}, []); // 空依赖数组 → 只在首次渲染后执行一次
// 渲染逻辑:根据状态展示不同 UI
if (loading) {
return <div className="loading">加载中...</div>;
}
if (error) {
return <div className="error">出错了:{error}</div>;
}
return (
<ul className="article-list">
{articles.map((article) => (
<li key={article.id}>
<h3>{article.title}</h3>
<p>{article.content}</p>
</li>
))}
</ul>
);
}
执行时机拆解:
- 组件首次渲染 → 生成初始 DOM
- 浏览器绘制完成后 → useEffect 的 setup 函数被调用
fetchArticles()发起网络请求- 请求返回后 →
setArticles(data)触发重新渲染 → 页面展示文章列表 - 由于依赖数组是
[],后续任何 state 变化都不会再触发这个 useEffect - 组件卸载时 → cleanup 函数执行,
cancelled = true阻止卸载后的 setState
关键技巧:
cancelled标志位是一种常见的"竞态条件"处理模式,防止组件卸载后异步操作仍然尝试更新状态。
2.2 依赖状态更新时执行(类似 componentDidUpdate)
场景描述:搜索框输入关键词后自动触发联想搜索。用户每输入一个字符,都自动向服务端请求联想词列表。为避免请求过于频繁,加入防抖逻辑优化。
import { useState, useEffect } from "react";
interface Suggestion {
id: number;
text: string;
}
function SearchBox() {
// 用户输入的关键词
const [keyword, setKeyword] = useState<string>("");
// 防抖后的关键词(真正用于请求的)
const [debouncedKeyword, setDebouncedKeyword] = useState<string>("");
// 联想结果
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
// 加载状态
const [loading, setLoading] = useState<boolean>(false);
// ---- 副作用 1:防抖逻辑 ----
// 当用户输入变化时,延迟 300ms 再更新 debouncedKeyword
useEffect(() => {
// 如果关键词为空,立即清空联想结果
if (keyword.trim() === "") {
setDebouncedKeyword("");
setSuggestions([]);
return;
}
// 设置 300ms 防抖定时器
const timer = setTimeout(() => {
setDebouncedKeyword(keyword);
}, 300);
// 清理函数:用户在新 300ms 内继续输入,清除上一个定时器
return () => {
clearTimeout(timer);
};
}, [keyword]); // 依赖 keyword 变化 → 每次输入都重新执行
// ---- 副作用 2:发起搜索请求 ----
// 当 debouncedKeyword 变化时,向后端请求联想词
useEffect(() => {
// 空关键词不请求
if (debouncedKeyword.trim() === "") {
return;
}
let cancelled = false;
async function fetchSuggestions() {
try {
setLoading(true);
const response = await fetch(
`/api/suggestions?q=${encodeURIComponent(debouncedKeyword)}`,
);
if (!response.ok) {
throw new Error("请求失败");
}
const data: Suggestion[] = await response.json();
if (!cancelled) {
setSuggestions(data);
}
} catch (err) {
if (!cancelled) {
setSuggestions([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchSuggestions();
return () => {
cancelled = true;
};
}, [debouncedKeyword]); // 依赖防抖后的关键词 → 防抖结束后才请求
return (
<div className="search-box">
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="请输入关键词搜索..."
/>
{loading && <span className="loading">搜索中...</span>}
<ul className="suggestions">
{suggestions.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
</div>
);
}
执行时机拆解:
- 用户在输入框输入 "a" →
keyword变为"a"→ 触发副作用 1,启动 300ms 定时器 - 用户紧接着输入 "b"(距上一次小于 300ms)→
keyword变为"ab"→ 副作用 1 的清理函数清除旧定时器 → 启动新定时器 - 用户停止输入满 300ms → 定时器触发 →
debouncedKeyword变为"ab"→ 触发副作用 2,发起实际的网络请求 - 请求返回后,
setSuggestions更新 UI
设计要点:将"防抖"和"请求"拆成两个独立的 useEffect,各司其职,逻辑清晰且易于维护。
2.3 组件卸载前清理(类似 componentWillUnmount)
场景描述:同时演示两个典型清理场景——监听浏览器窗口 resize 事件和订阅 WebSocket 消息,在组件卸载时分别移除事件监听和断开连接。
import { useState, useEffect } from "react";
function WindowSizeMonitor() {
// 存储窗口尺寸
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
// 存储最新一条 WebSocket 消息
const [lastMessage, setLastMessage] = useState<string>("暂无消息");
useEffect(() => {
// ==================== 场景 A:监听窗口 resize 事件 ====================
// 定义事件处理函数(抽成具名函数,方便后续移除)
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// 绑定事件监听
window.addEventListener("resize", handleResize);
// ==================== 场景 B:订阅 WebSocket 消息 ====================
// 创建 WebSocket 连接
const socket = new WebSocket("wss://example.com/socket");
// 连接建立成功的回调
socket.onopen = () => {
console.log("WebSocket 连接已建立");
};
// 收到消息的回调
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setLastMessage(data.content);
} catch {
setLastMessage(event.data);
}
};
// 连接发生错误的回调
socket.onerror = (error) => {
console.error("WebSocket 错误:", error);
};
// ==================== 清理函数:组件卸载时执行 ====================
return () => {
// 移除 resize 事件监听 → 防止内存泄漏
window.removeEventListener("resize", handleResize);
// 关闭 WebSocket 连接 → 释放网络资源
// readyState 检查:如果连接未关闭才调用 close()
if (
socket.readyState === WebSocket.OPEN ||
socket.readyState === WebSocket.CONNECTING
) {
socket.close();
console.log("WebSocket 连接已关闭");
}
};
}, []); // 空依赖数组 → setup 只在挂载时执行,cleanup 只在卸载时执行
return (
<div className="monitor">
<h3>窗口尺寸监听</h3>
<p>宽度:{windowSize.width}px</p>
<p>高度:{windowSize.height}px</p>
<hr />
<h3>WebSocket 最新消息</h3>
<p>{lastMessage}</p>
<p className="hint">
💡 尝试缩放浏览器窗口,观察数值变化;组件卸载后事件和连接会自动清理。
</p>
</div>
);
}
为什么清理逻辑是必要的?
| 不写清理逻辑的后果 | 解释 |
|---|---|
| 内存泄漏 | 事件监听器引用着组件的作用域,组件卸载后监听器仍在内存中,导致垃圾回收无法释放 |
| 状态更新警告 | 组件卸载后事件仍然可能触发 setState,React 会在控制台警告 "Can't perform a React state update on an unmounted component" |
| WebSocket 连接泄漏 | 不断打开新连接却不关闭,服务端和客户端资源持续浪费 |
执行时机拆解:
- 组件首次挂载 → setup 执行 → 绑定 resize 事件 + 建立 WebSocket 连接
- 组件正常渲染,事件和消息持续更新 UI
- 组件从 DOM 中移除 → React 调用 cleanup 函数 → 移除事件 + 关闭 WebSocket
2.4 清理逻辑随依赖更新触发
场景描述:实现一个轮询后台状态的组件。用户可以动态调整轮询间隔(比如 1 秒、3 秒、5 秒)。当间隔参数变化时,自动清除旧定时器,按新间隔生成新定时器。
import { useState, useEffect } from "react";
interface ServerStatus {
cpu: number;
memory: number;
uptime: string;
}
function ServerStatusPolling() {
const [status, setStatus] = useState<ServerStatus | null>(null);
// 轮询间隔(单位:毫秒),默认 3000ms
const [pollInterval, setPollInterval] = useState<number>(3000);
const [lastUpdate, setLastUpdate] = useState<string>("");
useEffect(() => {
// 标记组件是否已卸载
let cancelled = false;
// 定义轮询函数
async function poll() {
try {
const response = await fetch("/api/server-status");
if (!response.ok) throw new Error("获取状态失败");
const data: ServerStatus = await response.json();
if (!cancelled) {
setStatus(data);
setLastUpdate(new Date().toLocaleTimeString());
}
} catch (err) {
if (!cancelled) {
console.error("轮询出错:", err);
}
}
}
// 立即执行一次拉取(避免等待第一个间隔)
poll();
// 启动定时轮询
const timerId = setInterval(poll, pollInterval);
// 清理函数:组件卸载 或 pollInterval 变化时执行
return () => {
cancelled = true;
clearInterval(timerId); // 清除旧定时器,避免多个定时器叠加
console.log(`已清除 ${pollInterval}ms 的轮询定时器`);
};
}, [pollInterval]); // 依赖 pollInterval → 间隔变化时重新执行整个流程
return (
<div className="server-monitor">
<h3>服务器状态监控(轮询)</h3>
{/* 间隔选择器 */}
<div className="interval-selector">
<span>轮询间隔:</span>
{[1000, 3000, 5000, 10000].map((ms) => (
<button
key={ms}
onClick={() => setPollInterval(ms)}
disabled={pollInterval === ms}
style={{
fontWeight: pollInterval === ms ? "bold" : "normal",
marginRight: 8,
}}
>
{ms / 1000}秒
</button>
))}
</div>
{/* 状态展示 */}
{status ? (
<div className="status-card">
<p>CPU 使用率:{status.cpu}%</p>
<p>内存使用率:{status.memory}%</p>
<p>运行时长:{status.uptime}</p>
<p className="update-time">最后更新:{lastUpdate}</p>
</div>
) : (
<p>正在获取服务器状态...</p>
)}
</div>
);
}
执行时机拆解(以用户从 3 秒切换到 5 秒为例):
- 组件挂载 →
useEffect执行 →setInterval(poll, 3000)启动 3 秒定时器 - 用户点击 "5秒" 按钮 →
setPollInterval(5000)→ 触发重新渲染 - React 检测到
pollInterval从 3000 变为 5000 - 先执行上一次的 cleanup:
clearInterval(timerId)清除 3 秒定时器 - 再执行新的 setup:
setInterval(poll, 5000)创建 5 秒定时器 - 组件卸载 → cleanup 执行 →
clearInterval(timerId)清除最后一个定时器
关键认知:cleanup 函数不仅会在卸载时执行,每次依赖项变化导致副作用重新执行之前也会先执行 cleanup。这保证了"旧的副作用"总是先被清理。
2.5 依赖数组传参对比总结表
| 传参方式 | 代码示例 | 首次渲染后 | 每次重渲染后 | 依赖变化后 | 组件卸载时 | 典型场景 |
|---|---|---|---|---|---|---|
| 不传第二个参数 | useEffect(() => {...}) |
✅ 执行 | ✅ 执行 | — | cleanup 执行 | 极少数场景(一般不推荐) |
传空数组 [] |
useEffect(() => {...}, []) |
✅ 执行 | ❌ 跳过 | ❌ 无依赖 | cleanup 执行 | 初始数据加载、事件绑定、订阅 |
| 传具体依赖 | useEffect(() => {...}, [a, b]) |
✅ 执行 | ❌ 跳过(依赖未变时) | ✅ 执行(a 或 b 变化时) | cleanup 执行 | 搜索联想、表单联动、轮询间隔调整 |
执行流程图解:
┌──────────────┐
│ 组件渲染完成 │
└──────┬───────┘
│
┌──────▼──────────────────────────┐
│ 依赖数组是空数组 [] ? │
└──┬──────────────────┬───────────┘
│ 是 │ 否
┌───────▼──────┐ ┌──────▼───────────────────┐
│ 首次渲染? │ │ 依赖数组未传? │
└──┬──────┬────┘ └──┬────────────────┬───────┘
│ 是 │ 否 │ 是 │ 否(有依赖数组)
┌────▼──┐ ┌─▼──┐ ┌───▼────┐ ┌──────▼──────────────┐
│ 执行 │ │跳过 │ │ 每次执行│ │ 依赖项与上次不同? │
│ setup │ │ │ │ setup │ └──┬──────────────┬───┘
└────────┘ └────┘ └────────┘ │ 是 │ 否
┌────▼────┐ ┌───▼──┐
│ 先 cleanup│ │ 跳过 │
│ 再 setup │ └──────┘
└──────────┘
三、注意事项:五大高频易错点
3.1 依赖项缺失的警告与修复
问题描述:useEffect 内部使用了组件内的变量,但没有把它们放入依赖数组。
❌ 错误示例
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`) // 使用了外部的 userId
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, []); // BUG:依赖数组为空,但内部用到了 userId
// 问题:当 userId prop 从 "1" 变成 "2" 时,
// 因为依赖数组是 [],useEffect 不会重新执行,
// 页面仍然显示 userId=1 的数据
return <div>{/* 渲染用户信息 */}</div>;
}
React 的依赖检查规则:useEffect 内部使用的所有来自组件作用域的响应式变量(state、props、以及由它们计算出的其他变量),都必须作为依赖项声明。
✅ 正确写法
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [userId]); // ✅ 把 userId 加入依赖数组
return <div>{/* 渲染用户信息 */}</div>;
}
useCallback 和 useMemo 的配合用法
当 useEffect 的依赖是一个函数或对象时,如果不做处理,每次渲染都会重新创建引用,导致 useEffect 无意义地重复执行:
import { useState, useEffect, useCallback, useMemo } from "react";
function Dashboard() {
const [count, setCount] = useState(0);
// ❌ 不用 useCallback:fetchData 每次渲染都是新引用
// 会导致下面 useEffect 每次渲染都重新执行
// async function fetchData() { ... }
// ✅ 用 useCallback 稳定函数引用
const fetchData = useCallback(async () => {
console.log("fetching...", count); // count 也需要作为 useCallback 的依赖
}, [count]);
useEffect(() => {
fetchData();
}, [fetchData]); // fetchData 引用稳定,只在 count 真正变化时才执行
// ✅ 用 useMemo 稳定对象引用(类似原理)
const config = useMemo(
() => ({ pageSize: 20, sortBy: "date" }),
[], // 如果对象内部没有响应式依赖,传空数组
);
useEffect(() => {
console.log("配置更新:", config);
}, [config]);
return <button onClick={() => setCount((c) => c + 1)}>+1</button>;
}
3.2 在副作用内部修改依赖状态导致的无限循环
问题描述:在 useEffect 里修改某个 state,又把这个 state 作为依赖项。
❌ 错误示例
function InfiniteLoop() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // 修改了 count
}, [count]); // count 是依赖 → 修改后触发重新渲染 → 再执行 useEffect → 再修改 → 死循环
// 这会导致:
// 渲染 → useEffect → setCount(1) → 渲染 → useEffect → setCount(2) → 渲染 → ...
// 无限循环,页面卡死!
return <div>{count}</div>;
}
死循环的执行链:
count = 0 → 渲染 → useEffect 检测到 count = 0 → setCount(1)
→ count = 1 → 渲染 → useEffect 检测到 count 从 0 变 1 → setCount(2)
→ count = 2 → 渲染 → useEffect 检测到 count 从 1 变 2 → setCount(3)
→ ... 无穷无尽
✅ 解决方案一:使用函数式更新去除依赖
function FixedVersion() {
const [count, setCount] = useState(0);
useEffect(() => {
// ✅ 使用 setCount((prev) => prev + 1),不依赖外部的 count
const timer = setInterval(() => {
setCount((prev) => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []); // ✅ 依赖数组为空,只执行一次
return <div>{count}</div>;
}
✅ 解决方案二:添加条件判断阻止不必要的更新
function ConditionalUpdate() {
const [data, setData] = useState(null);
const [id, setId] = useState(1);
useEffect(() => {
async function loadData() {
const response = await fetch(`/api/data/${id}`);
const result = await response.json();
// ✅ 添加条件判断:只在数据真正不同时才更新
setData((prevData) => {
// 如果新旧数据相同,返回旧数据避免不必要的渲染
if (JSON.stringify(prevData) === JSON.stringify(result)) {
return prevData;
}
return result;
});
}
loadData();
}, [id]); // 依赖 id,但不依赖 data → 不会循环
return <div>{/* 渲染 */}</div>;
}
3.3 异步函数直接作为 setup 参数的错误
问题描述:直接把 async 函数传给 useEffect 的第一个参数。
❌ 错误示例
// ❌ 这是错误的!async 函数的返回值总是一个 Promise,
// 而 useEffect 期望的是 void 或 cleanup 函数,不是 Promise
useEffect(async () => {
const response = await fetch("/api/data");
const data = await response.json();
setData(data);
}, []);
// React 控制台会警告:Effect callbacks are synchronous to prevent race conditions
为什么会报错?
async函数本质上返回一个Promise<void>- useEffect 的第一个参数签名是
() => void | (() => void),不接受 Promise - React 需要 setup 函数返回的清理函数在同步执行时就能拿到,而
async函数的返回值只能是 Promise,React 无法从中解析出清理函数
✅ 正确写法
useEffect(() => {
// ✅ 在 setup 内部定义一个 async 函数
async function fetchData() {
try {
const response = await fetch("/api/data");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setData(data);
} catch (err) {
setError(err instanceof Error ? err.message : "请求失败");
} finally {
setLoading(false);
}
}
// ✅ 然后在 setup 内部调用它
fetchData();
// ✅ 清理函数可以正常返回(同步)
return () => {
// 清理逻辑,比如取消请求
};
}, []);
进阶技巧:使用 AbortController 取消请求
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const response = await fetch("/api/data", {
signal: controller.signal, // 传入信号
});
const data = await response.json();
setData(data);
} catch (err) {
// AbortError 是正常取消,不需要报错
if (err instanceof DOMException && err.name === "AbortError") {
return;
}
setError("请求失败");
}
}
fetchData();
return () => {
controller.abort(); // 组件卸载或依赖变化时取消请求
};
}, []);
3.4 React 19 严格模式下的重复执行问题
问题描述:开发环境升级到 React 19 后,发现 useEffect 里的逻辑总是执行两次,不确定是 bug 还是特性。
这到底是不是 bug?
不是 bug,这是 React 19 + StrictMode 的特性。React 刻意在开发环境下多执行一轮 setup → cleanup → setup,用来帮助开发者发现副作用逻辑中的潜在问题。
场景重现:一个会暴露问题的例子
import { useEffect } from "react";
function LeakyComponent() {
useEffect(() => {
// 场景:连接 WebSocket
const socket = new WebSocket("wss://example.com/socket");
console.log("WebSocket 连接已创建");
socket.onmessage = (event) => {
console.log("收到消息:", event.data);
};
// ❌ 没有返回清理函数!没有 socket.close()
// React 19 开发环境会:创建连接 → 不关闭 → 再创建连接
// 结果:两个 WebSocket 连接同时存在 → 内存泄漏!
}, []);
return <div>Leaky Component</div>;
}
✅ 正确写法:始终提供清理逻辑
function CleanComponent() {
useEffect(() => {
const socket = new WebSocket("wss://example.com/socket");
console.log("WebSocket 连接已创建");
socket.onmessage = (event) => {
console.log("收到消息:", event.data);
};
// ✅ 提供清理函数
return () => {
console.log("正在关闭 WebSocket 连接");
socket.close();
};
}, []);
// React 19 开发环境下会执行:
// 1. "WebSocket 连接已创建"
// 2. "正在关闭 WebSocket 连接" ← StrictMode 模拟卸载
// 3. "WebSocket 连接已创建" ← StrictMode 模拟重新挂载
// 最终只有一个连接,不会泄漏!
return <div>Clean Component</div>;
}
如何识别你的代码是否有潜在问题?
| 检查项 | 潜在风险 | 修复方法 |
|---|---|---|
| useEffect 没有 return 清理函数,但内部有订阅/事件/定时器 | 严格模式下会产生多个连接/监听器 | 添加 cleanup 函数 |
| 异步请求返回后直接 setState,没有检查组件是否已卸载 | 卸载后更新状态报警告 | 使用 cancelled 标志位或 AbortController |
| 手动创建了 DOM 节点的引用但没有在卸载时重置 | 残留引用阻止 GC | 在 cleanup 中置 null |
如果你确实想禁用严格模式(不推荐),可以在入口文件移除
<StrictMode>包裹,但这意味着你放弃了 React 19 帮你提前发现问题的机会,生产环境可能产生难以追踪的 bug。
3.5 在循环、条件判断或嵌套函数中调用 useEffect
问题描述:把 useEffect 写在 if 判断、for 循环或普通函数内部。
这是 React Hooks 的铁律之一:Hooks 必须在函数组件或自定义 Hook 的顶层调用,不能在条件判断、循环或嵌套函数中使用。
❌ 错误示例
function Component({
shouldFetch,
userId,
}: {
shouldFetch: boolean;
userId: string;
}) {
// ❌ 错误 1:在条件判断中调用 useEffect
if (shouldFetch) {
useEffect(() => {
fetch(`/api/users/${userId}`);
}, [userId]);
}
// React 规则:每次渲染时 Hooks 的调用顺序必须完全一致。
// 当 shouldFetch 从 true 变成 false 时,这个 useEffect 不见了,
// React 内部的 hooks 链表就会错乱。
// ❌ 错误 2:在循环中调用 useEffect
const items = [1, 2, 3];
items.forEach((item) => {
useEffect(() => {
// 这是不允许的!
}, [item]);
});
// ❌ 错误 3:在嵌套函数中调用 useEffect
function handleClick() {
useEffect(() => {
// 这是不允许的!
}, []);
}
return <div>...</div>;
}
为什么 React 有这个规则?
React 依赖 Hooks 的调用顺序来区分不同的 Hook 实例。React 内部用链表存储 Hook 状态,按调用顺序一一对应:
第一次渲染:useState(A) → useState(B) → useEffect(C)
第二次渲染:useState(A) → (被跳过) → useEffect(C) ← 顺序乱了!React 无法匹配
✅ 正确写法:把条件移到 useEffect 内部
function Component({
shouldFetch,
userId,
}: {
shouldFetch: boolean;
userId: string;
}) {
useEffect(() => {
// ✅ 把条件判断写在 useEffect 里面
if (!shouldFetch) {
return; // 不能满足条件就提前返回,但 useEffect 本身始终被调用
}
let cancelled = false;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) {
// 处理数据
}
});
return () => {
cancelled = true;
};
}, [shouldFetch, userId]); // ✅ 依赖数组包含所有相关变量
// ✅ 对于多个独立条件,用多个 useEffect 分别处理
useEffect(() => {
if (userId) {
// 处理 userId 相关的副作用
}
}, [userId]);
useEffect(() => {
if (shouldFetch) {
// 处理 shouldFetch 相关的副作用
}
}, [shouldFetch]);
return <div>...</div>;
}
记忆口诀:条件写在 Hook 里面,Hook 写在组件顶层。
四、常见问题答疑(FAQ)
Q1:useEffect 和 useLayoutEffect 有什么区别?
useLayoutEffect 在浏览器绘制之前同步执行,useEffect 在绘制之后异步执行。绝大多数场景使用 useEffect 即可。只在需要读取 DOM 布局信息并同步修改(避免闪烁)时才用 useLayoutEffect。
Q2:useEffect 的回调能是 async 函数吗?
不能。async 函数返回 Promise,而 useEffect 期望返回 void 或 cleanup 函数。需要在 setup 函数内部定义 async 函数再调用(见 3.3 节)。
Q3:为什么我的 useEffect 执行了两次?
在 React 19 开发环境 + StrictMode 下,这是正常行为——React 刻意多执行一轮来帮助发现内存泄漏问题(见 3.4 节)。生产环境只执行一次。
Q4:依赖数组可以不放 state setter(setXxx)吗?
可以。React 保证 useState 返回的 setter 函数引用永远稳定,不会在重渲染时改变,因此可以安全地省略。
Q5:useEffect 和事件处理函数的区别是什么?
事件处理函数由用户交互触发(如点击),useEffect 由渲染本身触发。数据获取这类"渲染后就该发生"的事情应该放在 useEffect 中,而不是 onClick 里。
Q6:如何在 useEffect 中拿到最新的 state 而不把它加入依赖?
使用函数式更新 setState((prev) => prev + 1),或者使用 useRef 存储最新值:
const latestValue = useRef(value);
latestValue.current = value; // 始终保持最新
useEffect(() => {
// 通过 latestValue.current 获取最新值,不会触发重执行
}, []);
Q7:多个 useEffect 的执行顺序是怎样的?
按代码中声明的顺序从上到下依次执行。每个 useEffect 的 cleanup 也在下一次执行前按同样顺序调用。
Q8:useEffect 里可以调用其他 Hooks 吗?
不可以。Hooks 只能在组件或自定义 Hook 的顶层调用。useEffect 内部不能调用 useState、useEffect 等 Hooks。
Q9:空数组 [] 和 undefined(不传)有什么区别?
[]:只在首次渲染后执行一次- 不传第二个参数:每次渲染后都执行(几乎等同于没有任何优化)
Q10:componentWillUnmount 的对应写法是哪个?
useEffect(() => { ... return () => { /* cleanup */ }; }, []) 中的 cleanup 函数。注意 cleanup 不仅会在卸载时执行,依赖变化时也会先执行。
五、实操练习题
练习一:带清理功能的鼠标位置监听组件
要求:
- 在组件中实时显示鼠标在页面中的坐标(x, y)
- 组件卸载时自动移除事件监听
- 证明你的代码在 React 19 严格模式下不会出现内存泄漏
提示:监听 document 的 mousemove 事件;使用 useState 存储坐标;在 useEffect 的 cleanup 中调用 removeEventListener。
📌 点击查看参考答案
import { useState, useEffect } from "react";
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
function handleMouseMove(event: MouseEvent) {
setPosition({ x: event.clientX, y: event.clientY });
}
// 绑定事件
document.addEventListener("mousemove", handleMouseMove);
// 清理:移除事件监听
return () => {
document.removeEventListener("mousemove", handleMouseMove);
};
}, []); // 空依赖 → 只在挂载/卸载时执行
return (
<div
style={{
padding: 20,
border: "2px solid #333",
borderRadius: 8,
textAlign: "center",
fontSize: 20,
}}
>
<p>鼠标当前位置:</p>
<p>
X: {position.x}, Y: {position.y}
</p>
</div>
);
}
export default MouseTracker;
练习二:带防抖的搜索组件
要求:
- 实现一个搜索输入框,输入内容后延迟 500ms 再发起搜索
- 在搜索过程中显示加载状态
- 当输入框内容清空时,立即清空搜索结果
- 使用 AbortController 实现请求取消(组件卸载或新请求发出时取消旧请求)
提示:使用两个 useEffect 分别处理防抖和请求;请求使用 fetch + AbortController。
📌 点击查看参考答案
import { useState, useEffect } from "react";
function DebouncedSearch() {
const [input, setInput] = useState("");
const [debouncedInput, setDebouncedInput] = useState("");
const [results, setResults] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
// 副作用 1:防抖
useEffect(() => {
if (input.trim() === "") {
setDebouncedInput("");
setResults([]);
return;
}
const timer = setTimeout(() => {
setDebouncedInput(input);
}, 500);
return () => clearTimeout(timer);
}, [input]);
// 副作用 2:发起请求(含 AbortController)
useEffect(() => {
if (debouncedInput.trim() === "") return;
const controller = new AbortController();
setLoading(true);
async function search() {
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(debouncedInput)}`,
{ signal: controller.signal },
);
if (!response.ok) throw new Error("搜索失败");
const data: string[] = await response.json();
setResults(data);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// 正常取消,不报错
return;
}
setResults([]);
} finally {
setLoading(false);
}
}
search();
return () => controller.abort();
}, [debouncedInput]);
return (
<div>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入关键词搜索..."
/>
{loading && <span> 搜索中...</span>}
<ul>
{results.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
}
export default DebouncedSearch;
练习三:综合练习——计时器应用
要求:
- 实现一个秒表,包含"开始/暂停"和"重置"功能
- 使用 useEffect 管理定时器的生命周期
- 正确处理组件卸载时的定时器清理
- 思考:依赖数组应该放什么?为什么?(答案在参考代码中)
📌 点击查看参考答案
import { useState, useEffect } from "react";
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
// 不运行时无需设置定时器
if (!isRunning) return;
// 启动定时器
const timer = setInterval(() => {
// ✅ 使用函数式更新,不依赖外部 seconds
setSeconds((prev) => prev + 1);
}, 1000);
// 清理:停止运行或卸载时清除定时器
return () => clearInterval(timer);
}, [isRunning]);
// 思考:依赖数组只放 isRunning,不用放 seconds
// 因为 setSeconds 用了函数式更新 (prev) => prev + 1,不依赖外部 seconds 值
function handleStartPause() {
setIsRunning((prev) => !prev);
}
function handleReset() {
setIsRunning(false);
setSeconds(0);
}
return (
<div style={{ textAlign: "center", fontFamily: "monospace" }}>
<h2>秒表</h2>
<div style={{ fontSize: 48, margin: "20px 0" }}>
{Math.floor(seconds / 60)
.toString()
.padStart(2, "0")}
:{(seconds % 60).toString().padStart(2, "0")}
</div>
<button onClick={handleStartPause}>{isRunning ? "暂停" : "开始"}</button>
<button onClick={handleReset} style={{ marginLeft: 12 }}>
重置
</button>
</div>
);
}
export default Stopwatch;
总结:useEffect 是 React 函数组件中最重要的 Hook 之一。理解它的核心机制——声明副作用 + 依赖控制 + 清理函数——是写好 React 应用的关键。记住三条黄金法则:
- 把副作用写在 useEffect 里,不要在渲染期间执行
- 诚实地声明所有依赖,不要欺骗 React
- 始终考虑清理逻辑,不给应用留后遗症
养成这些习惯,你就能轻松驾驭 React 19 中的副作用处理。