别说你懂 useEffect:从底层机制到生命周期管理,这篇全讲透了
摘要:useEffect 是 React 函数组件中处理"副作用"的核心工具。本文从底层机制出发,拆解三种触发时机、清理函数的关键作用、依赖数组的常见陷阱,并结合浏览器本地存储的实战场景,帮你彻底掌握这个 React 最重要的 Hook。
📑 目录
- useEffect 的本质:延迟执行的同步回调
- 三种触发时机:依赖数组决定一切
- 清理函数:防止内存泄漏的关键
- setTimeout 与 setInterval:本质不同的两种定时器
- 依赖数组的引用类型陷阱
- 多个 useEffect:按逻辑拆分而非合并
- 常见误区纠正
- 互动讨论
useEffect 的本质:延迟执行的同步回调
很多初学者把 useEffect 当成"异步函数",这是一个需要纠正的认知偏差。
正确的理解是:useEffect 是一个"延迟执行的同步回调函数"。
它的执行机制是这样的:
text
markdown
组件渲染(Render)
↓
更新 DOM(浏览器绘制)
↓
【执行 useEffect 回调】 ← 延迟执行,而非异步
useEffect 的回调函数在浏览器完成 DOM 绘制之后 才执行。它不阻塞浏览器的渲染过程,因此表现出"异步"的行为特征------但它不能 像真正的 async 函数那样直接使用 await(除非在内部再定义异步函数)。
javascript
scss
useEffect(() => {
console.log('组件挂载后执行');
}, []);
这段代码会在组件渲染完成并绘制到页面后才执行 console.log。这符合 React 的设计理念:先让用户看到界面,再执行副作用操作------比如请求数据、订阅事件、操作 DOM 等。
三种触发时机:依赖数组决定一切
useEffect 通过第二参数(依赖数组)控制触发时机,分为三种情况:
| 依赖数组 | 触发时机 | 典型场景 |
|---|---|---|
| 无第二参数 | 组件初次挂载 + 每一次更新(每次渲染后都执行) | 谨慎使用,容易引发性能问题 |
[](空数组) |
只在初次挂载时执行一次 | 初始化数据、订阅外部事件 |
[dep1, dep2] |
初次挂载 + 指定依赖项发生变化时执行 | 监听特定状态变化,如搜索、分页 |
在 Todo 应用的代码中,展示了四种不同的 useEffect 用法:
javascript
javascript
// 1. 监听到 count 更新就会执行
useEffect(() => {
console.log('挂载后执行');
console.log('count改变也会执行');
}, [count]);
// 2. 只在挂载后执行一次
useEffect(() => {
console.log('只在挂载后执行');
}, []);
// 3. 监听到 todos 更新就会执行------常用于本地存储同步
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
// 4. 每次更新都会执行(无依赖数组)
useEffect(() => {
console.log('挂载后执行');
console.log('每次更新都会执行');
});
本地存储:useEffect 的最佳拍档
第三个 useEffect 展示了一个非常经典的 React 模式:当数据变化时,自动同步到浏览器的 localStorage。
浏览器提供了多种本地存储方案,其中最常用的是 localStorage:
javascript
javascript
// 存入数据------必须序列化为字符串
localStorage.setItem('todos', JSON.stringify(todos));
// 读取数据------从字符串解析回来
const saved = JSON.parse(localStorage.getItem('todos'));
localStorage 以键值对的形式存储数据,键和值都必须是字符串 。如果要存储数组或对象,必须使用 JSON.stringify() 序列化;读取时用 JSON.parse() 还原。这个特性直接影响了数据初始化的写法:
javascript
javascript
// ✅ 从 localStorage 读取初始数据
const [todos, setTodos] = useState(() =>
JSON.parse(localStorage.getItem('todos')) || []
);
使用惰性初始化 (传入函数)确保 localStorage 读取只在组件首次挂载时执行一次,而不是每次渲染都读取。|| [] 作为兜底------如果 localStorage 中没有数据(首次访问),就用空数组作为初始值。
这种"状态变化 → 自动持久化"的模式非常适合用户偏好设置、表单草稿、购物车数据等场景。用户刷新页面后,数据依然存在,体验非常流畅。
需要注意的是,localStorage 的存储空间大约只有 5MB ,适合存储轻量级数据。如果需要存储更大的数据(比如端侧模型、大量操作历史),可以考虑 IndexedDB ,它提供了类似数据库的存储能力,空间更大、支持更复杂的数据结构。不过对于 Todo 列表这样的场景,localStorage 完全够用。
清理函数:防止内存泄漏的关键
什么是清理函数?
useEffect 的回调函数可以返回一个清理函数,用于在组件卸载或下一次 effect 执行前执行收尾工作。
javascript
scss
useEffect(() => {
// 主逻辑
return () => {
// 清理逻辑
};
}, [deps]);
清理函数的执行时机
| 依赖数组情况 | 清理函数执行时机 |
|---|---|
| 无依赖 / 有依赖 | 下一次 effect 执行之前(先清掉旧的,再执行新的) |
[](空数组) |
组件卸载时(仅此一次) |
清理函数的三种写法
javascript
javascript
// ✅ 写法一:匿名箭头函数(最常用)
return () => clearInterval(timer);
// ✅ 写法二:具名函数
return function cleanup() {
clearInterval(timer);
};
// ✅ 写法三:提取为变量
const stopTimer = () => clearInterval(timer);
return stopTimer;
命名规范 :没有强制命名规则,但行业通常遵循 cleanup、stopXxx、removeXxx 等语义化命名------让代码的读者能一眼看出这个函数在做什么。
常见清理场景
清理定时器:
javascript
javascript
useEffect(() => {
const interval = setInterval(() => {
console.log('interval, is here');
}, 1000);
return () => {
console.log('组件卸载前执行,做什么样内存清理工作');
clearInterval(interval);
};
}, []);
在 Demo 组件中,setInterval 每秒打印一次。如果组件被卸载时没有清理,这个定时器会永远运行下去------它的回调仍然会尝试执行,但组件已经不存在了,这就造成了内存泄漏。
取消网络请求(AbortController):
javascript
scss
useEffect(() => {
const controller = new AbortController();
fetch('/api/user', { signal: controller.signal })
.then(res => res.json())
.then(setUser);
return () => {
controller.abort(); // 取消正在进行的请求
};
}, []);
移除事件监听:
javascript
javascript
useEffect(() => {
const handleResize = () => console.log(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
setTimeout 与 setInterval:本质不同的两种定时器
这两个定时器虽然名字相似,但底层行为截然不同:
| 对比维度 | setTimeout |
setInterval |
|---|---|---|
| 执行模式 | 一次性炸弹:倒计时结束后执行一次 | 重复播种机:每隔指定时间重复执行一次 |
| 不清理的后果 | 组件卸载后仍试图执行,导致 "野指针" 和内存泄漏 | 旧定时器未清除 + 新定时器不断创建,导致 "叠加爆炸" (打印速度指数级暴增) |
| 清理 API | clearTimeout(timerId) |
clearInterval(timerId) |
错误对比示例:
javascript
scss
// ❌ 会导致野指针(组件卸载后仍尝试执行)
useEffect(() => {
setTimeout(() => {
setCount(count + 1); // 组件可能已卸载
}, 1000);
}, []);
// ❌ 会导致叠加爆炸(每次渲染都创建新定时器,旧的没清理)
useEffect(() => {
setInterval(() => {
console.log('每秒打印');
}, 1000);
}); // 无依赖,每次渲染都执行
// ✅ 正确写法:空数组 + 清理
useEffect(() => {
const timer = setInterval(() => {
console.log('每秒打印');
}, 1000);
return () => clearInterval(timer);
}, []);
clearTimeout 和 clearInterval 是浏览器自带的原生 Web API,不是 React 提供的功能。传入定时器 ID 即可清除对应的定时器。
依赖数组的引用类型陷阱
问题代码
javascript
scss
// ❌ 这会导致无限循环!
useEffect(() => {
console.log('执行了');
}, [{ name: '大许' }]); // 每次渲染都生成新对象
根本原因
JavaScript 中,{ name: '大许' } === { name: '大许' } 永远返回 false。React 的依赖比较使用 Object.is()(浅比较),比较的是内存地址而不是"值"。
每次渲染都创建新对象 → 新地址 → React 认为依赖"变了" → 执行 effect → 可能触发 setState → 再次渲染 → 无限循环。
解决方案
| 方案 | 适用场景 |
|---|---|
| 拆解为基本类型 | 将 { name: '大许' } 拆为 '大许' 作为依赖 |
使用 useMemo 缓存对象引用 |
需要完整对象且依赖项稳定时 |
使用 useRef 存储不触发重新渲染的值 |
值变化不需触发 effect 时 |
正确示例:
javascript
scss
// ✅ 方案一:拆解为基本类型
useEffect(() => {
console.log('执行了');
}, [name]); // name 是字符串
// ✅ 方案二:useMemo 缓存引用
const user = useMemo(() => ({ name: '大许' }), []);
useEffect(() => {
console.log('执行了');
}, [user]);
多个 useEffect:按逻辑拆分而非合并
React 允许在一个组件中使用多个 useEffect ,推荐按逻辑拆分而非合并:
javascript
scss
// ✅ 良好实践:分离关注点
useEffect(() => {
// 订阅用户状态
}, [userId]);
useEffect(() => {
// 更新页面标题
}, [pageTitle]);
useEffect(() => {
// 同步数据到本地存储
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
好处:
- 逻辑清晰,易于维护
- 每个 effect 的依赖项更加明确,减少不必要的执行
- 每个副作用都有独立的生命周期管理
常见误区纠正
| ❌ 误区 | ✅ 真相 |
|---|---|
"useEffect 是异步函数。" |
它是同步回调函数 ,只是延迟执行(在绘制之后)。 |
"setTimeout 会导致速度越来越快。" |
setTimeout 是一次性 的,不会叠加。setInterval 才是叠加爆炸的元凶。 |
"清理函数必须叫 cleanup。" |
命名完全自由,React 只关心你返回了一个函数。 |
"clearTimeout 是 React 提供的。" |
它是浏览器原生 Web API,与 React 无关。 |
| "对象依赖和基本类型依赖没区别。" | React 用浅比较,对象的"值相同 ≠ 引用相同",容易触发无限循环。 |
| "多个 useEffect 会按顺序执行。" | 所有 useEffect 都在 DOM 绘制之后 执行,但执行顺序严格按照代码中声明的顺序。 |
互动讨论
💬 useEffect 和 useLayoutEffect 有什么区别?
useEffect 在浏览器绘制之后 执行,不阻塞渲染;useLayoutEffect 在 DOM 更新后、绘制之前 同步执行,会阻塞渲染。大多数场景用 useEffect,只有在需要精确测量 DOM 尺寸或防止闪烁时才用 useLayoutEffect。
💬 为什么 useEffect 不能直接使用 async/await?
useEffect 期望回调函数返回一个清理函数(或 undefined),而 async 函数总是返回一个 Promise。如果写成 useEffect(async () => {...}, []),React 会收到一个 Promise 而不是清理函数。正确做法是在内部定义异步函数并调用。
💬 清理函数在开发环境为什么会执行两次?
React 18 的 Strict Mode 会在开发环境下故意让组件挂载、卸载、再挂载,以帮助发现副作用遗漏。这是为了让开发者提前发现"忘记清理"的问题,在生产环境中不会出现。
💬 localStorage 和 useEffect 结合时需要注意什么?
关键是懒初始化 和同步时机 。用 useState(() => JSON.parse(localStorage.getItem('key')) || []) 确保只在挂载时读取一次。写入时用 useEffect 监听数据变化自动同步。另外要注意 localStorage 的 5MB 空间限制,以及它只能存储字符串------对象和数组需要用 JSON.stringify/parse 处理。
💬 什么时候使用无依赖的 useEffect?
极少数情况。通常用于"需要在每次渲染后都执行某些操作"的场景,比如:日志记录、同步 DOM 状态到第三方库。但需要确保这些操作不会触发新的状态更新,否则会陷入无限循环。