React Hooks 核心精讲:从 useState 到 useEffect,彻底搞懂响应式与副作用
大家好,今天我们来聊一聊React Hooks,这篇文章基于我实际调试过的代码(包括各种坑),结合官方机制和常见误区,带你从底层逻辑彻底吃透 React Hooks 的两大核心:useState 和 useEffect。力求让你看完后不仅会用,还知道为什么这么用。
一、useState:组件的“心脏” —— 响应式状态
1. 状态是组件的核心
React 组件本质上是一个函数,输入是 props 和 state,输出是 JSX。
没有状态的组件就像静态页面,点击无反应。而 useState 就是给函数组件注入“心脏”的钩子,让它拥有了响应式能力。
const [num, setNum] = useState(0);
num是当前状态值setNum是更新状态的唯一合法方式- 一旦调用
setNum,React 会重新执行组件函数,生成新的 UI
2. 初始化:为什么推荐用函数形式?
大多数时候我们直接传初始值:
useState(0)
但当初始值需要复杂计算时,官方强烈推荐传一个函数:
const [num, setNum] = useState(() => {
const num1 = 1 + 2;
const num2 = 2 + 3;
return num1 + num2; // 6
});
为什么?
因为 React 在某些情况下(尤其是开发模式的 StrictMode)可能会多次调用初始化逻辑。如果你的初始化有副作用(如 console.log、fetch 请求),就会被执行多次,导致 bug。
而传入函数形式,React 只会在真正需要时调用一次,并且这个函数必须是纯函数。
3. 什么是纯函数?(超级重要!)
纯函数是函数式编程的核心概念,在 React 中被反复强调。
定义:相同输入永远返回相同输出,并且没有可观察的副作用。
用大白话讲:
“你给我什么原料,我就老老实实加工成什么产品。只干这一件事,不打印日志、不发请求、不改全局变量、不看心情。”
纯函数示例
const add = (a, b) => a + b;
// add(2, 3) 永远是 5
非纯函数示例
打印日志
const logAdd = (a, b) => {
console.log(a, b); // 副作用
return a + b;
};
依赖外部变量
JavaScript
let tax = 0.1;
function calcPrice(price) {
return price * (1 + tax); // 依赖外部的 tax
}
如果别人改了 tax,同样的 price 输入会得到不同输出 → 不纯
经典错误案例
const nums = [1, 2];
const add = function(arr) {
arr.push(3); // 修改了外部数组!副作用!
return arr.reduce((a, b) => a + b);
};
add(nums);
console.log(nums.length); // 3,而不是预期的 2
纯函数的核心本质:
“你给我什么输入,我就老老实实根据这个输入给你计算出输出。只做这一件事,不多管闲事,也不影响外面任何东西。”
拆开来说:
-
只有一个很纯粹的功能 对!它只负责一件事,比如加法、字符串拼接、数据转换等。功能单一、明确。
-
你输入什么,他就执行什么(相同输入 → 相同输出) 绝对确定性: 今天调用 add(2, 3) 是 5,明天调用还是 5,一万年后再调用还是 5。 不管外面天气怎么样、全局变量改没改、时间是几点,它都永远给你同一个结果。
-
没有附加功能 / 不影响别的 这点最关键,也就是“无副作用”:
- 不会偷偷打印 console.log
- 不会修改外部变量
- 不会发网络请求
- 不会改 DOM
- 不会依赖随机数或当前时间 它就像一个“与世隔绝”的计算机器,进来原料,出去产品,不污染环境,也不受环境影响。
React 为什么在意纯函数?
- 组件的渲染函数(function App() { ... })最好是纯函数:相同 props/state → 相同 JSX
- useState 的初始化函数最好是纯的:React 可能调用多次,但结果始终一样
- setNum(prev => prev + 1) 这种更新函数最好纯:只根据 prev 计算新值,不偷偷干别的
这样 React 才能放心地优化、缓存、预测组件行为,不会出奇怪的 bug。
4. setNum 的两种用法:函数式更新(推荐!)
// 普通方式
setNum(num + 1);
// 函数式(推荐,尤其是连续更新)
setNum(prev => prev + 1);
为什么推荐函数式?
因为 React 会把多次 setState 合并(batching)。如果你连续调用三次普通方式:
setNum(num + 1);
setNum(num + 1);
setNum(num + 1);
实际只会 +1(因为都基于旧的 num)。
而函数式每次都拿最新值:
setNum(prev => prev + 1);
setNum(prev => prev + 1);
setNum(prev => prev + 1);
// 真正 +3
注意:即使你写了 console.log(prevNum),这也算轻微副作用,生产环境建议去掉。
二、useEffect:处理“副作用”
1. 什么是副作用?
副作用就是“除了返回 JSX 之外的其他操作”。
纯函数的对立面就是副作用。
常见副作用:
- 发请求
- 手动操作 DOM
- 设置定时器
- 添加事件监听
- 订阅事件
useEffect(() => {
// 这里就是副作用区域
}, []);
2. useEffect 的三种执行时机
依赖数组决定了 useEffect 什么时候执行:
情况1:[] —— 只在挂载时执行(onMounted)
useEffect(() => {
queryData().then(data => setNum(data));
}, []);
最常见:发请求获取初始数据。
情况2:[dep1, dep2] —— 挂载 + 依赖变化时执行
useEffect(() => {
console.log('num 变了');
}, [num]);
情况3:不传依赖 —— 每次渲染都执行(慎用!)
useEffect(() => {
console.log('每次渲染都执行');
});
3. return 清理函数:防止内存泄漏的救命稻草
这是最容易被忽略但最关键的点!
useEffect(() => {
const timer = setInterval(() => {
console.log('timer');
}, 1000);
return () => {
console.log('remove');
clearInterval(timer);
};
}, []);
执行顺序:
- 组件挂载 → 执行 effect → 设置定时器
- 组件卸载 → 先执行 return 清理函数 → 清除定时器
- 如果依赖变化 → 先执行上一次的清理 → 再执行新的 effect
不写清理会怎样?
定时器、事件监听、订阅会一直存在,导致:
- 内存泄漏
- 控制台疯狂打印
- 甚至访问已卸载组件的 state(报错)
金句:凡是在 useEffect 里启动的“长期运行操作”,必须在 return 里停止!
4. 经典坑:条件渲染导致组件反复挂载/卸载
{num % 2 === 0 && <Demo />}
点击改变 num,Demo 组件会:
- 偶数 → 挂载(执行 effect)
- 奇数 → 卸载(执行清理)
- 再偶数 → 重新挂载
控制台会看到: 123123 → timer → remove → 123123 → timer...
很多人以为是 useEffect([]) 执行多次,其实是组件本身反复 mount/unmount。
解决方案:
- 用 display: none 隐藏而不是条件渲染
- 或把定时器逻辑移到父组件
三、真实案例剖析:一步步看执行顺序
假设代码如下(无 StrictMode):
function App() {
const [num, setNum] = useState(0);
console.log('yyy');
useEffect(() => {
console.log('xxx');
queryData().then(data => setNum(data)); // 2秒后返回666
}, []);
useEffect(() => {
console.log(num, 'zzz');
}, [1,2,3,new Date()]); // 坑:每次都变化!
useEffect(() => {
console.log('ddd');
});
return <div>{num}</div>;
}
执行顺序:
-
初次渲染:
- yyy
- xxx(发起请求)
- 0 zzz(依赖变化)
- ddd
-
2秒后 setNum(666) → 重新渲染:
- yyy(第2次)
- 666 zzz(依赖又变化)
- ddd(第2次)
最终控制台:
- yyy ×2
- xxx ×1(因为依赖 [])
- zzz ×2
- ddd ×2
常见误区:有人把请求写成依赖 [num],会导致 num 变后又发一次请求(多余!)
四、开发模式下的“诡异”重复执行
很多同学会发现:开发时日志打印了两次甚至三次!
原因:React 18+ 默认开启 StrictMode
在开发环境,React 会故意:
- mount → unmount → remount 一次
- 帮助你发现副作用不纯的问题
解决方案:
- 接受它(它只在开发环境)
- 或临时注释掉 main.jsx 的
<StrictMode>
生产环境自动消失。
五、几个细节知识点
1、一个经典的“定时器地狱”(timer hell)
看这个 useEffect:
jsx
useEffect(()=>{
console.log('effct');
const timer = setInterval(()=>{
console.log(num);
},1000)
}, [num])
当你点击num几次后,你会看到控制台开始疯狂快速打印数字(0、1、2、3、0、1、2、3...),而且速度越来越快!
发生了什么?
-
初始 num = 0,组件挂载 → useEffect 执行一次 → 打印 'effct' → 创建一个定时器,每秒打印一次当前 num(现在是 0)
-
你点击 div → setNum(1) → 组件重新渲染 → num 变成 1 → 因为依赖是 [num],num 变了 → useEffect 重新执行 → 又打印 'effct' → 又创建一个新的定时器,每秒打印当前 num → 但上一个定时器没有被清除! (因为你注释了 return cleanup)
-
现在你有两个定时器在同时运行:
- 第一个还在每秒打印旧的 num
- 第二个每秒打印新的 num
-
再点击 → num = 2 → useEffect 又执行 → 创建第三个定时器…… 以此类推,每次点击 num 增加,useEffect 就再跑一次,再加一个没清理的定时器。
-
定时器越来越多(点击 n 次就有 n+1 个定时器),它们都在疯狂地每秒执行 console.log,所以你看到数字刷屏得越来越快!
所以问题是:每次状态更新都创建一个新定时器,但从不清理旧的,导致定时器数量指数级增长,日志刷屏。
正确修复方式
有两种方案,任选其一:
方案1:修复 cleanup(推荐,最标准)
jsx
useEffect(() => {
console.log('effct');
const timer = setInterval(() => {
console.log(num);
}, 1000);
// 一定要加返回清理函数!
return () => {
console.log('remove timer');
clearInterval(timer);
};
}, [num]); // 依赖 num 是可以的,只要有 cleanup 就不会泄漏
这样每次 num 变化时:
- 先执行上一次的 cleanup(清除旧定时器)
- 再创建新定时器 → 永远只会有一个定时器在运行,不会爆炸。
方案2:如果不想让 num 变化时重启定时器,把依赖改成空数组
jsx
useEffect(() => {
console.log('effct');
const timer = setInterval(() => {
// 注意:这里拿到的 num 是挂载时的值,不会更新!
// 如果想打印最新值,需要用 useRef 存最新 num
console.log(num);
}, 1000);
return () => {
clearInterval(timer);
};
}, []); // 只在挂载时执行一次
2、空更新(保活渲染)
来看这张图,这是延续了上面的“执行顺序相关知识点”
function App() {
const [num, setNum] = useState(0);
console.log('yyy');
useEffect(() => {
console.log('xxx');
queryData().then(data => setNum(data)); // 2秒后返回666
}, []);
useEffect(() => {
console.log(num, 'zzz');
}, [1,2,3,new Date()]); // 坑:每次都变化!
useEffect(() => {
console.log('ddd');
});
return <div>{num}</div>;
}
如果把依赖[] 改成了依赖[num],就会发生图片中的情况————'yyy'多打印了一次,这就显得有点诡异了,而且我们已经明确把严格模式给注释掉了,那这是怎么回事呢?
我们来按照代码走一遍执行流程:
严格的执行顺序和打印次数
-
初次渲染(num = 0)
- yyy ×1
- xxx ×1(effect 执行)
- 0 zzz ×1
- ddd ×1
- 启动第1个 queryData(2秒后 resolve 666)
-
约2秒后,第1个 queryData 完成 → setNum(666)
- num 0 → 666(值真的变了)→ 触发第2次渲染
- yyy ×1(第2次)
- xxx ×1(第2次!因为 num 变了,effect 重新执行)
- 666 zzz ×1(第2次)
- ddd ×1(第2次)
- 启动第2个 queryData(又一个2秒延迟)
-
又约2秒后,第2个 queryData 完成 → setNum(666)
-
当前 num 已经是 666
-
setNum(666) 设置完全相同的值(666 === 666)
-
React 优化:如果 useState 的新值与当前值严格相等(===),直接跳过更新
- 不调度渲染
- 不执行组件函数体
- 不检查任何 useEffect 的依赖
- 不执行任何 effect 回调
-
→ 没有第3次 yyy、xxx、zzz、ddd
-
完了,更晕了,明明按道理来说只打印两次'yyy'啊
那你为什么看到 yyy 打印了 3 次?
只有一种常见情况会导致 只有 yyy 多打印一次,而其他 effect 日志没多:
第2个 queryData 的 setNum(666) 虽然值相同,但由于某些原因,React 还是调度了一次“空更新”渲染(但不运行 effect) 。
这种情况在以下场景会出现:
- 你在项目中还有其他状态更新(比如父组件 props 变、Context 变、另一个 setState)
- Vite 热刷新正好在第2个 setNum 时触发了一次保活渲染
- React 18 的 automatic batching 在异步链中偶尔没完全 bail out
- 浏览器 DevTools 开启了 “Paint flashing” 或其他调试选项干扰渲染计数
最常见的真实原因:Vite 的 Fast Refresh 在你等待的这几秒内,检测到文件没变但保活状态,导致额外触发一次轻量渲染。
这种渲染的特点是:
- 组件函数体执行了(yyy 打印)
- 但因为 state 没实际变化,React 跳过所有 useEffect 依赖检查和执行
- 所以你只会看到额外一次 'yyy',而 'xxx'、'zzz'、'ddd' 依然只有 2 次
我们再在函数体添加一行打印日志来验证
console.log('多执行了吗?');
最终结论
-
正常情况下,依赖 [num] 时所有日志都是 2 次(因为第二次 setNum 同值被 React 完全跳过)
-
你看到 只有 yyy 是 3 次,其他仍是 2 次 → 是 Vite 热更新或异步批处理导致的一次“空渲染”
- 函数体执行了(yyy +3)
- 但 React 认为 state 没变,跳过 effect 阶段(xxx、zzz、ddd 不执行第3次)
这正是 React 优化的结果:即使有 setState 调用,只要值没变,就尽量少做事儿。
所以不是 effect “不执行”,而是被 React 聪明地阻止了不必要的执行。
六、总结:React Hooks 使用心法
-
useState
- 复杂初始化用函数,且保持纯函数
- 更新优先用函数式(prev => ...)
- 状态是组件的核心,改变状态 = 重新渲染
-
useEffect
- 数据请求依赖写 []
- 定时器/订阅必须写清理函数
- 依赖数组要精确,别放 new Date()、{} 等不稳定值
- 条件渲染会触发子组件反复挂载
-
纯函数思维
- 组件函数、初始化函数、更新函数尽量保持纯
- 副作用都放 useEffect 里处理
掌握了这些,你就真正理解了 React 函数组件的底层逻辑:状态驱动渲染,副作用隔离处理。
写 React 代码时,多问自己两个问题:
- 这个操作是纯计算吗?
- 这个副作用有没有清理?
坚持这样做,你的代码会越来越稳定、可预测、可维护。
最后,附上一个调试时的金句自勉:
“状态纯洁,副作用隔离,清理不忘,渲染无忧。”