在理解生命周期函数定义的基础上,真正考验的是不同场景下如何选用,以及避开那些容易踩的坑。下面从类组件和 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 应用。