创作者: Yardon | GitHub: github.com/YardonYan | 版本: v1.0
什么是副作用
在讨论 useEffect 之前,我们需要先理解「副作用」这个词在编程里的含义。
一个函数如果只根据输入计算输出,不做任何其他事情------比如读取参数、做计算、返回结果------我们就说它是纯函数 。useState 返回的 UI 描述就是纯函数:你给同样的 props,返回同样的 JSX。
但现实世界不是那么干净的。网页需要:
- 调用 API 获取数据
- 订阅浏览器事件(滚动、窗口大小变化)
- 设置定时器(setTimeout、setInterval)
- 操作 DOM(聚焦输入框、滚动页面)
- 记录日志到控制台
这些动作有一个共同特点:它们超出了"描述 UI"的范围,触达了外部世界。在 React 里,这类行为统一叫做「副作用(Side Effects)」。
副作用 = 任何影响组件外事物的行为
useEffect 的基本用法
useEffect 的签名大概是 React 里最容易被误解的 API。
jsx
import { useEffect } from 'react';
useEffect(() => {
// 这里是副作用逻辑
// ...
}, [dependencies]); // 依赖数组
这行代码翻译成人话是:
"每次当
dependencies数组里的任何一个值变了,就运行这个副作用。组件第一次渲染时也要跑一次。"
三种执行时机
时机 1:只跑一次(组件挂载时)
jsx
useEffect(() => {
console.log('组件挂载了,只跑这一次');
}, []); // 空数组 = 永远不重复
空数组的意思是"没有任何依赖项",所以只在首次渲染时运行一次。这常用于:发送初始请求、设置一次性事件监听、操作 DOM(比如聚焦输入框)。
时机 2:依赖某变量,每次变化都跑
jsx
useEffect(() => {
document.title = `你看了 ${count} 次`;
}, [count]); // count 变了就重新运行
时机 3:没有任何依赖,每次渲染都跑
jsx
useEffect(() => {
console.log('每次渲染都跑');
// 几乎不用,除非你知道你在干什么
});
useEffect 不是 componentDidMount / componentWillUnmount
很多从类组件转过来的开发者会把 useEffect 当成 componentDidMount 来用,这是个思维陷阱。
类组件时代:
jsx
componentDidMount() {
this.fetchData();
}
componentWillUnmount() {
this.timer && clearInterval(this.timer);
}
函数组件时代:
jsx
useEffect(() => {
fetchData();
return () => clearInterval(this.timer);
}, []);
关键是 return 出去的函数就是清理函数------它会在下次 effect 重新运行前和组件卸载时自动调用。把它想象成「拆帐篷」:搭帐篷之前先想好怎么拆,你就不会留下烂摊子。
依赖数组:控制何时运行
依赖数组是 useEffect 最容易出错的地方。规则只有一条,但很多人理解歪了:
声明你用到的所有变量,一个都不能少,一个也不能多。
jsx
function SearchBar({ query }) {
useEffect(() => {
const timeout = setTimeout(() => {
console.log('搜索:', query);
// 发请求...
}, 300);
return () => clearTimeout(timeout); // 清理上次的定时器
}, [query]); // 依赖 query
}
这个例子里的防抖逻辑:
- 用户输入时,query 变了
- 旧的 effect 清理(清除旧定时器)
- 新的 effect 执行(设新定时器)
- 如果用户在 300ms 内继续输入,只会触发最后一次的搜索
常见错误:遗漏依赖
jsx
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, []); // ❌ 少了 userId!第一次后永远不会再请求
}
eslint 会警告你这个错误,但很多人会习惯性加 // eslint-disable-next-line。别这样做------遗漏依赖意味着 effect 的逻辑已经和它实际使用的数据不同步了。
常见错误:把函数放进依赖数组
jsx
function App() {
function handleClick() {
console.log('clicked');
}
useEffect(() => {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [handleClick]); // ❌ handleClick 在每次渲染时是新函数
}
函数依赖的问题在于:每次父组件渲染,子组件都会得到一个新的 handleClick 函数引用(即使内容完全相同)。这会触发 effect 反复运行。
解决方案:把函数移到 effect 内部 (如果它只用到了 props/hooks 的数据)或者用 useCallback(如果需要在 effect 外复用同一个引用)。
jsx
useEffect(() => {
function handleClick() {
console.log('clicked');
}
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []); // ✅ 没有函数依赖了
数据获取:最常见的副作用
最简单的方式
jsx
function BlogList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/posts')
.then(res => {
if (!res.ok) throw new Error('请求失败');
return res.json();
})
.then(data => {
setPosts(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return <div>加载中...</div>;
if (error) return <div>出错了: {error}</div>;
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
这个例子展示了数据获取的标准模式:loading → success/error 三种状态。每一个真实项目里的数据请求都会遇到这三种情况。
更现代的方式:async/await
jsx
useEffect(() => {
async function fetchData() {
try {
setLoading(true);
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchData();
}, [userId]);
async/await 让代码看起来更像同步的线性流程,比嵌套的 .then().then().catch() 更好读。唯一的注意点:async 函数不能直接传给 useEffect(因为 useEffect 希望返回值是 undefined 或一个清理函数),所以要在里面再套一个 async 函数。
useEffect 的清理函数
清理函数是避免内存泄漏和 bug 的关键。看看几个典型场景:
场景 1:订阅/取消订阅
jsx
useEffect(() => {
const handler = (e) => console.log('窗口滚动了', e);
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, []);
如果不清理,组件卸载后事件处理器还在运行------用户滚动页面时,React 试图更新一个已经不存在的组件,报错。内存泄漏只是时间问题。
场景 2:定时器
jsx
useEffect(() => {
const interval = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
场景 3:撤销副作用对 DOM 的修改
如果你的 effect 在 DOM 上做了修改(比如给元素加了类、修改了样式),卸载时应该恢复原状:
jsx
useEffect(() => {
const sidebar = document.querySelector('.sidebar');
sidebar.classList.add('open'); // 打开
return () => sidebar.classList.remove('open'); // 关闭
}, []);
常见错误:死循环与竞态
死循环
jsx
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // ❌ 每次渲染都触发,又触发渲染,死循环!
}, []);
另一个常见的死循环是把 state 放进依赖数组但同时在 effect 里更新它:
jsx
useEffect(() => {
if (data && data.length > 0) {
setTotal(data.length); // 更新 total
}
}, [data]);
useEffect(() => {
fetchData(); // 每次 total 变都重新请求
}, [total]); // ❌ 总在变化
经验:effect 里更新 state 时,一定要注意依赖数组里有没有被更新的那个 state。
竞态:最容易被忽视的 bug
当你在 effect 里发请求,但数据回来之前组件已经卸载或者又触发了一次新的请求------就可能出现竞态。
想象你搜索"北京",然后立刻搜索"上海":
- 请求 A("北京")发出去
- 请求 B("上海")发出去
- 请求 A 慢,请求 B 先回来 → 页面显示北京
- 请求 A 终于回来了 → 页面又变成北京,覆盖了上海!
这就是经典的竞态条件。
解决方案:用 AbortController 取消旧请求
jsx
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') throw err; // 忽略主动取消的错误
});
return () => controller.abort(); // 组件卸载或 query 变时取消
}, [query]);
跳过 Effect 的正确方式
有时候你不想让 effect 在某些情况下运行。React 提供的写法:
jsx
useEffect(() => {
if (userId) { // 只有 userId 存在时才发请求
fetchUser(userId);
}
}, [userId]);
不要试图用空依赖数组来"跳过"条件逻辑------useEffect 的设计要求你把条件放在 effect 内部,而非条件性地决定要不要调用 useEffect。
实战:从 API 获取数据
把这一章的概念综合起来,写一个带加载状态的真实数据请求:
jsx
function UserDashboard({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// 获取用户信息
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const [userRes, postsRes] = await Promise.all([
fetch(`/api/users/${userId}`, { signal: controller.signal }),
fetch(`/api/users/${userId}/posts`, { signal: controller.signal })
]);
if (!userRes.ok || !postsRes.ok) throw new Error('加载失败');
const [userData, postsData] = await Promise.all([
userRes.json(),
postsRes.json()
]);
setUser(userData);
setPosts(postsData);
} catch (err) {
if (err.name !== 'AbortError') setError(err.message);
} finally {
setLoading(false);
}
}
load();
return () => controller.abort();
}, [userId]);
if (loading) return <Skeleton />;
if (error) return <ErrorMessage msg={error} />;
return (
<div>
<UserCard user={user} />
<PostList posts={posts} />
</div>
);
}
Promise.all 同时发两个请求节省时间;AbortController 处理竞态。50 行代码里有真实的工程考量。
本章小结
| 概念 | 一句话总结 |
|---|---|
| 副作用 | 触达组件外部的行为(API 调用、事件监听、DOC 操作) |
| useEffect | 依赖变化时运行副作用,支持清理函数 |
| 依赖数组 | 漏依赖 = bug,函数依赖 = 死循环,尽量把函数放 effect 内部 |
| 清理函数 | 防止内存泄漏、事件残留、定时器堆积 |
| 竞态 | 多请求并发时旧请求覆盖新结果,用 AbortController 取消 |
| Promise.all | 同时发多个请求,节省等待时间 |
到了这里,你应该对 React 的核心概念有了完整理解。组件、状态、副作用------这三样东西构成了 99% 的 React 应用基础。下一章我们聊 自定义 Hook------如何把逻辑抽出来复用。
📌 创作者: Yardon | 🏠 个人网站: GlimmerAI.top
📖 本章是「React 从入门到生产 」系列的第 3 章。上一章:状态与事件处理 | 下一章:自定义 Hook
🌟 如果你觉得有帮助,欢迎访问 GlimmerAI.top 查看我的更多作品。欢迎大家来观看!