ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

React生命周期与Hooks核心API全解:从原理到实战避坑

React生命周期与Hooks核心API全解:从原理到实战避坑 1. 整体设计思路12到底在指什么拿到React生命周期12这个标题我先说下我自己的解读。React生命周期其实经历了两次大的版本分水岭一次是16.3一次是16.8。前者废弃了三个will系列方法后者引入了Hooks改变了生命周期的心智模型。12恰好接近我们日常真正会用到、面试里会被追问的那一批生命周期API的总数——如果从class组件到函数组件都算上constructor、render、componentDidMount、componentDidUpdate、componentWillUnmount、shouldComponentUpdate、getDerivedStateFromProps、getSnapshotBeforeUpdate、useState、useEffect、useLayoutEffect、useMemo正好是12个需要逐一说清楚的核心入口。所以这篇内容我打算按这个线索展开不照本宣科地把16个生命周期方法全列一遍而是围绕哪个版本该用哪个API、为什么不能乱用、实际项目里哪些地方容易踩坑来写。这篇内容适合三类人看第一类是准备React面试的人因为生命周期几乎是必问环节尤其新版废弃了哪些、为什么废弃是高频考点第二类是刚学完React基础、正要上手做真实项目的初学者需要搞明白componentDidMount里到底该不该发请求、useEffect的依赖数组到底怎么填第三类是已经在写业务代码但经常遇到重复渲染、内存泄漏、请求竞态这些问题的人。文中所有案例都是我实际在业务和面试中反复碰到过的场景跟着过一遍应该能少踩不少坑。1.1 生命周期为什么值得单独拎出来讲很多初学者觉得生命周期就是一串到了某个时间点自动调用的函数背下来名字就行。但实际上生命周期解决的是组件的状态和外界资源如何同步这个核心问题。用大白话说组件从出生到销毁中间要经历创建DOM、挂载到页面、响应数据变化、从页面移除这几个阶段每个阶段都有该做的事情和不该做的事情。比如创建阶段不能拿DOM尺寸因为还没渲染出来更新阶段不能在render里改自己的state否则会死循环卸载阶段要清理定时器和订阅否则内存泄漏。React官方文档在16.3之后专门更新了生命周期图谱把常用和不常用分得很清楚。我建议所有想真正理解React的人都打开官方那张生命周期图对照着看不要只看二手教程。那张图把每个生命周期方法放在对应阶段下面箭头标明了调用时机比任何文字描述都直观。我用CTO vs 普通程序员的视角来理解生命周期普通程序员关心什么时候调用资深工程师关心为什么在这个时机调用、调用了会触发什么连锁反应。比如componentDidMount里setState会触发一次额外render这在大多数场景下没问题但如果组件层级很深、渲染成本很高就得不偿失。再比如getDerivedStateFromProps的返回值会直接覆盖state如果某个state同时被组件内部事件修改又依赖props变化同步非常容易出bug。1.2 面试角度生命周期题到底想考什么围绕React生命周期的高频面试题反复出现的就那么几个方向新旧版本差异、为什么要废弃will系列、getDerivedStateFromProps的使用姿势、父组件更新时子组件的生命周期顺序、Fiber架构对生命周期的影响。这些问题表面考API实际考的是你有没有真正写过大型组件、是否踩过因为生命周期误用导致的线上bug。要区分实力一个很有效的问题是如果有两秒的定时器在组件里跑用户在页面停留十秒后切换路由会发生什么能准确说出组件卸载、定时器继续跑、回调访问已卸载组件导致警告或内存泄漏的人说明对生命周期的理解不是背出来的。所以这篇文章后面专门写了一个实战排查章节把这类问题拆开来讲。2. 旧版生命周期梳理三大阶段的得与失React 16.3之前生命周期方法是按挂载、更新、卸载三阶段组织的。挂载阶段依次走constructor、componentWillMount、render、componentDidMount更新阶段走componentWillReceiveProps、shouldComponentUpdate、componentWillUpdate、render、componentDidUpdate卸载阶段只有componentWillUnmount。这套体系在React 15年代还算稳定很多人写业务就是在这几个方法里堆逻辑。2.1 曾经的主力军will系列到底做了什么事componentWillMount在首次渲染前调用曾经有人在这里提前发请求觉得能省一次渲染。实际上无论在这里还是didMount里发请求render都会先执行所以省渲染是伪命题。而且componentWillMount里调用setState不会触发额外渲染看起来高效但如果组件在服务端渲染场景下这里执行的代码可能在服务端和客户端各跑一遍等于重复请求两次。componentWillReceiveProps在props变化时调用常被用来做props变化后重置state的工作。问题是它只在该组件接收新props时触发不保证props一定变了。很多人在里面做setState每次父组件渲染都会把子组件整个更新流程触发一遍性能问题从源头就开始积累了。componentWillUpdate在render前调用理论上可以用它读取DOM信息比如记录滚动位置。问题在于它执行时已经能确定要重新渲染了此时做任何setState都多余且危险React 17之后这个方法彻底移除。我早年写过一段经典代码在componentWillReceiveProps里比较nextProps和this.props如果数据源变化就重置页码并重新请求。这个逻辑在今天完全可以用getDerivedStateFromProps或直接在父组件用key重置组件来替代而且更可靠。2.2 为什么16.3要大刀阔斧砍掉will系列根本原因是Fiber架构。React 16引入了可中断的渲染机制render阶段不再像以前那样一次性同步执行完而是可以按优先级分片处理。componentWillMount、componentWillReceiveProps、componentWillUpdate这几个方法都在render阶段之前调用它们可能被调用多次也可能被中断后恢复再次调用导致副作用重复执行。举个具体的风险场景一个组件在componentWillReceiveProps里发了请求在Fiber环境下如果这个阶段被中断后重新执行请求就会发了两次。这在老架构下不会发生因为阶段是原子性的。为了解决这个问题React官方把这三个方法标记为不安全并在17.0正式移除。取代它们的是getDerivedStateFromProps和getSnapshotBeforeUpdate前者是纯函数、强制无副作用后者保证在DOM真正更新前同步调用一次。这些设计选择理解透了面试题为什么React废弃了三个will方法就迎刃而解。答题关键是点出Fiber的分片渲染、render阶段可重复执行、副作用不安全这三点再补充这次改动也是为并发渲染铺路就完整了。2.3 旧版生命周期在实际项目中的遗留影响虽然React 17已经移除will系列但很多老项目升级到的过程中还是会遇到兼容问题。比如用了React 16.2及以下版本的项目直接迁移到17会遇到编译警告甚至报错。真实项目中我见过最多的处理方式是加UNSAFE_前缀过渡把componentWillMount改成UNSAFE_componentWillMount先保证能跑后续再逐个重构。这里要提醒一点UNSAFE_前缀不是因为危险所以叫这个名字而是官方明确告诉你这个API将来会删别再用。加了前缀还能跑但不要以为万事大吉。规范做法是把请求逻辑移到componentDidMount把props派生state的逻辑改到getDerivedStateFromProps把DOM读取操作移到getSnapshotBeforeUpdate逐步消除对旧API的依赖。我对老代码的态度是不鼓励一刀切删掉重构因为业务稳定优先但新代码必须用新API。团队协作时可以在Code Review规则里加一条禁止新增componentWill系列方法来卡住增量风险。3. 新版生命周期核心组件逐项拆解12个关键API新版生命周期体系下class组件的常用方法收敛为constructor、static getDerivedStateFromProps、render、componentDidMount、shouldComponentUpdate、getSnapshotBeforeUpdate、componentDidUpdate、componentWillUnmount这8个函数组件对应的是useState、useEffect、useLayoutEffect、useMemo这4个Hooks合起来正好是12个。3.1 static getDerivedStateFromProps设计意图与反模式这个静态方法的调用时机是每次render之前包括初始化和后续更新。它的返回值会被合并进state返回null表示不需要更新。因为是静态方法内部拿不到this所以不能调用实例方法这让它天然无法执行副作用。它最适合的场景是props变化时更新state。比如一个列表筛选组件外部传入的filter变了组件内部的activeFilter需要同步。需要注意如果是因为用户交互改了state而props没变这个方法也会被调用——因为父组件每次渲染都会触发子组件更新。所以必须同时比较prevState和nextProps不能只依赖props传值。反模式也来自这个问题很多开发者把任意props变化都同步state写成无条件覆盖state导致用户已输入的值被props冲掉。正确姿势是先判断这轮props是否真的和上一轮不同再判断我的state是否需要跟随变化。如果props派生出来的值完全可以用在render里计算那就不要存state直接用props。这是React官方文档反复强调的消除派生状态原则。3.2 getSnapshotBeforeUpdate被低估的DOM读操作钩子在React真正提交更新到DOM之前会调用getSnapshotBeforeUpdate。它的返回值会作为componentDidUpdate的第三个参数snapshot传入。这个设计解决了更新前后读取同一份DOM信息的经典需求。典型例子是聊天列表用户正在看到历史消息时来了新消息列表被推到顶部如果直接滚动到底部体验很差。正确做法是在getSnapshotBeforeUpdate里记录当前滚动高度和内容总高度然后在componentDidUpdate里根据这些快照信息恢复或调整滚动位置——新消息来的时候不会打扰用户正在往上翻的历史记录。实现细节上getSnapshotBeforeUpdate里不能调用setState因为它属于提交阶段的前置步骤任何状态修改都会干扰更新流程。如果快照逻辑复杂可以返回对象比如{scrollTop, scrollHeight, offsetHeight}三个值打包。componentDidUpdate里拿到snapshot后可以计算增量来修正scrollTop。这个方法面试容易考和componentWillUpdate有什么区别核心答getSnapshotBeforeUpdate不会因Fiber中断重复执行是提交阶段的同步保障即可。3.3 shouldComponentUpdate与PureComponent的关系shouldComponentUpdate接收nextProps和nextState返回布尔值决定是否继续走更新流程。React.PureComponent是内置了对props和state做浅比较shallow compare的组件基类它能拦截大部分父组件渲染导致子组件也跟着渲染的不必要开销但只做一层比较深层嵌套修改不会触发。使用PureComponent时有个经典坑直接mutate原数组再setState比如list.push(item); setState({list})因为浅比较发现引用没变组件不更新。解决办法永远是创建新数组比如const nextList [...this.state.list, item]。写了三年React之后你会发现不变性不是约定而是规则所有性能优化和渲染正确性的基础都建立在每次修改都产生新引用上。手动写shouldComponentUpdate的场景很少因为大部分需求PureComponent能覆盖。需要手动深比较时说明数据结构设计可能需要优化——一个经常出问题的地方是把每次都在render里创建的新对象/新函数传给子组件这会让任何浅比较优化都失效。后面第5章会给出具体解法。4. 实操环节生命周期在不同业务场景的正确打开方式理解了每个API的语义还要知道组合起来怎么用。这一章按真实业务中最常见的几个场景来拆解。4.1 数据请求到底该放哪个生命周期经典面试题请求数据放在componentDidMount还是componentWillMount。答案是componentDidMount。原因有三点第一componentWillMount在Fiber架构下可能被中断后重执行导致重复请求。第二componentWillMount执行时组件尚未挂载到DOM请求返回后如果组件已经被打断很难安全地setState。第三放在componentDidMount里能保证组件真实可用this.refs、DOM节点都是现成的后续要操作也方便。函数组件场景下对应的写法是useEffect(() { fetchData(); }, [])。注意依赖数组为空表示只在挂载后执行一次但这个行为在React 18严格模式下会变成开发环境执行两次。这会让很多人困惑官方解释是为了帮你暴露副作用代码的隐患。生产环境不会双执行所以不影响最终上线但依赖副作用做幂等处理的代码要在开发模式反复验证健壮性。实际发请求的时候我建议封装一个useFetch或useRequest的Hook把loading、error、data三个状态统一管理配合组件卸载时设置cancelToken或isCanceled标记防止请求返回后组件已卸载导致的setState警告。4.2 组件更新时的联动逻辑setState与componentDidUpdate的配合componentDidUpdate接收prevProps、prevState、snapshot三个参数。适合做什么props变化后重新拉数据、依赖数据变化做DOM操作非滚动截图的场景、根据条件清除旧缓存。一个常见的联动需求是父组件传入的id变了子组件要重新请求详情。class组件写法里需要自己在componentDidUpdate里比较prevProps.id和this.props.id不一样才拉数据。这个逻辑在函数组件里对应useEffect(() { fetch(); }, [id])依赖数组帮我们省去了手动比较的步骤。有个容易忽视的细节componentDidUpdate里setState会触发又一次render和componentDidUpdate所以一定要有终止条件。比如componentDidUpdate(prevProps) { if (prevProps.id ! this.props.id) { this.setState({ loading: true }); fetchData(this.props.id); } }这里setState放入tap条件内就不会无限循环。如果你写的是无条件的setStateReact会直接报Maximum update depth exceeded。4.3 组件卸载时的清理定时器、订阅和事件监听的收尾组件卸载时componentWillUnmount只做一件事清理该组件独有的资源。典型的有setInterval/setTimeout句柄、事件订阅如EventEmitter、WebSocket、全局事件监听window.addEventListener/removeEventListener、第三方库实例如地图实例。这里有个挺隐蔽的坑很多人以为在componentWillUnmount里写了removeEventListener就够了但如果同一个handler函数在前后两次addEventListener时不是同一个引用移除会失败。正确做法是把这个handler抽成一个固定的类字段方法class Demo extends React.Component { handleResize () { // ... }; componentDidMount() { window.addEventListener(resize, this.handleResize); } componentWillUnmount() { window.removeEventListener(resize, this.handleResize); } }用箭头函数类字段保证每次渲染都是同一个引用避免重复监听。函数组件中用useEffect的返回值做清理同样要求传入的清理函数幂等、可重复执行。4.4 函数组件Hooks映射useEffect和useLayoutEffect怎么分工在Hooks时代useEffect默认等价于componentDidMount加componentDidUpdate加componentWillUnmount的组合只是每次渲染后都执行。这也是很多面试官喜欢问useEffect为什么每次渲染都会跑的原因——它所谓依赖数组是用来跳过不必要执行的不是用来只跑一次的。如果需要在浏览器绘制之前同步执行副作用比如操作DOM、测量布局用useLayoutEffect。它更接近getSnapshotBeforeUpdate加componentDidUpdate的同步语义。大多数业务代码用useEffect就够了滥用useLayoutEffect会导致渲染阻塞影响页面交互流畅度。useEffect里执行setState要特别小心依赖项。比如useEffect(() { setCount((c) c 1); }, [count]);这段代码会无限循环因为每次setCount后count变了依赖就变了effect又执行又set。许多博客会调侃这是React新手三大噩梦之一解法是去掉依赖、用函数式更新或者把状态提升到不需要依赖它自己的地方。真实项目里遇到useEffect死循环九成都是这类依赖设计问题。5. 实战避坑React生命周期六个高频隐患与排查技巧这些坑都是我在项目和面试题里反复见到的整理成一张表格方便收藏对照。隐患/问题典型场景根因分析推荐解法卸载后setState警告组件卸载后请求才返回异步回调不受生命周期管控卸载标记位、AbortController取消请求重复请求第一次进入页面发了两次请求父组件渲染触发子组件componentDidUpdate无脑拉数据比较prevProps值后再请求内存泄漏全局事件/定时器未清除组件卸载后资源仍被引用componentWillUnmount清定时器、移除监听无限渲染循环render里直接setState或componentDidUpdate无setState条件更新链路没有终止条件给setState加if判断组件不更新直接修改原数组/对象后setState引用不变浅比较失效用展开运算符生成新引用白屏React Native低端机首屏启动白屏渲染前耗时任务阻塞了首屏渲染优化启动阶段、减少同步请求5.1 经典场景卸载标记怎么加才优雅React 18之前在卸载的组件里setState常见的处理是在componentDidMount里定义一个this._isMounted truecomponentWillUnmount里改为false请求回调里判断后再setState。React官方后来移除了isMounted API因为它不能在卸载前后可靠判断。现在更推荐AbortController。useEffect(() { const controller new AbortController(); fetch(url, { signal: controller.signal }) .then(...) .catch((err) { if (err.name AbortError) return; }); return () controller.abort(); }, [url]);这段代码的清理时机恰好是组件卸载时控制器终止请求回调不会再被执行也不会出现setState警告。这是函数组件风格下比较优雅的标准做法class组件里也可以在componentWillUnmount里调controller.abort()。5.2 React Native场景启动白屏与生命周期有关吗标题的热搜词里有React Native启动白屏这和生命周期确实有关系。RN应用首屏白屏的常见原因是首屏组件在componentDidMount里同步执行了大量耗时任务如发请求、做本地数据库读取、初始化地图等阻塞了JS线程导致原生端渲染完成但它显示不出来。合理做法是把这些任务延后到InteractionManager.runAfterInteractions或requestAnimationFrame里执行让首帧渲染先完成。在class组件里用InteractionManager处理在函数组件里可以用useEffect加一个延迟标记或者配合useInteractionManager这类封装Hooks。5.3 多层父子组件生命周期执行顺序一图理解挂载阶段执行顺序是父constructor → 父getDerivedStateFromProps → 父render → 子constructor → 子getDerivedStateFromProps → 子render → 子componentDidMount → 父componentDidMount。更新阶段如果父组件更新顺序是父getDerivedStateFromProps → 父shouldComponentUpdate → 父render → 子getDerivedStateFromProps → 子shouldComponentUpdate → 子getSnapshotBeforeUpdate → 子componentDidUpdate → 父getSnapshotBeforeUpdate → 父componentDidUpdate。这个顺序很多人容易记反面试官也爱从子组件的didMount和父组件的didMount谁先执行这种问题入手。原则是render从父到子didMount/commit阶段从子到父因为React要保证子组件先完成DOM操作父组件才能拿到完整子树的信息。6. 使用心得与后续扩展方向写生命周期相关的代码这几年最大的体会就是React对副作用的管控越来越严格。class组件时代的方法命名和调用顺序容易让人误以为可以在任何时机做任何事实际上每个方法被放在那个位置都有性能和安全上的考量。Fiber的引入把渲染从一次性任务变成了可分片执行的任务这也解释了为什么新版API要强调纯函数和无副作用。给正在学习的朋友一个建议可以通过实际场景去记忆生命周期方法而不是背名字。比如想如果我要监听鼠标移动应该在哪里监听、在哪里解除自然就会想到componentDidMount和componentWillUnmount想如果我要记住用户滚动位置应该在哪里读取快照就会想到getSnapshotBeforeUpdate配合componentDidUpdate。方法随着场景进入记忆比任何口诀都可靠。后面如果继续深挖可以考虑三个方向第一是React 18并发特性对生命周期方法的影响特别是Transition和Suspense下组件的挂载、更新时机变化第二是怎么用sentry等监控工具收集生产环境卸载后setState之类的警告量化清理的收益第三是把class组件老旧项目渐进式迁移到Hooks的思路过程中如何通过生命周期方法对齐保证行为一致。这几个方向都值得单独写一篇长文大家可以期待一下。
返回列表