react生命周期
·
在理解生命周期函数定义的基础上,真正考验的是不同场景下如何选用,以及避开那些容易踩的坑。下面从类组件和 Hooks 函数组件两条线,用代码案例详细对比,深入场景、注意事项和陷阱。
一、类组件生命周期:场景、坑点与案例
1. 挂载阶段 Mounting
constructor(props)
- 场景:初始化内部 state、绑定事件处理函数。
- 坑点:不可调用
setState,不要做任何副作用(如请求数据、订阅)。如果忘记调用super(props),this.props会是undefined。 - 正确示例:
constructor(props) {
super(props);
this.state = { loading: true };
this.handleSave = this.handleSave.bind(this);
}
componentDidMount
- 场景:发起网络请求、订阅全局事件、操作 DOM、开启定时器。
- 坑点:
- 直接
setState会触发二次渲染,但只要用于初始化数据请求,这是正常的。但要注意,若在服务端渲染时调用会导致内存泄漏,应避免。 - 注册的监听器和定时器必须在
componentWillUnmount中清除,否则卸载后继续执行会导致状态更新错误。
- 直接
- 代码案例:
componentDidMount() {
this.fetchUser();
window.addEventListener('resize', this.handleResize);
this.timer = setInterval(this.tick, 1000);
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
clearInterval(this.timer);
}
不推荐用法:在 componentWillMount 中请求数据
废弃原因:异步渲染下该钩子可能被多次调用,请求会重复。所以请求请放在 componentDidMount。
2. 更新阶段 Updating
shouldComponentUpdate(nextProps, nextState)
- 场景:性能优化,避免不必要的渲染。默认返回
true。 - 坑点:若错误地返回
false,会导致组件无法响应 props 或 state 的变化。深层比较代价可能比渲染还高,一般推荐使用PureComponent或React.memo。 - 案例:
shouldComponentUpdate(nextProps) {
// 只有列表数据真正变化时才更新
return nextProps.items !== this.props.items;
}
getSnapshotBeforeUpdate(prevProps, prevState)
- 场景:需要在 DOM 更新前捕获信息(如滚动位置),更新后配合
componentDidUpdate恢复。 - 坑点:忘记返回值会导致
componentDidUpdate的第三个参数为undefined。此方法必须和componentDidUpdate配合使用。 - 代码案例(保持聊天框滚动位置):
getSnapshotBeforeUpdate(prevProps) {
if (prevProps.messages.length < this.props.messages.length) {
const list = this.listRef.current;
return list.scrollHeight - list.scrollTop; // 记录距离底部的位置
}
return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
if (snapshot !== null) {
this.listRef.current.scrollTop = this.listRef.current.scrollHeight - snapshot;
}
}
componentDidUpdate(prevProps, prevState, snapshot)
- 场景:根据变化后的 props 重新请求数据、操作 DOM。
- 致命陷阱:无限循环
如果直接在里面无条件调用setState或触发状态更新,且未做对比判断,就会死循环。必须先比较新旧 props 或 state。 - 错误案例 vs 正确案例:
// ❌ 错误!每次更新都会请求,导致无限循环
componentDidUpdate() {
this.fetchData(this.props.userId);
}
// ✅ 正确:仅在 userId 变化时请求
componentDidUpdate(prevProps) {
if (this.props.userId !== prevProps.userId) {
this.fetchData(this.props.userId);
}
}
3. 卸载阶段 Unmounting
componentWillUnmount
- 场景:清理所有副作用。
- 坑点:在这里调用
setState会触发警告且无效(组件已卸载)。此外,忘记取消异步请求的setState会导致内存泄漏和 React 警告。 - 异步请求安全案例:
componentDidMount() {
this.cancelled = false;
fetch(`/api/user/${this.props.id}`)
.then(res => res.json())
.then(data => {
if (!this.cancelled) {
this.setState({ user: data });
}
});
}
componentWillUnmount() {
this.cancelled = true; // 标记已卸载,避免 setState
}
4. 错误处理 Error Boundary
- 场景:捕获子树渲染错误,显示降级 UI。
- 坑点:无法捕获自身抛出的错误,只能捕获子组件。也无法捕获事件处理器、异步代码中的错误,需要配合
try/catch。 - 代码:
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 上报错误
logService.report(error, errorInfo);
}
render() {
if (this.state.hasError) return <h1>Something went wrong.</h1>;
return this.props.children;
}
}
二、函数组件 Hooks 模拟生命周期:场景与陷阱
函数组件没有生命周期方法,一切靠 Hooks 组合。最核心的是 useEffect。
1. 模拟 componentDidMount
- 场景:首次挂载后执行请求、订阅。
- 代码:
useEffect(() => {
fetchUser();
const subscription = eventSource.subscribe();
return () => {
subscription.unsubscribe(); // 模拟 componentWillUnmount
};
}, []); // 空依赖数组 → 只在挂载时运行一次
- 坑点:
- 依赖数组
[]必须真实无依赖,否则eslint-plugin-react-hooks会报警告。如果内部引用了 props 或 state,应该列入依赖。 - 函数中读取的 state 会形成闭包,获取到的是初次渲染的值,每次更新都会变化?实际上,如果依赖为空,内部引用的变量永远是初始值。若需要最新值,可用
useRef。
- 依赖数组
2. 模拟 componentDidUpdate
- 场景:当特定 props 或 state 变化时,执行副作用(如重新获取数据)。
- 代码:
useEffect(() => {
if (userId) {
fetchUserData(userId);
}
}, [userId]); // userId 变化时执行
-
陷阱1:忽略依赖导致闭包陈旧值
// ❌ 错误:count 陈旧,每次打印的 count 都是初始的 0 useEffect(() => { const timer = setInterval(() => { console.log(count); // 永远打印 0 }, 1000); return () => clearInterval(timer); }, []);解决:将
count列入依赖,或使用函数式更新setCount(c => c + 1),或用useRef存储最新 count。 -
陷阱2:异步请求在组件卸载后更新状态
即使依赖变化触发新请求,前一次请求可能还未完成。可使用标志位或AbortController。useEffect(() => { let cancelled = false; fetch(`/api/user/${userId}`) .then(res => res.json()) .then(data => { if (!cancelled) setUser(data); }); return () => { cancelled = true; }; }, [userId]);
3. 模拟 componentWillUnmount
- 代码:在
useEffect返回清理函数。
useEffect(() => {
const onResize = () => { /* ... */ };
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
4. useLayoutEffect — 同步读取布局
- 场景:需要同步获取 DOM 尺寸,并在修改后立即渲染,避免闪烁。如滚动到指定位置、测量元素。
- 对比 useEffect:
useEffect是在屏幕绘制后执行,如果修改 DOM 可能导致闪屏。
useLayoutEffect(() => {
// 获取元素尺寸并马上调整
const rect = ref.current.getBoundingClientRect();
setHeight(rect.height);
}, []);
- 坑点:会阻塞渲染,需谨慎使用。大多数情况
useEffect足够。
5. useRef 突破闭包陷阱
- 场景:需要在异步回调中获取最新 state 或 props,但又不想添加依赖。
const latestCount = useRef(count);
useEffect(() => {
latestCount.current = count; // 每次渲染更新 ref
});
useEffect(() => {
const timer = setInterval(() => {
console.log(latestCount.current); // 总是最新值
}, 1000);
return () => clearInterval(timer);
}, []);
三、常见坑点深度对比案例
陷阱对比:getDerivedStateFromProps 滥用 vs 正确替代
错误用法:用 props 复制到 state,导致后续 props 更新无法同步。
// ❌ 仅在挂载时复制,后续 props 变化不会更新 state
state = { email: this.props.email };
如果确实需要根据 props 重置 state,应使用 getDerivedStateFromProps,但更好的方式是用完全受控组件或使用 key 重置。
// ✅ 使用 key 强制重新挂载
<EmailInput key={userId} email={user.email} />
getDerivedStateFromProps 会让代码冗长,通常只在罕见场景(如 state 必须严格映射 props 变化,同时还要维护内部修改)使用。
陷阱对比:shouldComponentUpdate vs PureComponent vs React.memo
- 类组件:
React.PureComponent自动浅比较 props 和 state。 - 函数组件:
React.memo包裹。 - 搭配
useMemo和useCallback避免引用变化导致 memo 失效。
const MyComponent = React.memo(({ items, onItemClick }) => {
// 渲染
});
// 父组件中
const clickHandler = useCallback((id) => { /* ... */ }, []);
const sortedItems = useMemo(() => items.sort(), [items]);
陷阱:在事件处理中直接使用 state 导致闭包陈旧值
函数组件每次渲染都会创建新的函数,若事件处理函数(如定时器)捕获了旧的 state,解决方案是使用函数式更新。
// ❌ 如果依赖变化频繁可能仍捕获旧值
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count 陈旧
}, 1000);
return () => clearInterval(id);
}, [count]); // 每次 count 变化重建定时器,不好
// ✅ 函数式更新
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
四、场景对比:类组件 vs 函数组件实现订阅
类组件实现实时消息订阅
class ChatRoom extends React.Component {
componentDidMount() {
this.sub = MessageAPI.subscribe(this.props.roomId, this.handleNewMessage);
}
componentDidUpdate(prevProps) {
if (prevProps.roomId !== this.props.roomId) {
this.sub.unsubscribe();
this.sub = MessageAPI.subscribe(this.props.roomId, this.handleNewMessage);
}
}
componentWillUnmount() {
this.sub.unsubscribe();
}
handleNewMessage = (msg) => {
this.setState(prev => ({ messages: [...prev.messages, msg] }));
}
// ...
}
函数组件 Hooks 实现
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const handleNewMessage = (msg) => {
setMessages(prev => [...prev, msg]);
};
const sub = MessageAPI.subscribe(roomId, handleNewMessage);
return () => sub.unsubscribe();
}, [roomId]); // roomId 变化时重新订阅,清理旧订阅
// ...
}
Hooks 版本自动将挂载、更新、卸载的清理逻辑整合在一起,避免了重复代码和漏清理的风险。
五、最佳实践总结
- 数据请求:统一在
componentDidMount或useEffect([], [])中发起,注意清理。 - 副作用清理:务必返回清除函数,避免内存泄漏。
- 避免不必要的重新渲染:类组件用
PureComponent或shouldComponentUpdate;函数组件用React.memo+useCallback/useMemo。 - 小心闭包陈旧值:使用
useRef或函数式更新setState(prev => ...)。 - 谨慎使用
getDerivedStateFromProps,优先考虑受控组件或key重置。 - 错误边界用类组件实现,包裹可能出错的子树,同时注意无法捕获异步错误。
useEffect与useLayoutEffect:绝大多数场景用前者;只有当 DOM 操作需要同步读取/修改,避免视觉抖动时才用后者。- 严格遵循 eslint-plugin-react-hooks 规则,它可以帮助你捕获缺失的依赖。
理解生命周期的本质,就是管理组件的资源申请与释放。用对场景、避开陷阱,才能写出健壮的 React 应用。
更多推荐
所有评论(0)