今天想和大家聊聊我在学习 React 函数式组件中两个核心 Hook —— useState 和 useEffect 时的一些体会。不讲花里胡哨的概念,只用最朴实的语言、真实的代码例子,带你一步步看清这两个 API 的本质。
一、为什么需要 useState?
在类组件时代,我们通过 this.state 来管理组件内部的状态变化。而到了函数式组件,它原本是“无状态”的——执行完就销毁,无法记住上一次的数据。
于是 React 引入了 useState,让函数组件也能拥有“记忆”。
1. 基本用法
const [num, setNum] = useState(0);
这行代码的意思是:声明一个叫 num 的状态变量,初始值为 0;同时提供一个更新它的方法 setNum。
当你调用 setNum(1),React 会重新渲染组件,并让 num 变成 1。这就是所谓的“响应式状态”。
2. 初始化可以传函数
有时候初始值不是简单的数字或字符串,而是需要经过复杂计算才能得出的结果:
const [num, setNum] = useState(() => {
const num1 = 1 + 2;
const num2 = 2 + 3;
return num1 + num2; // 8
});
这样做有两个好处:
- 避免每次渲染都执行这些计算(提升性能)
- 符合“纯函数”原则:输入相同,输出确定
⚠️ 注意:这个初始化函数必须是同步且无副作用的。你不能在这里写 async/await 或发起网络请求,因为状态的初始化过程必须是确定性的。
那如果我真想在初始化时请求数据怎么办?别急,后面 useEffect 会解决这个问题。
3. 更新状态也可以传函数
当我们基于前一个状态来更新当前状态时,推荐使用函数形式:
setNum(prevNum => prevNum + 1);
这样能确保拿到的是最新的状态值,避免闭包带来的“旧状态”问题。
举个例子:
// ❌ 错误示范:可能读取到过期的 num
setTimeout(() => {
setNum(num + 1);
}, 1000);
// ✅ 正确做法:总是基于最新状态
setTimeout(() => {
setNum(prev => prev + 1);
}, 1000);
二、useEffect:处理副作用的利器
如果说 useState 是“状态引擎”,那 useEffect 就是“副作用控制器”。
什么是副作用?
先说清楚一个概念:纯函数 vs 副作用
-
纯函数:给定相同输入,永远返回相同输出,没有外部影响。
function add(x, y) { return x + y; } -
副作用:函数执行过程中对外部产生了不可控的影响,比如:
- 修改全局变量
- 发起 AJAX 请求
- 操作 DOM
- 设置定时器
- 订阅事件
在 React 组件中,我们的目标是尽量保持组件函数为“纯”的——即只负责根据 props/state 输出 JSX。但现实开发中,我们不可避免要处理副作用,这就轮到 useEffect 上场了。
useEffect 的三种典型用法
① 模拟 onMounted:组件挂载后执行一次
useEffect(() => {
console.log('组件已挂载');
}, []);
第二个参数是一个空数组 [],表示该 effect 不依赖任何状态。因此它只会在组件第一次渲染后执行一次,类似于 Vue 的 onMounted。
常见用途:
- 页面加载后请求接口数据
- 初始化第三方库(如 echarts)
- 监听全局事件(once)
② 根据依赖项更新:类似 onUpdated
useEffect(() => {
console.log('num 改变了:', num);
}, [num]);
只要 num 发生变化,这个 effect 就会重新执行。这就是所谓的“监听某个状态的变化”。
⚠️ 注意:如果你漏写了依赖项,可能会导致拿到的是旧值;但如果多写了不必要的依赖,又可能导致频繁执行。所以要精准填写依赖项。
③ 不传依赖项:每次渲染都执行
useEffect(() => {
console.log('每次渲染都会打印');
});
这种写法很少见,因为它会在每次状态更新、props 改变时都触发,容易造成性能问题或无限循环。
一般用于调试或特殊场景。
如何清除副作用?return 清理函数
很多副作用是有“寿命”的,比如定时器、订阅、连接等。如果不及时清理,会造成内存泄漏。
useEffect 允许你在内部 return 一个清理函数:
useEffect(() => {
const timer = setInterval(() => {
console.log('每秒打印一次');
}, 1000);
// 清理函数
return () => {
console.log('清除定时器');
clearInterval(timer);
};
}, [num]);
这个 return 的函数会在两种情况下被执行:
- 当前 effect 要重新执行前(比如
num变了) - 组件卸载时
✅ 这是非常重要的模式!尤其是在处理定时器、WebSocket、addEventListener 等资源时,一定要记得清理。
当然可以。下面是一段补充内容,适合作为文章的“反面案例”章节,用来警示开发者如果不正确清理副作用可能带来的严重后果。
三、血的教训:不写 return 清理函数,真的会“内存泄漏”
我们常说“记得清理定时器”“记得取消订阅”,但很多新手甚至老手在实际开发中都会忽略这一点。下面来看一个典型的错误写法:
// ❌ 危险示范:没有清理定时器
useEffect(() => {
const timer = setInterval(() => {
console.log('当前计数:', num);
}, 1000);
// 没有 return 清理函数!!!
}, [num]);
看起来没什么问题?每秒打印一次 num,当 num 改变时,重新设置定时器?
错!这会导致严重的内存泄漏和逻辑混乱。
会发生什么?
假设你有一个按钮,点击后跳转页面或卸载当前组件(比如从 /home 切换到 /about)。此时组件已经不在界面上了,但这个 setInterval 的回调依然在后台运行!
- 定时器不会自动停止
- 每次
num更新都会注册一个新的定时器(因为 effect 重新执行) - 老的定时器还在跑,新的也加上了 → 多个定时器同时工作
- 最终导致控制台疯狂输出、浏览器卡顿、甚至崩溃
🚨 更可怕的是:这些定时器仍然持有对
num的引用,JavaScript 引擎无法回收该组件的内存 —— 这就是典型的内存泄漏。
再看一个更隐蔽的问题:闭包陷阱
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log('count');
}, 2000);
// 忘记 return clearInterval(id)
}, [count]);
return (
<button onClick={() => setCount(c => c + 1)}>
当前是 {count}
</button>
);
}
你以为每次 count 变化时,定时器里的 count 也会更新?
不会!
因为每个 useEffect 执行时捕获的是当时的 count 值。而且由于没有清除上一个定时器,结果就是:
- 第一次点击:启动一个打印
0的定时器 - 第二次点击:又启动一个打印
1的定时器(原来的还在) - 第三次点击:再启动一个打印
2的定时器……
最终你会看到控制台每隔两秒就同时打出多个值,越积越多,完全失控。
✅ 正确做法:永远记得清理
useEffect(() => {
const timer = setInterval(() => {
console.log('安全地打印:', num);
}, 1000);
return () => {
// 🔥 在重新执行前或组件卸载时,清除上一个定时器
clearInterval(timer);
};
}, [num]);
这样就能保证:
- 每次只存在一个定时器
- 组件销毁时不再有任何后台任务
- 不会出现内存泄漏
- 行为可控、可预测
总结一句话:
凡是有“开始”的操作,就必须有对应的“结束”操作。
开启了定时器?→ 清除它
添加了事件监听?→ 移除它
建立了 WebSocket?→ 关闭它
订阅了数据流?→ 取消订阅
否则,你的应用将在用户看不见的地方悄悄“腐烂”。
别让一个小疏忽,成为压垮性能的最后一根稻草。
四、实战案例分析
让我们结合一段完整代码来看这些知识点是如何协同工作的。
示例:点击计数 + 异步加载数据 + 条件渲染子组件
// App.jsx
import { useState, useEffect } from 'react';
import Demo from './components/Demo';
export default function App() {
const [num, setNum] = useState(0);
// 定时器副作用,记得清理
useEffect(() => {
const timer = setInterval(() => {
console.log('当前num:', num);
}, 1000);
return () => {
console.log('清除上一个定时器');
clearInterval(timer);
};
}, [num]);
return (
<>
<div onClick={() => setNum(prev => prev + 1)}>
{num}
</div>
{/* 条件渲染:只有偶数才显示 Demo */}
{num % 2 === 0 && <Demo />}
</>
);
}
再看一下子组件 Demo.jsx 中的副作用清理:
// components/Demo.jsx
import { useEffect } from 'react';
export default function Demo() {
useEffect(() => {
const timer = setInterval(() => {
console.log('Demo 内部定时器运行中...');
}, 1000);
// 卸载前清除定时器
return () => {
clearInterval(timer);
console.log('Demo 组件即将被卸载,定时器已清除');
};
}, []);
return <div>偶数 Demo</div>;
}
你会发现:
- 每次
num是奇数时,<Demo />被移除,component unmount → 清理定时器 - 再变成偶数时,重新挂载 → 新建定时器
- 完美避免了内存泄漏!
五、总结:Hooks 使用最佳实践
| 场景 | 推荐写法 |
|---|---|
| 初始化复杂状态 | useState(fn) 函数式初始化 |
| 更新状态依赖前值 | setState(prev => prev + 1) |
| 组件挂载后操作 | useEffect(fn, []) |
| 监听某状态变化 | useEffect(fn, [dep]) |
| 清理副作用 | 在 useEffect 中 return cleanupFn |
| 避免重复创建 | 把不会变的对象/函数提到外面或用 useMemo/useCallback |
六、最后的提醒
useState提供的是“确定性状态”,不要在里面做异步操作。useEffect是“副作用容器”,适合处理异步请求、DOM 操作、订阅等。- 所有副作用都要考虑“如何清除”,否则容易引发 bug 或内存泄漏。
- React 的设计理念是“UI = f(state)” —— 用户界面是状态的函数。我们要做的,就是合理管理 state 和 effect。
七、结语
Hooks 的出现,让函数式组件变得强大而灵活。但它也带来了新的心智负担:你需要更清楚地知道“什么时候执行”、“依赖谁”、“要不要清理”。
但我相信,只要你坚持写注释、多动手实践、像今天这样一行行去读代码背后的逻辑,一定能掌握好 useState 和 useEffect 这对黄金搭档。
共勉!
📌 如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发~
💬 欢迎在评论区分享你的疑问和经验,我们一起进步!
#React #ReactHook #useState #useEffect #前端开发 #JavaScript #掘金原创