3、useEffect 生命周期:告别 Class 的生命周期函数
说实话,我刚从 Class 组件切换到 Hooks 时,最不适应的就是生命周期。
以前写 componentDidMount、componentDidUpdate、componentWillUnmount,三个函数各司其职,逻辑清晰。但 useEffect 一个函数就把它们全包了,刚开始真有点懵。
不过用久了你会发现,这种设计其实更合理。为什么?因为它强迫你「关注副作用本身」,而不是「关注组件在哪个阶段」。
3.1 从 Class 到 Effect:思维转换
先看一个典型的 Class 组件写法:
class UserProfile extends React.Component {
componentDidMount() {
// 初始化:获取用户数据
fetchUser(this.props.userId);
}
componentDidUpdate(prevProps) {
// 更新:userId 变了就重新获取
if (prevProps.userId !== this.props.userId) {
fetchUser(this.props.userId);
}
}
componentWillUnmount() {
// 清理:取消请求或订阅
cancelRequest();
}
render() {
return <div>...</div>;
}
}
换成 useEffect 怎么写?
function UserProfile({ userId }) {
useEffect(() => {
// 挂载 & 更新时执行
fetchUser(userId);
// 卸载时执行清理
return () => {
cancelRequest();
};
}, [userId]); // 只有 userId 变化时才重新执行
return <div>...</div>;
}
看到了吗?三个生命周期函数,被合并成一个 useEffect。我个人觉得,这种写法更符合直觉——「当 userId 变化时,我要做这件事」。而不是「组件挂载了我要做这个,更新了我要做那个」。
3.2 模拟 componentDidMount:空依赖数组
想只在组件挂载时执行一次?传一个空数组 [] 就行。
useEffect(() => {
console.log('组件挂载了');
// 初始化数据、注册事件监听等
}, []);
我在项目中遇到过一个问题:有个同事在空数组里用了某个 prop,结果 prop 更新了但 Effect 没重新执行,数据一直不对。嗯,这里要记住——如果你在 Effect 里用了某个变量,它就应该出现在依赖数组里。
3.3 模拟 componentDidUpdate:有依赖的 Effect
想监听某个值的变化?把它放进依赖数组:
useEffect(() => {
console.log('count 变了:', count);
// 每次 count 变化时执行
}, [count]);
如果想监听所有状态变化?不传依赖数组:
useEffect(() => {
console.log('任意状态更新了');
}); // 没有第二个参数
3.4 模拟 componentWillUnmount:清理函数
Effect 返回的函数就是清理函数,在组件卸载时执行:
useEffect(() => {
const timer = setInterval(() => {
console.log('tick');
}, 1000);
// 清理函数
return () => {
clearInterval(timer);
console.log('定时器已清理');
};
}, []);
我曾经犯过一个错:在 Effect 里订阅了 WebSocket,但忘了写清理函数。结果组件卸载后,订阅还在,回调里还尝试更新已卸载组件的状态,控制台报了一堆警告。从那以后,我养成了一个习惯——只要 Effect 里有订阅、定时器、事件监听,就一定要写清理函数。
3.5 三种模式的对比表格
| Class 生命周期 | useEffect 写法 | 依赖数组 | 执行时机 |
|---|---|---|---|
| componentDidMount | useEffect(() => {...}, []) |
[] |
首次渲染后 |
| componentDidUpdate | useEffect(() => {...}, [dep]) |
[dep] 或不传 |
依赖变化时或每次渲染后 |
| componentWillUnmount | useEffect(() => { return () => {...} }, []) |
[] |
组件卸载时 |
3.6 避坑指南:常见的 useEffect 陷阱
陷阱一:依赖数组漏了变量
说白了,这就是 React 官方说的「闭包陷阱」。Effect 里用到的变量,如果没写在依赖数组里,它拿到的永远是旧值。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 永远打印 0!
}, 1000);
return () => clearInterval(timer);
}, []); // 漏了 count
return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
解决方案:要么把 count 加进依赖数组,要么用 ref 保存最新值。
陷阱二:无限循环
Effect 里更新状态,状态变化又触发 Effect 重新执行,形成死循环。
useEffect(() => {
setCount(count + 1); // 每次执行都更新状态
}); // 没有依赖数组,每次渲染都执行
陷阱三:清理函数执行时机
很多人以为清理函数只在卸载时执行。其实不是——每次 Effect 重新执行前,都会先执行上一次的清理函数。
useEffect(() => {
console.log('订阅', id);
return () => {
console.log('取消订阅', id);
};
}, [id]);
当 id 从 1 变成 2 时,执行顺序是:
- 取消订阅 id=1
- 订阅 id=2
这种设计很巧妙,它保证了不会同时存在两个订阅。
3.7 实战建议:什么时候用哪个?
我个人习惯这样划分:
- 数据初始化(比如请求列表数据)→ 用
[],模拟 didMount - 监听 prop/state 变化(比如根据 userId 切换用户)→ 用
[userId],模拟 didUpdate - 清理资源(定时器、订阅、事件监听)→ 用清理函数,模拟 willUnmount
- 多个独立副作用 → 拆成多个 useEffect,别写在一个里面
举个例子,一个聊天组件可能需要三个 Effect:
function ChatRoom({ roomId }) {
// 1. 连接房间(挂载时 + roomId 变化时)
useEffect(() => {
connectToRoom(roomId);
return () => disconnectFromRoom(roomId);
}, [roomId]);
// 2. 发送已读回执(每次消息列表变化时)
useEffect(() => {
sendReadReceipt(messages);
}, [messages]);
// 3. 页面标题(挂载时设置一次)
useEffect(() => {
document.title = `聊天室 - ${roomId}`;
}, [roomId]);
return // ...
}
你看,三个 Effect 各管各的事,互不干扰。比在 Class 里把逻辑分散到不同生命周期函数里清晰多了。
最后说一句:别想着「用 useEffect 模拟生命周期」,而是想「这个副作用什么时候该执行」。思维转过来后,你会发现 Hooks 的设计其实更优雅。