组件生命周期
组件生命周期是 React 中非常重要的概念,它描述了组件从创建到销毁的整个过程。在现代 React 中,我们主要通过 useEffect Hook 来处理生命周期相关的逻辑,但理解生命周期的概念仍然至关重要。
理解组件的生命周期
每个 React 组件都会经历几个阶段,就像生物的生命周期一样:它诞生、成长(更新),最终消亡。理解这些阶段有助于我们在正确的时机执行正确的操作。
组件的三个核心阶段
┌─────────────────────────────────────────────────────────────┐
│ 组件生命周期 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 挂载阶段(Mounting) │
│ ├── 组件被创建 │
│ ├── 初始化 state │
│ └── 组件被插入 DOM │
│ ↓ │
│ 更新阶段(Updating) │
│ ├── props 变化 │
│ ├── state 变化 │
│ └──父组件重新渲染 │
│ ↓ │
│ 卸载阶段(Unmounting) │
│ └── 组件从 DOM 中移除 │
│ │
└─────────────────────────────────────────────────────────────┘
为什么需要理解生命周期?
在实际开发中,我们经常需要在特定时机执行特定操作:
- 挂载时:发起网络请求、设置订阅、初始化第三方库
- 更新时:响应数据变化、执行副作用
- 卸载时:清理定时器、取消订阅、释放资源
理解生命周期可以帮你避免常见的陷阱,比如内存泄漏和不必要的重复渲染。
useEffect:生命周期的现代方式
在函数组件中,useEffect 是处理生命周期逻辑的核心工具。它将类组件中分散在多个生命周期方法中的逻辑统一到了一个 API 中。
useEffect 的执行时机
useEffect 在组件渲染到屏幕之后执行。这意味着它不会阻塞浏览器的屏幕更新,用户会先看到更新后的 UI,然后才执行副作用。
function EffectTimingDemo() { const [count, setCount] = useState(0); const [logs, setLogs] = useState([]); const addLog = (message) => { setLogs(prev => [...prev, `${new Date().toLocaleTimeString()}: ${message}`]); }; useEffect(() => { addLog('组件挂载完成'); return () => addLog('组件即将卸载'); }, []); useEffect(() => { if (count > 0) { addLog(`count 更新为 ${count}`); } }, [count]); return ( <div> <p>计数: <strong style={{ fontSize: '24px' }}>{count}</strong></p> <button onClick={() => setCount(c => c + 1)}>增加</button> <div style={{ marginTop: '10px', padding: '10px', background: '#f1f5f9', borderRadius: '4px', maxHeight: '150px', overflow: 'auto' }}> <strong>生命周期日志:</strong> {logs.slice(-5).map((log, i) => ( <p key={i} style={{ margin: '4px 0', fontSize: '12px', color: '#64748b' }}>{log}</p> ))} </div> </div> ); }
依赖数组的作用
依赖数组决定了 useEffect 何时执行:
// 1. 每次渲染后都执行
useEffect(() => {
console.log('每次渲染后执行');
});
// 2. 只在挂载时执行一次
useEffect(() => {
console.log('组件挂载');
}, []);
// 3. 在挂载时和 count 变化时执行
useEffect(() => {
console.log('count 变化:', count);
}, [count]);
// 4. 在挂载时和 count 或 name 任一变化时执行
useEffect(() => {
console.log('count 或 name 变化');
}, [count, name]);
清理函数
useEffect 可以返回一个清理函数,这个函数会在组件卸载时或下一次 effect 执行前被调用。
function TimerDemo() { const [seconds, setSeconds] = useState(0); const [isRunning, setIsRunning] = useState(false); useEffect(() => { if (!isRunning) return; // 设置定时器 const intervalId = setInterval(() => { setSeconds(s => s + 1); }, 1000); // 清理函数:在组件卸载或 isRunning 变为 false 时执行 return () => { clearInterval(intervalId); }; }, [isRunning]); return ( <div style={{ textAlign: 'center', padding: '20px' }}> <div style={{ fontSize: '48px', marginBottom: '20px' }}> {Math.floor(seconds / 60).toString().padStart(2, '0')}: {(seconds % 60).toString().padStart(2, '0')} </div> <button onClick={() => setIsRunning(!isRunning)} style={{ padding: '10px 20px', background: isRunning ? '#ef4444' : '#22c55e', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }} > {isRunning ? '暂停' : '开始'} </button> <button onClick={() => setSeconds(0)} style={{ marginLeft: '10px', padding: '10px 20px', background: '#64748b', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }} > 重置 </button> </div> ); }
清理函数的执行时机
清理函数在以下情况执行:
- 组件卸载时:组件从 DOM 中移除
- 下一次 effect 执行前:依赖变化导致 effect 需要重新执行
// 假设 roomId 从 'general' 变为 'travel'
useEffect(() => {
const connection = connectToRoom(roomId);
return () => {
// 1. 先断开与 'general' 的连接
connection.disconnect();
};
}, [roomId]);
// 2. 然后连接到 'travel'
这个机制确保了资源始终与当前的 props 和 state 保持同步。
挂载阶段详解
挂载阶段是组件生命周期的开始,在这个阶段组件被创建并插入到 DOM 中。
挂载时机的典型操作
function MountDemo() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { // 模拟数据获取 let cancelled = false; async function fetchData() { try { setLoading(true); // 模拟 API 调用 await new Promise(resolve => setTimeout(resolve, 1500)); if (!cancelled) { setData({ title: 'React 生命周期教程', author: 'React Team', views: 12345 }); } } catch (err) { if (!cancelled) { setError('加载失败'); } } finally { if (!cancelled) { setLoading(false); } } } fetchData(); // 清理:防止组件卸载后更新状态 return () => { cancelled = true; }; }, []); if (loading) { return ( <div style={{ textAlign: 'center', padding: '40px' }}> <div style={{ display: 'inline-block', width: '40px', height: '40px', border: '4px solid #e2e8f0', borderTopColor: '#3b82f6', borderRadius: '50%', animation: 'spin 1s linear infinite' }} /> <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style> <p style={{ marginTop: '10px', color: '#64748b' }}>加载中...</p> </div> ); } if (error) { return <p style={{ color: '#ef4444' }}>{error}</p>; } return ( <div style={{ padding: '20px', background: '#f8fafc', borderRadius: '8px' }}> <h3 style={{ margin: '0 0 10px' }}>{data.title}</h3> <p style={{ margin: '4px 0', color: '#64748b' }}>作者: {data.author}</p> <p style={{ margin: '4px 0', color: '#64748b' }}>浏览: {data.views.toLocaleString()}</p> </div> ); }
避免内存泄漏
在组件卸载时,如果异步操作仍在进行,可能会尝试更新已卸载组件的状态,导致内存泄漏。
// 错误示范:可能导致内存泄漏
useEffect(() => {
fetchData().then(data => {
setData(data); // 如果组件已卸载,这会警告
});
}, []);
// 正确做法:使用清理标志
useEffect(() => {
let cancelled = false;
fetchData().then(data => {
if (!cancelled) {
setData(data);
}
});
return () => { cancelled = true; };
}, []);
// 或者使用 AbortController 取消请求
useEffect(() => {
const controller = new AbortController();
fetchData({ signal: controller.signal }).then(data => {
setData(data);
});
return () => controller.abort();
}, []);
更新阶段详解
更新阶段发生在组件的 props 或 state 发生变化时。React 会重新渲染组件,并可能触发副作用。
更新触发的条件
组件会在以下情况下更新:
- 父组件传递新的 props
- 组件内部调用 setState 更新 state
- 父组件重新渲染(即使 props 未变)
function UpdateDemo() { const [count, setCount] = useState(0); const [text, setText] = useState(''); const renderCount = useRef(0); // 记录渲染次数 renderCount.current++; // 响应 count 变化 useEffect(() => { if (count > 0) { console.log(`count 更新为 ${count}`); } }, [count]); // 响应 text 变化 useEffect(() => { if (text) { console.log(`text 更新为 "${text}"`); } }, [text]); return ( <div> <p>渲染次数: <strong>{renderCount.current}</strong></p> <p>Count: {count}</p> <button onClick={() => setCount(c => c + 1)}>增加 Count</button> <div style={{ marginTop: '10px' }}> <input value={text} onChange={e => setText(e.target.value)} placeholder="输入文字..." style={{ padding: '8px', width: '200px' }} /> </div> <p style={{ fontSize: '12px', color: '#64748b', marginTop: '10px' }}> 打开浏览器控制台查看更新日志 </p> </div> ); }
前一次 props/state 的值
有时候我们需要访问前一次的值来进行比较。
function PreviousValueDemo() { const [count, setCount] = useState(0); const prevCountRef = useRef(); // 在渲染后保存当前值 useEffect(() => { prevCountRef.current = count; }, [count]); const prevCount = prevCountRef.current; return ( <div style={{ textAlign: 'center' }}> <p>当前值: <strong style={{ fontSize: '24px', color: '#3b82f6' }}>{count}</strong></p> <p>前一次值: <strong style={{ fontSize: '24px', color: '#94a3b8' }}>{prevCount ?? '-'}</strong></p> <p>变化: {prevCount !== undefined ? (count - prevCount > 0 ? '+' : '') + (count - prevCount) : '-'}</p> <button onClick={() => setCount(c => c + 1)} style={{ padding: '8px 16px' }}> 增加 </button> <button onClick={() => setCount(c => c - 1)} style={{ marginLeft: '8px', padding: '8px 16px' }}> 减少 </button> </div> ); }
封装为自定义 Hook
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// 使用
function MyComponent() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return <div>当前: {count}, 之前: {prevCount}</div>;
}
卸载阶段详解
卸载阶段是组件生命周期的终点,组件从 DOM 中被移除。这是执行清理工作的最佳时机。
需要清理的资源
在组件卸载时,应该清理以下类型的资源:
- 定时器:
setTimeout,setInterval - 订阅:WebSocket, 自定义事件, Redux store
- 网络请求:取消正在进行的 fetch/axios 请求
- 事件监听器:
addEventListener添加的监听器 - 动画帧:
requestAnimationFrame
function UnmountDemo() { const [show, setShow] = useState(true); return ( <div> <button onClick={() => setShow(!show)} style={{ padding: '8px 16px', marginBottom: '10px' }} > {show ? '卸载组件' : '重新挂载'} </button> {show && <CounterWithCleanup />} </div> ); } function CounterWithCleanup() { const [count, setCount] = useState(0); const [logs, setLogs] = useState([]); useEffect(() => { const log = (msg) => setLogs(prev => [...prev.slice(-4), msg]); log('组件挂载'); const intervalId = setInterval(() => { setCount(c => c + 1); }, 1000); const handleResize = () => { log('窗口大小变化'); }; window.addEventListener('resize', handleResize); // 清理函数 return () => { log('组件卸载,清理资源'); clearInterval(intervalId); window.removeEventListener('resize', handleResize); }; }, []); return ( <div style={{ padding: '15px', background: '#f0fdf4', borderRadius: '8px', border: '1px solid #86efac' }}> <p style={{ fontSize: '32px', margin: '0 0 10px' }}>{count}</p> <p style={{ fontSize: '12px', color: '#64748b' }}>计数器每秒增加 1</p> <div style={{ fontSize: '12px', marginTop: '10px' }}> {logs.map((log, i) => ( <p key={i} style={{ margin: '2px 0', color: '#166534' }}>{log}</p> ))} </div> </div> ); }
类组件生命周期(遗留知识)
虽然现代 React 开发推荐使用函数组件,但你可能会在旧代码中遇到类组件。了解类组件的生命周期方法有助于维护遗留项目。
类组件生命周期方法对照表
| 类组件方法 | 触发时机 | 函数组件等效 |
|---|---|---|
constructor | 组件创建时 | useState 初始化 |
componentDidMount | 挂载后 | useEffect(() => {}, []) |
componentDidUpdate | 更新后 | useEffect(() => {}, [deps]) |
componentWillUnmount | 卸载前 | useEffect 返回清理函数 |
shouldComponentUpdate | 更新前 | React.memo |
getDerivedStateFromProps | 渲染前 | 渲染时更新 state |
getSnapshotBeforeUpdate | DOM 更新前 | useLayoutEffect |
类组件示例
// 类组件的生命周期方法
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
console.log('constructor: 组件创建');
}
componentDidMount() {
console.log('componentDidMount: 组件挂载完成');
// 发起网络请求、设置订阅等
}
componentDidUpdate(prevProps, prevState) {
console.log('componentDidUpdate: 组件更新');
if (prevState.count !== this.state.count) {
console.log('count 从', prevState.count, '变为', this.state.count);
}
}
componentWillUnmount() {
console.log('componentWillUnmount: 组件即将卸载');
// 清理定时器、取消订阅等
}
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
增加
</button>
</div>
);
}
}
迁移指南
将类组件迁移到函数组件:
// 类组件
class Profile extends React.Component {
state = { user: null, loading: true };
componentDidMount() {
fetchUser(this.props.userId).then(user => {
this.setState({ user, loading: false });
});
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.setState({ loading: true });
fetchUser(this.props.userId).then(user => {
this.setState({ user, loading: false });
});
}
}
render() {
const { user, loading } = this.state;
if (loading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
}
// 等效的函数组件
function Profile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
Effect 的生命周期视角
React 官方文档建议我们从 Effect 本身的视角来理解生命周期,而不是从组件的视角。
从组件视角 vs 从 Effect 视角
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
从组件视角看:
- 组件挂载 → 连接到房间
- 组件更新 → 断开旧连接,连接到新房间
- 组件卸载 → 断开连接
从 Effect 视角看:
- Effect 开始同步 → 连接到当前 roomId
- Effect 停止同步 → 断开连接
- Effect 重新同步 → 断开旧连接,连接到新 roomId
这种视角的转变让我们更关注"同步"这个核心概念,而不是"生命周期的时机点"。
Effect 应该代表独立的同步过程
每个 Effect 应该做一件事,代表一个独立的同步过程。
// 不推荐:一个 Effect 做多件事
useEffect(() => {
logVisit(roomId); // 分析日志
const connection = createConnection(roomId);
connection.connect(); // 连接聊天室
document.title = roomId; // 更新标题
return () => connection.disconnect();
}, [roomId]);
// 推荐:拆分为多个独立的 Effect
useEffect(() => {
logVisit(roomId);
}, [roomId]);
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
useEffect(() => {
document.title = roomId;
}, [roomId]);
常见陷阱与解决方案
1. 无限循环
当 useEffect 的依赖包含了在 effect 内部更新的状态时,会导致无限循环。
// 错误:无限循环
function BadExample() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // 更新 count
}, [count]); // 依赖 count,导致无限循环
}
// 正确方案 1:移除不必要的依赖
function GoodExample1() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(c => c + 1); // 使用函数式更新
}, []); // 空依赖,只执行一次
}
// 正确方案 2:检查是否真的需要这个 effect
function GoodExample2() {
const [count, setCount] = useState(0);
// 也许这个更新根本不需要在 effect 中进行
}
2. 依赖数组遗漏
遗漏依赖可能导致 effect 使用过时的 props 或 state。
// 错误:遗漏依赖
function BadExample({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
fetchData(userId).then(setData);
}, []); // 缺少 userId 依赖
// 当 userId 变化时,不会重新获取数据!
}
// 正确:包含所有依赖
function GoodExample({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
fetchData(userId).then(setData);
}, [userId]); // userId 变化时重新获取
}
3. 在渲染中执行副作用
副作用不应该在渲染过程中执行,这会导致问题。
// 错误:在渲染中执行副作用
function BadExample() {
const [count, setCount] = useState(0);
if (count > 10) {
setCount(0); // 在渲染中更新状态,导致无限循环
}
return <div>{count}</div>;
}
// 正确:在 effect 中处理
function GoodExample() {
const [count, setCount] = useState(0);
useEffect(() => {
if (count > 10) {
setCount(0);
}
}, [count]);
return <div>{count}</div>;
}
// 或者更好的方式:在事件处理器中处理
function BetterExample() {
const [count, setCount] = useState(0);
const handleClick = () => {
const newCount = count + 1;
setCount(newCount > 10 ? 0 : newCount);
};
return <button onClick={handleClick}>{count}</button>;
}
useLayoutEffect 与 useEffect 的区别
useLayoutEffect 与 useEffect 语法相同,但执行时机不同。
执行时机对比
状态更新
↓
React 计算新的虚拟 DOM
↓
React 更新真实 DOM
↓
useLayoutEffect 执行 ← 同步执行,阻塞浏览器绘制
↓
浏览器绘制屏幕
↓
useEffect 执行 ← 异步执行,不阻塞绘制
使用场景
- useEffect:大多数副作用,不阻塞视觉更新
- useLayoutEffect:需要读取 DOM 布局并同步重新渲染时
function Tooltip({ children, content }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
const tooltipRef = useRef(null);
const [show, setShow] = useState(false);
useLayoutEffect(() => {
if (show && triggerRef.current && tooltipRef.current) {
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
// 在浏览器绘制前计算位置,避免闪烁
setPosition({
top: triggerRect.top - tooltipRect.height - 8,
left: triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
});
}
}, [show]);
return (
<>
<span
ref={triggerRef}
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
>
{children}
</span>
{show && (
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: position.top,
left: position.left,
background: '#1e293b',
color: 'white',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '14px'
}}
>
{content}
</div>
)}
</>
);
}
小结
- 三个核心阶段:挂载、更新、卸载
- useEffect 是函数组件处理生命周期逻辑的核心工具
- 依赖数组 决定了 effect 何时执行
- 清理函数 在组件卸载或 effect 重新执行前调用
- 从 Effect 视角理解:关注同步的开始和停止,而非组件生命周期
- 避免常见陷阱:无限循环、依赖遗漏、渲染中执行副作用
- useLayoutEffect 用于需要同步读取 DOM 布局的场景
练习
- 实现一个自定义 Hook
useInterval,能够安全地设置和清理 interval - 实现一个计数器组件,显示前一次的值和当前值的差异
- 创建一个组件,在挂载时订阅窗口大小变化,卸载时取消订阅
- 使用
useLayoutEffect实现一个不会闪烁的下拉菜单 - 将一个使用生命周期方法的类组件迁移为函数组件