Hooks核心 —— 告别class的现代React
上一章我们用 useState 让组件拥有了”记忆”。但一个真正的应用不可能只管理数据——它还要请求接口、监听事件、操控 DOM。这些”渲染之外”的事情,就是 Hooks 的用武之地。掌握 useEffect 和 useRef,你才算真正拿到了函数组件的驾照。
📋 开篇自测:你已经知道多少?
- 你能说清楚 useEffect 的依赖数组为空数组
[]和完全不传有什么区别吗?- 你知道 useRef 除了操作 DOM,还能用来做什么吗?
- 在一个定时器回调里读取 state,为什么拿到的总是旧值?你能说出至少两种解决方案吗?
一、为什么需要 Hooks?—— 从 class 组件的痛点说起
在 Hooks 出现之前(React 16.8 之前),想在组件里做任何”有副作用”的事——请求数据、订阅事件、操作 DOM——你必须写 class 组件,并把逻辑分散在 componentDidMount、componentDidUpdate、componentWillUnmount 等生命周期方法里。
这带来了三个让人头疼的问题:
痛点一:相关逻辑被拆散到不同生命周期里
比如你要监听窗口 resize 事件:在 componentDidMount 里添加监听,在 componentWillUnmount 里移除监听。如果还要根据某个 prop 变化重新注册,还得在 componentDidUpdate 里写一遍。一个功能的代码被撕成三块,读起来像在拼拼图。
痛点二:不相关的逻辑挤在同一个生命周期里
componentDidMount 里既要请求用户数据,又要注册定时器,还要初始化第三方库——它变成了一个”杂物间”。
痛点三:this 的指向让人崩溃
this.handleClick = this.handleClick.bind(this) 这种代码你大概不想再写第二遍了。
Hooks 的解决思路非常优雅:把逻辑按”关注点”组织,而不是按”生命周期阶段”组织。一个功能相关的代码全部写在一个 useEffect 里,添加和清理放在一起,一目了然。
打一个比方:class 组件的生命周期就像按”早中晚”来安排一天——早上做早餐、健身、送孩子上学全混在一起;而 Hooks 则是按”事务”来安排——“健身”这件事从热身到拉伸全写在一个 block 里,清晰得多。
现在,React 官方已经把 class 组件归入了 “Legacy”(遗产)目录。这不是说 class 不能用,而是明确告诉你:未来是 Hooks 的。
二、useEffect —— 副作用管理
2.1 什么是”副作用”
函数组件本质上是一个”纯函数”:传入 props 和 state,返回 JSX。但现实中,我们总需要做一些”渲染之外”的事情:
- 向服务器请求数据
- 手动修改 DOM
- 设置定时器
- 订阅事件(WebSocket、窗口 resize 等)
这些在函数执行过程中”偷偷”干的事情,就叫做副作用(side effect)。useEffect 就是 React 提供的”副作用管理器”。
你可以把它理解成一支善后小分队——组件渲染完成后,useEffect 里的函数才会执行,专门负责那些”渲染之外”的脏活累活。
2.2 基本用法:组件挂载时请求数据
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 定义异步函数
async function fetchUser() {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
setLoading(false);
}
fetchUser();
}, [userId]); // 当 userId 变化时重新请求
if (loading) return <p>加载中...</p>;
return <h1>你好,{user.name}</h1>;
}
注意这里有一个细节:useEffect 的回调函数不能直接用 async。因为 useEffect 期望回调要么不返回东西,要么返回一个清理函数。而 async 函数总是返回一个 Promise,React 不知道怎么处理。所以我们在内部定义一个 async 函数再调用它。
🤔 想一想 如果把
fetch请求直接写在函数组件的顶层(不放在 useEffect 里),会发生什么?为什么这样做是有问题的?
三、useEffect 的依赖数组 —— 精确控制执行时机
useEffect 的第二个参数是一个依赖数组(dependency array),它决定了 effect 在什么时候执行。这是 useEffect 最核心、也最容易搞混的部分。
3.1 三种模式一网打尽
// 模式一:不传依赖数组 —— 每次渲染后都执行
useEffect(() => {
console.log('组件渲染了');
});
// 模式二:传空数组 —— 仅在挂载时执行一次
useEffect(() => {
console.log('组件挂载了');
}, []);
// 模式三:传入依赖 —— 依赖变化时执行
useEffect(() => {
console.log(`count 变了:${count}`);
}, [count]);
用一张表来总结:
| 依赖数组 | 执行时机 | 类比 |
|---|---|---|
| 不传 | 每次渲染后 | 无条件加班——只要公司没关门,你就得干活 |
[] | 仅挂载时执行一次 | 入职培训——只在第一天做一次 |
[a, b] | a 或 b 变化时执行 | 按需值班——只有相关任务来了才出动 |
3.2 依赖数组的工作原理
React 会在每次渲染后,把本次的依赖数组和上次的逐项比较(使用 Object.is)。只要有一项不同,就重新执行 effect 函数。
function SearchResults({ query, page }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${query}&page=${page}`)
.then(res => res.json())
.then(data => setResults(data));
}, [query, page]); // query 或 page 变化时重新搜索
return (
<ul>
{results.map(item => <li key={item.id}>{item.title}</li>)}
</ul>
);
}
这里的关键原则是:effect 里用到了哪些”会变化的值”,就把它们放进依赖数组。如果你漏了某个依赖,effect 里读到的就是旧值,这就是后面要讲的”闭包陷阱”的根源。
四、useEffect 的清理机制 —— 避免内存泄漏
4.1 为什么需要清理
想象你在一家餐厅吃饭。你点了菜(启动了副作用),吃完要走了(组件要卸载或 effect 要重新执行),你得买单、擦桌子(清理副作用)。如果不清理,桌上的碗碟会越堆越多——在程序里,这就是内存泄漏。
常见需要清理的场景:
- 定时器(
setInterval/setTimeout) - 事件监听(
addEventListener) - WebSocket 连接
- 订阅(如 RxJS、EventEmitter)
4.2 清理函数的写法
useEffect 的回调可以返回一个函数,这个函数就是清理函数。它会在两个时机被调用:
- 下一次 effect 执行之前(依赖变化时)
- 组件卸载时
function WindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
// 添加监听
window.addEventListener('resize', handleResize);
// 返回清理函数:移除监听
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // 空数组:只绑定一次
return <p>窗口宽度:{width}px</p>;
}
再来看一个定时器的例子:
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setSeconds(prev => prev + 1); // 使用函数式更新
}, 1000);
return () => {
clearInterval(timer); // 组件卸载时清理定时器
};
}, []);
return <p>已运行 {seconds} 秒</p>;
}
这里有个细节值得注意:我们在 setInterval 回调里用了 setSeconds(prev => prev + 1) 而不是 setSeconds(seconds + 1)。为什么?因为后者会掉进”闭包陷阱”——这个坑我们在第八节会深入讲解。
4.3 清理的执行顺序
为了帮你建立更清晰的心理模型,我们来看一个依赖变化时的完整流程:
function ChatRoom({ roomId }) {
useEffect(() => {
console.log(`连接到房间 ${roomId}`);
const connection = connectToRoom(roomId);
return () => {
console.log(`断开房间 ${roomId}`);
connection.disconnect();
};
}, [roomId]);
return <h1>房间:{roomId}</h1>;
}
当 roomId 从 “general” 变为 “tech” 时,控制台输出:
断开房间 general ← 先执行旧 effect 的清理函数
连接到房间 tech ← 再执行新 effect
就像搬家:先退掉旧房子的租约,再签新房子的合同。顺序不能乱。
🤔 想一想 如果你在 useEffect 里发起了一个网络请求,但在请求还没返回时组件就卸载了,会发生什么?你该如何处理这种情况?(提示:可以在清理函数里使用
AbortController)
五、useRef —— 跨渲染周期的”记事本”
5.1 理解 useRef
如果 useState 是组件的”记忆”(改了会触发重新渲染),那 useRef 就是一张便利贴——你可以在上面随便写东西、随时修改,但不管你怎么改,组件都不会重新渲染。
const myRef = useRef(initialValue);
// myRef.current === initialValue
useRef 返回一个带有 current 属性的对象。这个对象在组件的整个生命周期中保持不变——每次渲染拿到的都是同一个对象引用。
这就是 useRef 和普通变量的本质区别:
function Counter() {
let normalVar = 0; // 每次渲染都重新创建,永远是 0
const refVar = useRef(0); // 跨渲染保持,值可以累积
const handleClick = () => {
normalVar += 1;
refVar.current += 1;
console.log(`普通变量: ${normalVar}`); // 永远打印 1
console.log(`ref 变量: ${refVar.current}`); // 1, 2, 3, 4...
};
return <button onClick={handleClick}>点我</button>;
}
5.2 useRef 的两大用途
用途一:保存不需要触发渲染的数据
比如保存定时器 ID、前一次的 state 值、累计的计数等。
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current; // 返回上一次渲染时的值
}
// 使用
function PriceDisplay({ price }) {
const prevPrice = usePrevious(price);
return (
<div>
<p>当前价格:{price}</p>
{prevPrice !== undefined && (
<p>
{price > prevPrice ? '涨了' : '跌了'}
</p>
)}
</div>
);
}
用途二:保存 DOM 引用(下一节详细讲)。
六、useRef 操作 DOM —— 聚焦、滚动、测量
6.1 获取 DOM 元素
这是 useRef 最常见的用法——把 ref 挂到 JSX 元素上,React 渲染完成后会自动把该 DOM 节点赋值给 ref.current。
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
// 组件挂载后自动聚焦
inputRef.current.focus();
}, []);
return <input ref={inputRef} placeholder="我会自动获得焦点" />;
}
6.2 实战:滚动到指定位置
function ScrollToBottom() {
const bottomRef = useRef(null);
const [messages, setMessages] = useState([
'你好!', '欢迎来到聊天室', '今天天气不错'
]);
const addMessage = () => {
setMessages(prev => [...prev, `新消息 ${prev.length + 1}`]);
};
// 每次消息更新后滚动到底部
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div>
<div style={{ height: 200, overflow: 'auto', border: '1px solid #ccc' }}>
{messages.map((msg, i) => (
<p key={i} style={{ padding: 8 }}>{msg}</p>
))}
<div ref={bottomRef} />
</div>
<button onClick={addMessage}>发送消息</button>
</div>
);
}
6.3 实战:测量元素尺寸
function MeasuredBox() {
const boxRef = useRef(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
if (boxRef.current) {
const { width, height } = boxRef.current.getBoundingClientRect();
setSize({ width: Math.round(width), height: Math.round(height) });
}
}, []);
return (
<div>
<div
ref={boxRef}
style={{ padding: 20, background: '#e0f0ff', display: 'inline-block' }}
>
测量我的尺寸
</div>
<p>宽:{size.width}px,高:{size.height}px</p>
</div>
);
}
你可以看到一个清晰的模式:useRef 负责”拿到”DOM 节点,useEffect 负责在渲染后”操作”它。这两个 Hook 经常搭配使用。
七、Hooks 的使用规则 —— 两条铁律
Hooks 不是普通函数,它们依赖 React 内部的调用顺序来工作。因此 React 定了两条不可违反的规则:
规则一:只在函数组件或自定义 Hook 的顶层调用
不要在循环、条件判断、嵌套函数里调用 Hook。
// 错误示范
function BadComponent({ isLoggedIn }) {
if (isLoggedIn) {
const [name, setName] = useState(''); // 条件语句里调用 Hook
}
useEffect(() => {
// ...
}, []);
return <div>...</div>;
}
// 正确做法
function GoodComponent({ isLoggedIn }) {
const [name, setName] = useState('');
useEffect(() => {
if (isLoggedIn) {
// 把条件判断放在 Hook 内部
fetchUserName().then(n => setName(n));
}
}, [isLoggedIn]);
return <div>{isLoggedIn ? `你好, ${name}` : '请登录'}</div>;
}
为什么? React 靠 Hook 的调用顺序来”记住”每个 Hook 对应的状态。如果第一次渲染调用了 3 个 Hook,第二次渲染因为条件分支只调用了 2 个,React 就会错位——第二个 Hook 拿到了第三个的值,整个状态系统就乱套了。
规则二:只在 React 函数中调用
Hook 只能在两个地方调用:
- 函数组件内部
- 自定义 Hook内部(即以
use开头的函数)
不要在普通 JavaScript 函数、class 组件、事件处理函数外部等地方调用。
// 错误:在普通函数里调用 Hook
function getWindowWidth() {
const [width, setWidth] = useState(window.innerWidth); // 报错!
return width;
}
// 正确:封装成自定义 Hook
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
}
🤔 想一想 ESLint 插件
eslint-plugin-react-hooks提供了rules-of-hooks和exhaustive-deps两条规则。前者帮你检查是否违反了”顶层调用”规则,后者帮你检查依赖数组是否写全。你的项目配置了这个插件吗?如果没有,强烈建议加上。
八、闭包陷阱 —— Hooks 最常踩的坑及解决方案
如果说 Hooks 有一个”终极 Boss”,那一定是闭包陷阱。它让无数开发者抓狂过,也是面试中的常考题。
8.1 问题复现
来看一个经典案例——用定时器每秒给 count 加 1:
function BrokenCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log('当前 count:', count); // 永远打印 0
setCount(count + 1); // 永远是 0 + 1 = 1
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖数组:effect 只执行一次
return <h1>{count}</h1>; // 界面永远显示 1
}
预期行为:count 每秒加 1,显示 0, 1, 2, 3, …
实际行为:count 从 0 变为 1 后就不动了。
为什么?
关键在于 useEffect 的依赖数组是 [],这意味着 effect 函数只在组件挂载时执行一次。而那一次执行时,count 的值是 0。
setInterval 的回调函数”记住了”创建时的 count 值(这就是 JavaScript 的闭包机制),之后不管组件渲染多少次,这个回调里的 count 永远是 0。所以每次执行 setCount(count + 1) 实际上都是 setCount(0 + 1)。
用一张图来理解:
第1次渲染:count = 0
└─ useEffect 创建了 setInterval
└─ 回调函数闭包捕获了 count = 0
└─ 每秒执行 setCount(0 + 1) = 1
第2次渲染:count = 1(但 setInterval 的回调没有更新)
└─ useEffect 没有重新执行(因为 deps 是 [])
└─ 回调函数里的 count 还是 0
└─ 每秒执行 setCount(0 + 1) = 1 ← 不再变化
⚠️ 常见误区 很多人以为
count是一个”实时变量”,在哪里读都是最新值。但在函数组件里,每次渲染都有自己的count。useEffect 闭包捕获的是那次渲染的count,而不是某个会自动更新的”引用”。这是理解闭包陷阱的核心:函数组件的每次渲染都是一次独立的快照。
8.2 解决方案一:使用函数式更新(最简单)
如果你只是要基于前一个值来计算新值,根本不需要读取外部的 count:
function FixedCounter1() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
// 不再引用外部的 count,而是通过参数拿到最新值
setCount(prevCount => prevCount + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <h1>{count}</h1>; // 正常递增:0, 1, 2, 3, ...
}
setCount(prevCount => prevCount + 1) 传入的是一个函数,React 会把最新的 state 作为参数传给它。这样就完全绕开了闭包——回调函数里没有引用任何外部的 state 变量。
适用场景: 新值可以从旧值推导出来,不需要读取其他状态。
8.3 解决方案二:把依赖加入数组(正统方案)
如果你确实需要在 effect 里读取 state 的最新值(比如要打印日志),那就把它加到依赖数组里:
function FixedCounter2() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('当前 count:', count); // 每次都能打印最新值
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
// 必须清理,否则每次 effect 重新执行都会多一个定时器
return () => clearInterval(timer);
}, [count]); // count 变了就重新执行 effect
return <h1>{count}</h1>;
}
每次 count 变化时:旧 effect 清理 -> 新 effect 执行 -> 新 setInterval 启动。这样回调函数里的 count 永远是最新的。
注意事项: 这种方式会导致定时器不断被销毁和重建。对于简单场景没问题,但如果你依赖”定时器不被中断”(比如精确计时),这种方案就不太合适了。
适用场景: 需要读取最新 state,且频繁重建 effect 可以接受。
8.4 解决方案三:useRef 保存最新值(最灵活)
当你既需要读取最新 state,又不想重建 effect 时,useRef 就是终极武器:
function FixedCounter3() {
const [count, setCount] = useState(0);
// 用 ref 保存最新的更新函数
const countRef = useRef(count);
countRef.current = count; // 每次渲染都同步最新值到 ref
useEffect(() => {
const timer = setInterval(() => {
// 从 ref 读取,永远是最新值
console.log('当前 count:', countRef.current);
setCount(countRef.current + 1);
}, 1000);
return () => clearInterval(timer);
}, []); // effect 只执行一次,定时器不会被重建
return <h1>{count}</h1>;
}
原理很简单:countRef 是一个在组件整个生命周期中保持不变的对象引用。每次渲染时我们把最新的 count 写入 countRef.current,而 setInterval 的回调里通过 countRef.current 去读取——因为读的是对象属性而不是闭包里的变量,所以永远能拿到最新值。
适用场景: 定时器、动画等不希望被重建的长期副作用,同时需要访问最新状态。
8.5 三种方案对比
| 方案 | 原理 | 优点 | 局限 |
|---|---|---|---|
| 函数式更新 | 不引用外部变量,从参数获取旧值 | 最简单 | 只能基于旧 state 推导新值 |
| 依赖数组 | 依赖变化时重建 effect | 最符合 React 设计理念 | effect 会被频繁重建 |
| useRef | 用 ref 桥接最新值 | 最灵活,effect 不重建 | 多一层间接,代码稍复杂 |
实际开发中,优先用方案一,解决不了再用方案二,方案二有性能问题再用方案三。
8.6 封装一个不受闭包陷阱影响的 useInterval
定时器 + 闭包陷阱是如此常见,我们可以把方案三封装成一个自定义 Hook:
import { useEffect, useRef } from 'react';
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
// 每次渲染后更新 ref 中保存的回调
useEffect(() => {
savedCallback.current = callback;
});
// 设置定时器(只执行一次)
useEffect(() => {
if (delay === null) return; // 传 null 可以暂停定时器
const timer = setInterval(() => {
savedCallback.current();
}, delay);
return () => clearInterval(timer);
}, [delay]);
}
使用时,再也不用担心闭包问题了:
function App() {
const [count, setCount] = useState(0);
useInterval(() => {
// 这里可以放心使用最新的 count
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>;
}
📝 掌握度自测
-
以下 useEffect 的依赖数组写法,哪个表示”仅在组件挂载时执行一次”?
- A)
useEffect(() => { ... }) - B)
useEffect(() => { ... }, []) - C)
useEffect(() => { ... }, [undefined]) - D)
useEffect(() => { ... }, null)
- A)
-
useEffect 清理函数的执行时机,以下哪个说法是正确的?
- A) 仅在组件卸载时执行
- B) 在下一次 effect 执行前 + 组件卸载时执行
- C) 在每次渲染前执行
- D) 仅在依赖数组变化时执行
-
以下代码中,
inputRef.current在什么时候被赋值为 DOM 元素?const inputRef = useRef(null); return <input ref={inputRef} />;- A) useRef 调用时
- B) return 语句执行时
- C) React 完成 DOM 渲染后
- D) 组件函数执行之前
-
以下哪个做法违反了 Hooks 的使用规则?
- A) 在函数组件顶层调用 useState
- B) 在自定义 Hook 中调用 useEffect
- C) 在 if 语句内部调用 useRef
- D) 在 useEffect 回调中调用 setState
-
解决闭包陷阱的”函数式更新”方案,其核心原理是什么?
- A) 使用 useRef 保存最新值
- B) 把 state 加入依赖数组
- C) 传函数给 setState,通过参数获取最新 state,避免闭包引用
- D) 使用 useLayoutEffect 替代 useEffect
💡 自我评估
- 答对5题:你已经扎实掌握了 useEffect 和 useRef 的核心用法,闭包陷阱对你来说不再是问题。
- 答对3-4题:基础不错,建议回顾答错的部分,特别是清理函数的执行时机和闭包陷阱的原理。
- 答对0-2题:别灰心,这一章内容密度较高。建议动手把文中的代码示例全部跑一遍,边跑边理解。
参考答案: 1-B, 2-B, 3-C, 4-C, 5-C
购买课程解锁全部内容
从组件到架构:12 章系统掌握现代 React
¥29.90