在 React 函数组件的世界里,useState 和 useEffect 是两个最基础却也最关键的 Hooks。它们看似简单,但若只停留在"会用"的层面,很容易在复杂场景中踩坑。今天,我们就结合一段真实代码和核心概念图示,一起深入探讨这两个 Hook 背后的设计思想------不只是怎么写,更是为什么这样设计。
一、useState:状态不是变量,而是一套响应式系统
先看这段代码:
scss
const [num, setNum] = useState(0);
表面上,它只是声明了一个初始值为 0 的状态。但如果你仔细看过官方文档或相关图示,会注意到一个关键细节:
useState 的初始值可以是一个函数。
比如:
ini
const [num, setNum] = useState(() => {
const num1 = 1 + 2;
const num2 = 2 + 3;
return num1 + num2; // 返回 6
});
你可能会问:为什么要支持函数形式?直接写 6 不就行了吗?
这背后其实体现了 React 对性能和确定性的考量。
思考:如果初始化需要昂贵计算怎么办?
假设你的初始状态依赖于 localStorage、URL 参数,或者复杂的配置合并,那么每次组件渲染都重新计算一次显然是浪费的。React 规定:当 useState 接收一个函数时,该函数仅在组件首次挂载时执行一次。这既保证了动态初始化的能力,又避免了重复开销。
更重要的是,这种设计统一了"静态值"和"动态值"的使用接口------无论你是传数字还是函数,调用方式完全一致。
setState 也可以传函数:为什么这是最佳实践?
再看这个常见操作:
ini
setNum(prevNum => prevNum + 1);
而不是:
scss
setNum(num + 1);
很多人知道前者"更安全",但未必理解其深层原因。
问题来了:如果我在短时间内连续调用两次 setNum(num + 1),会发生什么?
答案是:两次都基于同一个旧值 num 进行计算,导致更新丢失。
因为 num 是闭包捕获的值,它在本次渲染周期内是固定的。即使你调用了两次 setNum,它们看到的 num 都是同一个快照。
而当你使用函数式更新:
ini
setNum(prev => prev + 1);
React 会确保每次更新都基于最新的状态值进行计算,从而避免竞态条件。
所以,只要新状态依赖于旧状态,就应优先使用函数式更新。这不是语法偏好,而是数据一致性的保障。
二、useEffect:副作用的生命周期管理器
如果说 useState 解决了"状态如何变化"的问题,那么 useEffect 就回答了:"变化之后,我们能做什么?"
图示中明确指出:
useEffect 用于处理副作用(side effect),如请求数据、设置定时器等。它的第二个参数是依赖数组,决定何时重新执行。返回函数用于清理副作用。
这三句话,几乎概括了 useEffect 的全部精髓。
什么是"副作用"?为什么不能直接写在组件里?
在函数式编程中,"纯函数"意味着:相同输入 → 相同输出,且不产生外部影响。而组件本身就应该是一个纯函数:接收 props,返回 JSX。
但现实开发中,我们总要做一些"不纯"的事:
- 发起 API 请求
- 订阅 WebSocket
- 启动定时器
- 操作 DOM
这些操作都会改变外部环境,因此被称为"副作用"。
所以,React 强制将副作用隔离到
useEffect中,是为了保持组件主体的纯净性。
这就像 Vue 3 中 setup() 负责逻辑组织,而 onMounted、watch 负责副作用一样,是一种职责分离的设计哲学。
依赖数组:不是"触发开关",而是"安全护栏"
很多人误以为依赖数组的作用是"控制 effect 何时执行"。但实际上,它的真正价值在于:防止闭包陷阱。
看这段危险代码:
scss
useEffect(() => {
setTimeout(() => {
console.log(num); // 始终打印初始值!
}, 1000);
}, []); // 错误:num 未列入依赖
即使你点击按钮让 num 变成 100,setTimeout 里打印的仍是 0。为什么?
因为 num 是闭包中的旧值,而空依赖数组告诉 React:"这个 effect 永远不需要重新执行"。于是,它永远卡在第一次渲染的状态里。
正确的做法是:凡是在 effect 中使用的状态或 props,都必须加入依赖数组。
React 甚至提供了 ESLint 插件来自动检测这类问题。这不是为了麻烦你,而是帮你避开难以调试的 bug。
return 函数:内存泄漏的"防火墙"
scss
useEffect(() => {
const timer = setInterval(() => {
console.log(num);
}, 1000);
return () => {
clearInterval(timer);
};
}, [num]);
这里开启了一个定时器,并在依赖变化或组件卸载时清理它。
试想:如果不写 return 清理函数,会发生什么?
答案是:每次 num 变化,都会创建一个新的定时器,而旧的定时器仍在运行。最终,页面上可能有数十个定时器同时打印日志------这就是典型的内存泄漏。
那该怎么办?
第一反应当然是:"加个 clearInterval!"
但关键在于:在哪里加?
React 给出的答案是:在 useEffect 的返回函数中清理。
这个返回函数会在两种情况下被调用:
- 下次 effect 重新执行前(用于清理上一次的副作用)
- 组件卸载时(用于彻底释放资源)
这相当于把类组件中的 componentWillUnmount 和 componentDidUpdate 的清理逻辑统一了起来。
所以,凡是创建了持久性资源的操作(定时器、订阅、监听器等),都必须提供对应的清理逻辑。否则,你的应用迟早会因内存泄漏而变慢甚至崩溃。
三、综合案例:动态渲染与副作用的协同
ini
{num % 2 === 0 && <Demo />}
javascript
import{
useEffect
} from 'react'
export default function Demo(){
useEffect(()=>{
console.log('偶数Demo');
const timer=setInterval(()=>{
console.log('timer');
},1000)
return ()=>{//卸载前执行回收定时器
clearInterval(timer);
}
},[])
return(
<div>
偶数Demo;
</div>
)
}
这意味着 Demo 组件会随着 num 的奇偶性动态挂载/卸载。而 Demo 内部有自己的 useEffect 定时器。
这时,整个生命周期链就变得非常清晰:
num改变 → App 重新渲染- 条件满足 →
Demo挂载 → 其useEffect执行 → 启动定时器 - 条件不满足 →
Demo卸载 → 其useEffect的返回函数执行 → 定时器被清除
这是一个完美的副作用闭环。
但如果 Demo 忘记清理定时器,哪怕它已经从界面上消失,后台依然在运行------用户看不见,但内存压力在累积。
这再次印证了:Hooks 的强大,伴随着对开发者责任心的要求。
四、结语:Hooks 是工具,更是思维方式
useState 和 useEffect 不仅仅是语法糖,它们代表了一种全新的前端开发范式:
- 状态驱动视图:UI 是状态的函数,一切变化源于状态更新。
- 副作用显式化 :所有非纯操作必须通过
useEffect声明,不可隐藏。 - 依赖透明化:任何外部引用都必须显式声明,避免隐式耦合。
- 资源可回收:每个副作用都必须有对应的清理路径,防止泄漏。
最后,请记住:
会用 Hooks 只是起点,理解其背后的设计哲学,才是写出健壮 React 应用的关键。
下次当你写下 useEffect 时,不妨多问自己一句:
"我有没有遗漏依赖?有没有忘记清理?这个副作用真的必要吗?"
正是这些思考,让你从"写代码的人",成长为"设计系统的人"。