多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

React生命周期详解:从Class方法到Hooks心智模型

React生命周期详解:从Class方法到Hooks心智模型 1. 项目概述为什么今天还要聊React生命周期如果你在2024年还在搜索“React生命周期详解”我猜你大概率是遇到了以下几种情况之一正在维护一个历史悠久的Class组件项目面试官冷不丁地问起了componentWillReceiveProps或者你只是想彻底搞懂React组件从“出生”到“死亡”的完整故事线以便更好地理解现代Hooks的设计哲学。没错尽管React Hooks已经大行其道但生命周期这个概念依然是理解React运行机制的一块基石。它就像一本老相册记录了组件在特定时刻挂载、更新、卸载的“标准动作”。今天我们不只回顾这本相册里的经典照片更要弄明白为什么有些照片被移除了以及如何用新的方式Hooks去拍出同样精彩、甚至更可控的瞬间。理解生命周期绝不是为了让你回去写Class组件。恰恰相反它能让你在遇到遗留代码时不再发怵能让你在面试中清晰地阐述框架的演进思路更能让你深刻体会到useEffect这个“瑞士军刀”背后的设计意图——它本质上就是生命周期逻辑的一种更声明式、更组合式的表达。所以无论你是React新手想打牢基础还是老手想系统梳理这次“详解”都会带你穿越时光从Class的“生命周期方法”走到Hooks的“副作用与依赖”把这条脉络彻底理清。2. 核心脉络从Class方法到Hooks心智模型在深入每个具体方法之前我们必须先建立两个核心认知生命周期的三个阶段以及Class API与Hooks API在思维模式上的根本不同。这能帮你避免“死记硬背”真正理解其设计逻辑。2.1 生命周期的三大阶段与两大API体系一个React组件的生命旅程无论以何种形式编写都必然经历三个核心阶段挂载Mounting组件实例被创建并插入DOM树。这是“诞生”时刻。更新Updating组件的props或state发生变化导致需要重新渲染。这是“成长与变化”时刻。卸载Unmounting组件实例从DOM树中移除。这是“消亡”时刻。围绕这三个阶段React提供了两套API来让你介入这些关键时刻Class组件生命周期方法这是一套基于类的、面向对象风格的API。你通过定义诸如componentDidMount、shouldComponentUpdate这样的特殊方法来告诉React“在挂载完成后执行这段代码”或“在更新前先问我是否同意”。函数组件 Hooks主要是useEffect这是一套基于函数的、声明式与响应式的API。你通过useEffect这个Hook描述副作用数据获取、订阅、手动操作DOM与组件渲染结果props, state之间的依赖关系。React会根据依赖变化在恰当的时机渲染提交到屏幕后安排和执行这些副作用。关键理解不要试图在Hooks中寻找与Class方法一对一的等价物。useEffect是一个更强大的抽象它融合并重新组织了多个生命周期方法的能力。你的思维应从“在某个时刻执行某段代码”转变为“当某些值变化时执行某段副作用”。2.2 废弃与演进为什么有些生命周期方法消失了如果你看过旧版文档可能会对componentWillMount,componentWillReceiveProps,componentWillUpdate感到熟悉又陌生。它们在React 16.3后被标记为UNSAFE_前缀并在后续版本中逐渐被淘汰。核心原因在于它们被用于了其设计初衷之外的场景并且与React新的异步渲染架构如Concurrent Mode不兼容。这些Will系列方法共同的问题是它们都在“渲染阶段”被调用。在异步渲染中React可能会为了更高的响应性暂停、中断或重启渲染工作。如果一个副作用比如数据获取、订阅放在componentWillMount中它可能会被调用多次或者在不恰当的时机被调用导致内存泄漏、数据竞争或界面不一致。React的解决方案是将副作用严格限制在“提交阶段”之后。这就是为什么componentDidMount、componentDidUpdate和useEffect的回调函数都是在浏览器完成DOM更新后才执行。为了更安全地替代Will系列方法的功能React引入了两个新的生命周期方法getDerivedStateFromProps和getSnapshotBeforeUpdate它们都是纯函数没有副作用因此对异步渲染是安全的。3. Class组件生命周期方法全解析让我们沿着一个Class组件实例的真实生命轨迹逐一拆解每个方法。我会用一段模拟的“用户仪表板”组件代码作为例子贯穿始终。class UserDashboard extends React.Component { constructor(props) { super(props); this.state { userId: props.initialUserId, userData: null, onlineStatus: checking, updateCount: 0 }; console.log(1. constructor: 初始化state和绑定方法); // 传统上在这里绑定事件处理函数 this.handleClick this.handleClick.bind(this); } // 3.1 挂载阶段 (Mounting) static getDerivedStateFromProps(nextProps, prevState) { // 这是一个静态方法没有this只能根据props计算并返回一个对象来更新state console.log(2. getDerivedStateFromProps: 在每次渲染前调用包括初始挂载和后续更新); // 典型场景当props变化时以一种声明式的方式同步state。 // 例如如果传入的initialUserId变化了重置用户数据 if (nextProps.initialUserId ! prevState.userId) { return { userId: nextProps.initialUserId, userData: null // 重置数据触发重新获取 }; } return null; // 返回null表示不更新state } componentDidMount() { console.log(4. componentDidMount: 组件已挂载到DOM树); // 这里是执行副作用的黄金位置数据获取、订阅、操作DOM this.fetchUserData(this.state.userId); this.setupStatusSubscription(this.state.userId); // 操作DOM示例 const titleEl document.getElementById(user-title-${this.state.userId}); if (titleEl) { titleEl.style.color #1890ff; } } // 3.2 更新阶段 (Updating) shouldComponentUpdate(nextProps, nextState) { console.log(5. shouldComponentUpdate: 决定组件是否需要重新渲染); // 性能优化关键点通过对比this.props/state和nextProps/nextState避免不必要的渲染。 // 浅比较如果userId和userData都没变就不渲染 const isUserDataSame shallowCompare(this.state.userData, nextState.userData); // 假设有个浅比较工具函数 if (this.state.userId nextState.userId isUserDataSame) { console.log( - 用户数据和ID未变阻止渲染); return false; } console.log( - 状态已变允许渲染); return true; // 注意在现代React中更推荐使用PureComponent或React.memo进行自动浅比较优化。 } getSnapshotBeforeUpdate(prevProps, prevState) { console.log(6. getSnapshotBeforeUpdate: 在DOM更新前捕获一些信息); // 这个方法的返回值将作为第三个参数传递给componentDidUpdate // 典型场景在UI更新前保存当前的滚动位置。 const listContainer this.listRef.current; if (listContainer) { return { scrollHeight: listContainer.scrollHeight, scrollTop: listContainer.scrollTop }; } return null; } componentDidUpdate(prevProps, prevState, snapshot) { console.log(7. componentDidUpdate: DOM更新已完成); // 核心作用在组件更新后操作DOM或执行依赖于新DOM的副作用。 // 场景1基于props变化获取新数据替代旧的componentWillReceiveProps if (this.props.initialUserId ! prevProps.initialUserId) { this.fetchUserData(this.props.initialUserId); } // 场景2使用getSnapshotBeforeUpdate返回的快照恢复滚动位置 if (snapshot this.listRef.current) { const listContainer this.listRef.current; if (listContainer.scrollHeight ! snapshot.scrollHeight) { listContainer.scrollTop snapshot.scrollTop (listContainer.scrollHeight - snapshot.scrollHeight); } } // 注意一定要有条件判断否则容易导致无限循环 // if (this.state.updateCount ! prevState.updateCount) { ... } } // 3.3 卸载阶段 (Unmounting) componentWillUnmount() { console.log(8. componentWillUnmount: 组件即将被销毁); // 清理工作取消网络请求、清除定时器、取消订阅、清理手动DOM监听器等 this.cancelPendingRequests(); this.statusSubscription.unsubscribe(); if (this.resizeObserver) { this.resizeObserver.disconnect(); } } // 渲染方法必须的 render() { console.log(3. render: 计算虚拟DOM准备更新); // render应该是纯函数只根据this.props和this.state返回JSX。 // 不要在这里调用setState、进行数据获取等副作用操作 const { userData, onlineStatus } this.state; return ( div ref{this.listRef} h1 id{user-title-${this.state.userId}}用户仪表板/h1 {userData ? ( UserProfile data{userData} status{onlineStatus} / ) : ( LoadingSpinner / )} /div ); } // 以下是示例的辅助方法 fetchUserData (userId) { // 模拟数据获取 api.getUser(userId).then(data this.setState({ userData: data })); } setupStatusSubscription (userId) { this.statusSubscription statusService.subscribe(userId, status { this.setState({ onlineStatus: status }); }); } cancelPendingRequests () { // 取消所有进行中的fetch请求 } }执行顺序与流程图解对于一个挂载和更新过程控制台打印的顺序清晰地展示了生命周期的调用链首次挂载constructor-getDerivedStateFromProps-render-componentDidMount状态/属性更新getDerivedStateFromProps-shouldComponentUpdate-render-getSnapshotBeforeUpdate-componentDidUpdate实操心得render方法必须保持纯净。我曾在一个复杂表格组件的render里不小心调用了一个会修改外部缓存的方法导致在开发严格模式下React会故意双调render数据被错误地修改了两次引发了极其诡异的bug。记住render只负责描述UI任何“动作”都应放到生命周期方法或事件处理函数中。4. 函数组件与Hooks生命周期的现代化表达现在让我们用函数组件和Hooks重写上面那个UserDashboard。你会发现我们不再思考“挂载时做什么”、“更新时做什么”而是思考“哪些副作用依赖于哪些状态”。4.1 useEffect副作用管理的核心useEffectHook 是连接函数组件与生命周期的桥梁。它的核心思想是声明一个副作用并指定其依赖项数组。React会在每次渲染后包括首次检查依赖项如果依赖项发生变化就执行这个副作用。如果依赖项数组为空[]则副作用只会在组件挂载后执行一次类似于componentDidMount。如果依赖项数组中有值则副作用会在该值变化后执行融合了componentDidMount和componentDidUpdate的部分职责。返回的清理函数则对应componentWillUnmount。import React, { useState, useEffect, useRef } from react; function UserDashboard({ initialUserId }) { const [userId, setUserId] useState(initialUserId); const [userData, setUserData] useState(null); const [onlineStatus, setOnlineStatus] useState(checking); const listRef useRef(null); // 使用ref来存储可能需要在清理时访问的订阅对象或AbortController const statusSubscriptionRef useRef(null); const abortControllerRef useRef(null); // 对应 getDerivedStateFromProps用useState useEffect 或 useMemo 来响应prop变化 useEffect(() { if (initialUserId ! userId) { setUserId(initialUserId); setUserData(null); // 重置数据 } }, [initialUserId]); // 依赖initialUserId当其变化时执行 // 对应 componentDidMount componentDidUpdate (基于userId的更新) useEffect(() { console.log(副作用获取用户数据依赖项[userId]); // 创建一个AbortController用于取消请求 const abortController new AbortController(); abortControllerRef.current abortController; const fetchData async () { try { const data await api.getUser(userId, { signal: abortController.signal }); setUserData(data); } catch (error) { if (error.name ! AbortError) { // 处理非取消错误 console.error(获取用户数据失败:, error); } } }; fetchData(); // 清理函数对应 componentWillUnmount 或 下一次effect执行前 return () { console.log(清理取消用户数据请求); abortControllerRef.current?.abort(); }; }, [userId]); // 关键依赖项数组。当userId变化时会先执行上一个清理函数再执行新的effect。 // 另一个独立的副作用管理在线状态订阅 useEffect(() { console.log(副作用建立状态订阅依赖项[userId]); const subscription statusService.subscribe(userId, (status) { setOnlineStatus(status); }); statusSubscriptionRef.current subscription; return () { console.log(清理取消状态订阅); statusSubscriptionRef.current?.unsubscribe(); statusSubscriptionRef.current null; }; }, [userId]); // 对应 componentDidMount (只执行一次) 的操作比如记录分析 useEffect(() { console.log(副作用仅挂载时执行的分析日志依赖项[]); analytics.track(DashboardMounted); // 操作DOM的例子谨慎使用通常应避免 const timer setTimeout(() { if (listRef.current) { listRef.current.style.backgroundColor #f5f5f5; } }, 1000); return () { clearTimeout(timer); analytics.track(DashboardUnmounted); }; }, []); // 空依赖数组意味着这个effect只运行一次挂载时和清理一次卸载时 // 模拟 getSnapshotBeforeUpdate componentDidUpdate 的滚动位置保持 // 在函数组件中我们通常使用useLayoutEffect或在渲染时通过ref保存信息 const prevScrollHeightRef useRef(); useEffect(() { if (listRef.current) { const currentScrollHeight listRef.current.scrollHeight; // 如果滚动高度变化了说明有新内容尝试保持滚动位置 if (prevScrollHeightRef.current ! undefined listRef.current.scrollTop ! 0) { listRef.current.scrollTop listRef.current.scrollHeight - prevScrollHeightRef.current; } prevScrollHeightRef.current currentScrollHeight; } }, [userData]); // 当userData变化导致列表内容变化后执行 // 对应 shouldComponentUpdate 的优化使用 React.memo, useMemo, useCallback const memoizedProfile React.useMemo(() { return UserProfile data{userData} status{onlineStatus} /; }, [userData, onlineStatus]); // 仅当userData或onlineStatus变化时重新计算 return ( div ref{listRef} h1用户仪表板 (Hooks)/h1 {userData ? memoizedProfile : LoadingSpinner /} /div ); } // 使用React.memo包裹组件实现类似PureComponent的浅比较props优化 export default React.memo(UserDashboard);4.2 其他关键Hooks与生命周期的映射useState/useReducer管理状态是触发更新的根源。setState调用会导致重新渲染进入更新周期。useMemo/useCallback用于性能优化对应shouldComponentUpdate的部分职责。它们通过记忆化缓存计算结果或函数避免在每次渲染时进行不必要的昂贵计算或子组件重渲染。useMemo(() computeExpensiveValue(a, b), [a, b])只有当a或b变化时才重新计算。useCallback(fn, [deps])返回一个记忆化的回调函数只有当依赖项变化时才会更新。这对于防止子组件因回调函数引用变化而无效重渲染至关重要。useLayoutEffect与useEffect签名相同但执行时机更早。它会在所有DOM变更之后、浏览器绘制之前同步执行。当你需要直接读取或操作DOM布局如测量元素尺寸、设置滚动位置并随后同步触发重渲染时使用它。这更接近Class组件中getSnapshotBeforeUpdate和componentDidUpdate的组合效果。对于大多数数据获取和订阅请坚持使用useEffect以避免阻塞视觉更新。useRef不仅仅是访问DOM。它常用于存储可变值如定时器ID、订阅对象、上一次的props/state这些值在组件的整个生命周期内持续存在且变更不会触发重新渲染。这在模拟一些生命周期逻辑时非常有用如上面例子中存储prevScrollHeight。注意事项useEffect的依赖项数组是精确的。React使用Object.is进行比较。如果你漏掉了依赖effect可能会使用过期的闭包值stale closure。如果你设置了依赖但导致effect无限循环通常意味着你的effect在修改其依赖项例如在effect里setState了一个依赖的状态。这时需要重新思考你的数据流或者使用useReducer、useRef来打破循环。5. 常见问题、反模式与性能优化实战理解了基本概念后我们来看看实际开发中高频出现的问题和如何正确优化。5.1 Class组件经典陷阱在constructor或render中调用setStateconstructor中可以直接初始化state但调用setState会触发一次不必要的更新虽然初始渲染前会被合并。render中调用setState是绝对禁止的会导致无限递归渲染浏览器卡死。状态更新应由事件、生命周期方法或异步回调触发。滥用componentWillReceiveProps已废弃进行派生状态// 反模式 UNSAFE_componentWillReceiveProps(nextProps) { if (nextProps.userId ! this.props.userId) { this.setState({ userData: null }); this.fetchUserData(nextProps.userId); // 副作用放在这里不安全 } }正确做法使用getDerivedStateFromProps进行纯state派生副作用移到componentDidUpdate中并配合条件判断。shouldComponentUpdate优化过度或不足过度进行了非常深层次的比较计算成本可能高于一次虚拟DOM Diff。不足对于复杂对象或数组只进行浅比较内部变化无法被感知导致UI不更新。建议对于简单组件直接继承React.PureComponent自动浅比较props和state。对于复杂情况确保shouldComponentUpdate逻辑正确或考虑使用不可变数据如Immer库来简化比较。5.2 Hooks特有难题与解法无限循环最常见的useEffect陷阱。const [count, setCount] useState(0); useEffect(() { // 错误effect依赖count又在内部更新count setCount(count 1); // 触发更新 - effect重新执行 - 无限循环 }, [count]); // 依赖了count解法检查effect是否在修改其依赖项。如果更新依赖于前一个状态使用函数式更新setCount(c c 1)。如果effect的逻辑不需要在某个依赖变化时运行考虑是否应该将其移出依赖数组或者用useRef存储一个不需要触发effect的值。过时闭包Stale ClosureuseEffect(() { const intervalId setInterval(() { console.log(count); // 总是打印初始值0因为effect只在挂载时运行一次捕获了当时的count }, 1000); return () clearInterval(intervalId); }, []); // 空依赖count变化时effect不会更新解法将count添加到依赖数组[count]。如果不想每次count变化都重置定时器可以使用useRef来保存一个可变的、最新的值或者使用setState的函数式更新来获取最新状态。useEffect清理函数执行时机误解清理函数不仅在组件卸载时运行在每次effect重新执行前依赖变化后也会运行。这有助于清理上一次effect的“残留”如旧的订阅、请求。确保你的清理逻辑是幂等的多次执行无害。5.3 性能优化速查表场景Class组件方案函数组件方案说明避免不必要的子组件渲染PureComponent或 手写shouldComponentUpdateReact.memo()包裹子组件对props进行浅比较。对于函数组件React.memo是首选。避免回调函数导致子组件重渲染在constructor中绑定方法或使用类属性箭头函数useCallback包裹回调函数useCallback缓存函数引用除非依赖项变化。避免昂贵计算重复执行在render方法外计算或使用getDerivedStateFromPropsuseMemo缓存计算结果仅当依赖项变化时重新计算。在DOM更新后同步读取布局getSnapshotBeforeUpdatecomponentDidUpdateuseLayoutEffect用于需要同步测量DOM或设置滚动位置等场景。数据获取与订阅componentDidMountcomponentDidUpdatecomponentWillUnmountuseEffect 清理函数Hooks中通过依赖数组控制执行时机逻辑更集中。响应Props变化更新StategetDerivedStateFromPropsuseStateuseEffect或useMemo在Hooks中通常用useEffect来响应props变化执行副作用或用useMemo派生值。直接由props“驱动”的state应慎重考虑是否必要。一个高级优化技巧使用useReducer管理复杂状态当状态逻辑复杂下一个状态依赖于前一个状态或者有多个子值需要一起更新时useReducer比多个useState更合适。它可以将状态更新逻辑集中管理并且dispatch函数是稳定的引用便于传递给子组件而无需useCallback。function dashboardReducer(state, action) { switch (action.type) { case FETCH_USER_SUCCESS: return { ...state, userData: action.payload, isLoading: false }; case FETCH_USER_FAILURE: return { ...state, error: action.payload, isLoading: false }; case SET_USER_ID: // 可以在这里集中处理重置逻辑 return { userId: action.payload, userData: null, isLoading: true, error: null }; default: return state; } } function UserDashboard({ initialUserId }) { const [state, dispatch] useReducer(dashboardReducer, { userId: initialUserId, userData: null, isLoading: false, error: null }); useEffect(() { dispatch({ type: SET_USER_ID, payload: initialUserId }); }, [initialUserId]); // ... 其他effect调用dispatch来更新状态 }6. 从生命周期到设计模式构建可维护的React组件最后我想分享一些超越具体API的思考。无论是Class还是Hooks其最终目的都是构建可预测、可维护的UI组件。理解生命周期有助于我们遵循一些关键的设计原则单一职责与副作用分离一个生命周期方法或useEffect最好只做一件事。将数据获取、订阅、DOM操作等不同副作用拆分到不同的useEffect中依赖数组也会更清晰。状态提升与单向数据流避免使用getDerivedStateFromProps或复杂的useEffect来同步父子组件的状态。如果两个组件需要共享状态考虑将状态提升到最近的共同祖先。这能减少“派生状态”的bug。受控与非受控组件对于表单输入等元素理解生命周期有助于你决定是使用受控组件值由React state驱动通过onChange更新还是非受控组件值由DOM自身管理通过ref在需要时获取。受控组件更符合React的数据流哲学。错误边界Error Boundaries这是Class组件独有的生命周期特性static getDerivedStateFromError和componentDidCatch用于捕获子组件树中的JavaScript错误并展示降级UI。在函数组件中目前仍需通过Class组件来实现错误边界。这提醒我们在关键路径上合理的错误处理是生命周期管理的重要组成部分。回过头看React生命周期的演进是从“命令式”的、基于时间点的回调走向“声明式”的、基于数据依赖的描述。Hooks不是对生命周期的简单封装而是一次心智模型的升级。当你下次写useEffect时不妨先问自己“这个副作用依赖于哪些状态或属性它需要在什么时机被清理” 当你这样思考时你已经掌握了现代React状态管理的精髓。至于那些Class生命周期的具体名字记住它们的故事和教训远比记住它们的调用顺序更重要。
返回列表