一、什么是 React Hooks?
以 use 开头的函数,都是 React Hooks。它们是 React 官方提供的函数式编程接口,用于在函数组件中使用状态、生命周期、上下文等特性,而无需编写 class 组件。
✅ 特点:
- 更贴近原生 JavaScript 风格
- 逻辑复用更简单(通过自定义 Hooks)
- 代码更简洁、可读性更强
二、useState:管理组件状态
基本用法
jsx
编辑
import { useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
return (
<div onClick={() => setCount(count + 1)}>
点击次数:{count}
</div>
);
}
useState返回一个数组:[当前状态值, 更新状态的函数]- 初始值可以是任意类型(数字、对象、数组等)
初始化支持函数(惰性初始化)
如果初始状态需要复杂计算,可传入一个函数:
jsx
编辑
const [num, setNum] = useState(() => {
const a = 1 + 2;
const b = 2 + 3;
return a + b; // 必须返回值
});
⚠️ 注意:这个函数只在首次渲染时执行一次,且必须是同步纯函数(不能包含异步操作)。
❓问题:如何在初始化时发起异步请求?
答案:不能直接在 useState 中做异步初始化!
因为状态必须是确定的、同步的。正确的做法是:
jsx
编辑
import { useState, useEffect } from 'react';
async function fetchData() {
const res = await fetch('/api/data');
return res.json();
}
export default function App() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData().then(result => {
setData(result);
setLoading(false);
});
}, []); // 依赖项为空数组 → 相当于 componentDidMount
if (loading) return <div>加载中...</div>;
return <div>{data?.message}</div>;
}
✅ 关键点:异步数据获取属于“副作用”,应放在
useEffect中处理。
三、useEffect:处理副作用
什么是副作用?
在函数式组件中,纯函数指:输入 props → 输出 JSX。
而以下行为属于副作用(Side Effect):
- 发起网络请求
- 订阅事件(如定时器、WebSocket)
- 手动操作 DOM
- 修改外部变量
这些操作都应通过 useEffect 来管理。
三种依赖模式的核心区别
| 依赖模式 | 主逻辑执行时机 | 清理函数执行时机 |
|---|---|---|
空数组 [] | 仅组件挂载时执行 1 次 | 仅组件卸载时执行 1 次 |
非空数组 [num] | 挂载时执行 + 依赖变化时执行 | 依赖变化前执行 + 卸载时执行 |
| 无依赖数组(省略) | 组件每次重渲染都执行 | 组件下一次重渲染前执行 + 卸载时执行 |
四、深度解析三种依赖模式
一、空数组 []:模拟 componentDidMount / onMounted
jsx
编辑
useEffect(() => {
console.log('仅挂载时执行');
const timer = setInterval(() => console.log('运行'), 1000);
return () => console.log('仅卸载时清理');
}, []);
- 主逻辑:组件首次挂载时执行一次
- 清理函数:组件卸载时执行一次
- 典型用途:一次性数据请求、全局监听器注册
📌 注意:若内部使用了组件状态(如
num),该状态值将被闭包锁定为初始值,不会随后续更新而变化。
二、非空数组 [num]:响应依赖变化
jsx
编辑
useEffect(() => {
console.log(`effect: num=${num}`);
const timer = setInterval(() => console.log(`timer: num=${num}`), 1000);
return () => console.log(`remove: 清除 num=${num} 的定时器`);
}, [num]);
完整执行流程(关键!)
假设初始 num=0,点击按钮依次变为 1 → 2:
| 阶段 | 执行顺序 | 控制台输出 |
|---|---|---|
| 挂载(num=0) | 执行主逻辑 | effect: num=0 → 创建打印 0 的定时器 |
| num → 1 | 1. 执行上一轮清理 2. 执行本轮主逻辑 | remove: 清除 num=0 的定时器 effect: num=1 → 创建打印 1 的定时器 |
| num → 2 | 1. 执行上一轮清理 2. 执行本轮主逻辑 | remove: 清除 num=1 的定时器 effect: num=2 → 创建打印 2 的定时器 |
| 卸载 | 执行最后一轮清理 | remove: 清除 num=2 的定时器 |
✅ 优势:
- 始终只有一个定时器在运行
- 定时器内打印的是最新 num 值
- 自动清理旧资源,避免内存泄漏
⚠️ 避坑:若忘记写清理函数,每次
num变化都会新增一个定时器,导致多个定时器同时运行(旧值 + 新值混杂)。
三、无依赖数组(省略第二个参数):高频重执行模式
jsx
编辑
useEffect(() => {
console.log(`effect: num=${num}`);
const timer = setInterval(() => console.log(`timer: num=${num}`), 1000);
return () => console.log(`remove: 清除 num=${num} 的定时器`);
}); // 无依赖!
核心行为
- 主逻辑:组件首次挂载执行 1 次,之后每一次重渲染(state 变化、props 变化、父组件重渲染等)都会重新执行;
- 清理函数:每一次新主逻辑执行前(包括重渲染、卸载),都会先执行上一轮的清理函数;
- 最终效果:只要组件还在页面上,就会 “清旧的 → 建新的” 无限循环。
执行流程(以 num 变化为例)
- 挂载(num=0)
→ 执行主逻辑:创建打印 0 的定时器 - 点击按钮,num→1(重渲染)
→ 先执行清理:清除 num=0 的定时器
→ 再执行主逻辑:创建打印 1 的定时器 - 再点击,num→2
→ 清理 num=1 的定时器 → 创建 num=2 的新定时器 - 组件卸载
→ 清理最后一轮(num=2)的定时器
三大坑点(重点!)
-
触发频率远高于
[num][num]:仅num变化时触发- 无依赖:任何重渲染都触发(包括无关 state、父组件刷新等)
-
定时器高频销毁/重建,性能损耗
即使num没变,只要组件被动重渲染,就会:
→ 清掉当前定时器 → 创建新定时器
虽然功能正确,但带来不必要的开销。 -
闭包陷阱(若未清理)
如果忘记写清理函数:- 每次重渲染都创建新定时器
- 旧定时器不清理 → 多个定时器同时运行
- 旧定时器中的
num是创建时的旧值(闭包捕获)
✅ 好消息:只要写了清理函数,就不会出现“值不更新”的问题——因为每次都是全新创建,用的是当前渲染的最新值。
五、闭包:贯穿 useEffect + 定时器的核心机制
在你的 useEffect + 定时器 代码中,闭包是理解一切的关键。很多人遇到“定时器值不更新”或“内存泄漏”,根源都在于对闭包的理解不足。
一、闭包的核心本质
闭包是指「内部函数可以访问外部函数作用域中的变量 / 函数,即使外部函数已经执行完毕」。
在 React 函数组件中:
- 每次组件渲染(挂载 / 重渲染)都会生成一个独立的函数执行上下文(可理解为“一次渲染快照”);
useEffect里的主逻辑、清理函数、定时器回调,都是“内部函数”,会闭包捕获当前渲染上下文的变量(如num);- 这些内部函数即使脱离了原渲染上下文(比如定时器每秒执行),依然能访问当时捕获的变量。
二、代码中的闭包具体体现(逐行分析)
我们以这段代码为例,标注所有闭包场景:
jsx
编辑
useEffect(() => {
console.log(`effect: num=${num}`); // 主逻辑闭包捕获当前 num
// 定时器回调是内部函数,闭包捕获当前渲染的 num
const timer = setInterval(() => {
console.log(`timer: num=${num}`); // 👈 闭包核心位置!
}, 1000);
// 清理函数也是内部函数,闭包捕获当前的 timer 和 num
return () => {
console.log(`remove: 清除 num=${num} 的定时器`);
clearInterval(timer); // 👈 闭包捕获当前的 timer 实例
};
}, [num]);
场景 1:定时器回调闭包捕获「当前渲染的 num」
- 每次
useEffect执行(挂载 /num变化),都会创建一个新的定时器回调函数; - 这个回调函数会“记住”(闭包)本次渲染上下文的
num值; - 即使后续
num变化、组件重渲染,只要这个定时器没被清除,回调里的num永远是捕获时的值。
💥 极端错误示例(无清理 + 空依赖) :
jsx 编辑 useEffect(() => { setInterval(() => { console.log(num); // 闭包捕获“挂载时的 num(比如 0)” }, 1000); }, []);后续
num改为 1、2、3...,定时器永远打印 0!
原因:空依赖 →useEffect仅执行一次 → 定时器回调闭包锁定初始num。
场景 2:清理函数闭包捕获「当前渲染的 timer / num」
-
每次
useEffect执行时,返回的清理函数会闭包捕获本次创建的timer实例; -
当后续执行清理(
num变化前 / 卸载),它能精准清除对应定时器; -
例如
num从 0 → 1:- 上一轮清理函数闭包的是
num=0时创建的timer→ 清除旧定时器; - 本轮主逻辑创建新定时器,回调闭包
num=1→ 打印 1。
- 上一轮清理函数闭包的是
场景 3:useEffect 主函数本身也是闭包
useEffect的回调函数是组件渲染时创建的;- 它闭包捕获本次渲染的
num,所以console.log(effect: num=${num})能打印当前值。
三、闭包导致的三大常见坑点
坑 1:依赖数组写错 → 闭包捕获旧值
jsx
编辑
// ❌ 错误:想打印最新 num,却用了空依赖
useEffect(() => {
const timer = setInterval(() => {
console.log(num); // 永远打印初始值!
}, 1000);
return () => clearInterval(timer);
}, []); // 漏写 num
✅ 解决:依赖数组加 [num],让 num 变化时重建闭包,捕获新值。
坑 2:不清理定时器 → 多个闭包堆积(内存泄漏)
jsx
编辑
// ❌ 错误:无清理函数
useEffect(() => {
setInterval(() => {
console.log(num); // 每个定时器闭包不同 num
}, 1000);
}, [num]);
结果:num 从 0 → 1 → 2,会同时存在 3 个定时器:
- 定时器1:闭包
num=0,每秒打印 0; - 定时器2:闭包
num=1,每秒打印 1; - 定时器3:闭包
num=2,每秒打印 2;
✅ 这就是内存泄漏:多个闭包占据内存,无法释放。
坑 3:无依赖数组 → 高频闭包重建(性能损耗)
jsx
编辑
// ⚠️ 无依赖数组(慎用)
useEffect(() => {
const timer = setInterval(() => {
console.log(num); // 每次重渲染都创建新闭包
}, 1000);
return () => clearInterval(timer);
}); // 无依赖
结果:哪怕 num 没变,只要父组件重渲染 → 当前组件重渲染 →
→ 清旧定时器 → 建新定时器 → 高频闭包重建,造成性能损耗。
四、闭包的“好”与“坏”总结
| 闭包的作用(好) | 闭包的坑(坏) |
|---|---|
清理函数能记住对应的 timer,精准清除 | 依赖写错 → 闭包旧值,定时器打印错误 num |
定时器回调能访问组件内的变量(否则无法打印 num) | 不清理定时器 → 多个闭包堆积,内存泄漏 |
| 保证每次渲染的副作用独立(互不干扰) | 无依赖数组 → 高频闭包重建,性能损耗 |
五、核心结论:闭包是理解 useEffect + 定时器的钥匙
你这段代码的核心逻辑,从头到尾都是闭包在起作用:
- ✅ 定时器能打印
num→ 闭包捕获了组件内的num; - ✅ 清理函数能清除对应定时器 → 闭包捕获了当时创建的
timer实例; - ✅ 依赖
[num]能更新定时器值 → 让每次num变化都创建新闭包(捕获新num) ; - ❌ 所有“值不更新”“内存泄漏”问题 → 本质是闭包捕获的变量/实例未被正确管理。
🔑 一句话总结:
理解了这段代码里的闭包,就理解了useEffect + 定时器的所有关键逻辑;反之,不理解闭包,就会反复踩坑。
六、实战案例:条件渲染下的组件挂载/卸载
jsx
编辑
// Parent.jsx
import { useState } from "react";
import Demo from "./Demo";
export default function Parent() {
const [num, setNum] = useState(0);
return (
<>
<button onClick={() => setNum(prev => prev + 1)}>num+1</button>
<p>当前 num:{num}</p>
{/* 仅当 num 为偶数时挂载 Demo */}
{num % 2 === 0 && <Demo />}
</>
);
}
jsx
编辑
// Demo.jsx
import { useEffect } from "react";
export default function Demo() {
useEffect(() => {
console.log("123123"); // 挂载日志
const timer = setInterval(() => console.log("timer"), 1000);
return () => {
console.log("remove"); // 卸载日志
clearInterval(timer);
};
}, []); // 空依赖
return <div>偶数 Demo</div>;
}
执行流程
| 操作 | 组件状态 | 控制台输出 |
|---|---|---|
| 初始 num=0(偶数) | Demo 挂载 | 123123 → 定时器启动 |
| num=1(奇数) | Demo 卸载 | remove → 定时器停止 |
| num=2(偶数) | Demo 重新挂载 | 123123 → 新定时器启动 |
| num=3(奇数) | Demo 卸载 | remove → 新定时器停止 |
✅ 关键结论:
- 每次“偶数”都会重新挂载 → 创建全新定时器实例(全新闭包)
- 每次“奇数”都会卸载 → 触发清理函数 → 定时器被清除
- 不会内存泄漏(前提是写了清理函数)
⚠️ 重要区分:
若使用style={{ display: num%2===0 ? 'block' : 'none' }}控制显隐(DOM 未卸载):
useEffect不会重新执行- 定时器只创建一次,且永远不会被清理(除非组件真正卸载)
七、常见误区与注意事项
| 误区 | 正确做法 |
|---|---|
在 useState 中使用 async/await | 异步逻辑移到 useEffect |
| 忘记清理定时器/订阅 | 总是在 useEffect 中返回清理函数 |
| 依赖项遗漏导致 stale closure | 使用 ESLint 插件 eslint-plugin-react-hooks 自动检查 |
| 在条件语句中调用 Hooks | Hooks 必须在顶层调用(React 依赖调用顺序) |
| 用样式控制显隐代替条件渲染 | 若需重置状态/副作用,必须用条件渲染(卸载/挂载) |
| 空依赖中使用状态却不更新 | 若需最新值,必须将状态加入依赖数组 |
八、总结要点
✅ useState
- 用于声明响应式状态
- 初始值可为函数(惰性初始化)
setState可接收新值或更新函数(推荐后者避免闭包问题)
✅ useEffect 三种模式
| 模式 | 适用场景 | 注意事项 |
|---|---|---|
[] | 一次性初始化(如请求、监听) | 内部状态会被闭包锁定 |
[deps] | 响应特定状态变化 | 必须清理资源,避免残留 |
| 无依赖 | 极少使用 | 高频触发,性能敏感 |
✅ 闭包是核心
- 定时器能访问
num→ 闭包 - 清理函数能清除对应定时器 → 闭包
- 依赖数组决定是否重建闭包 → 控制值是否更新
✅ 核心原则
- 清理函数是防止内存泄漏的生命线
- 依赖数组决定执行频率,务必精确
- 条件渲染 = 卸载/挂载;样式显隐 ≠ 卸载