React生命周期的演变(v15 - v18)

710 阅读9分钟

前言

React已经经历了很多次迭代升级,从我最初接触到的React15,到现在已经React18了,渲染模型也已经从线性模型转向并发模型。期间生命周期在16版本也做了一次大的更新,并且函数式组件的出现也让我对React生命周期有了新的认识。至此,我想通过这篇文章总结一下v15到v18生命周期的变化、原因及使用。

类组件

React15的生命周期

React15的生命周期的主要函数有这些:

constructor()  //组件构建
componentWillReceiveProps() //将要接收父组件参数
shouldComponentUpdate() //组件是否更新
componentWillMount() //组件将要渲染
componentWillUpdate() //组件将要更新
componentDidUpdate() //组件更新完成
componentDidMount() //组件渲染完成
render() //渲染
componentWillUnmount() //组件将要卸载

生命周期的流程如下图所示:

image.png

组件挂载:constructor、componentWillMount、componentDidMount都只会在组件初始挂载期间加载一次。在constructor中可以对this.state进行声明、定义、初始化。因为componentWillMount在render前执行,所以有很多小伙伴喜欢在这个函数里进行一些初始化操作,但是这样会带来很多不必要的后果,例如会多次渲染等。componentDidMount在render之后,在这个函数中,我们就可以做一些对真实dom的相关操作。

组件更新:组件更新分为两种,一种是由父组件触发的更新,另一种是组件自身触发的更新。componentWillReceiveProps函数是在父组件传递props参数变化后或父组件自身状态变化后触发,也就是说也会存在props不变化也导致了子组件调用这个函数,因此React就提供了shouldComponentUpdate,用来指定组件是否需要更新,返回true则代表更新,false则不更新,并且shouldComponentUpdate默认返回的是true

组件卸载:组件的卸载会有两种情况,都会触发componentWillUnmount:

(1)子组件从父组件中移除

(2)组件中设置了key,渲染过程中发现key值与上一次不一致

React16的生命周期

React16生命周期的主要函数有这些:

construtor() //组件构建
getDerivedStateFromProps() //将props派生为state
shouldComponentUpdate() //组件是否更新
render() //组件渲染
getSnapshotBeforeUpdate() //可返回一个参数供componentDidUpdate使用,可以操作真是dom
componentDidMount() //组件渲染完成
componentDidUpdate() //组件更新完成
componentWillUnmount() //组件将要卸载

我们发现在React16废弃了3个函数:componentWillReceivePropscomponentWillMountcomponentWillUpdate,新增了2个函数:getDerivedStateFromPropsgetSnapshotBeforeUpdate。下面我们就谈谈React为什么做了这些变化。这里提供了React16.3之前和之后的两张React生命周期运行的流程图,方便小伙伴们的理解。

image.png

React16.3之前

image.png

React16.3之后

getDerivedStateFromProps

static getDerivedStateFromProps(props,state)有几个特点:

(1)它是一个静态函数,因此不依赖于组件实例的存在,在该方法内部我们是访问不到this的。

(2)它接受两个参数,一个是父组件传递的参数props,一个是组件自身的state。

(3)该函数需要返回一个Object对象。如果没有返回,则会在运行时被warning。因为React需要这个返回值对state进行更新。如无需更新,return null即可。

我们从上面两张流程图中可以看出,16.3版本之前只有父组件更新才能触发,,16.3版本之后父组件更新、自身组件更新、强制更新都可以触发getDerivedStateFromProps函数。但是我们发现不管是哪个版本,在这个函数中,React只负责props到state的派生工作,这样就避免了很多人滥用在React15版本中的componentWillReceivePropscomponentWillMountcomponentWillUpdate3个函数,维护了React项目一定的稳定性。

image.png

React16.3之前只有父组件更新触发函数

image.png

React16.3之后父组件、自身组件、强制更新都可以触发函数

getSnapshotBeforeUpdate

getSnapshotBeforeUpdate(preProps,preState)我觉得属于这一版本新增的一个生命周期函数,它可以返回一个参数交给componentDidUpdate,并且我们看下上面的流程图,不难发现它是处在rendercomponentDidUpdate之间的,也就是说,我们在getSnapshotBeforeUpdate阶段既可以获取更新前的真实dom也可以获取到更新前后的props和state信息,这就可以帮助我们完成一些特定的功能需求,例如根据滚动列表内容的变化记录滚动条的位置。

componentDidUpdate

componentDidUpdate(preProps,preState,valueFromSnapshot)接收3个参数,更新前的props、state和由getSnapshotBeforeUpdate返回的过来的参数。我们在componentDidUpdatecomponentDidMount函数中都可以进行网络请求,但是componentDidUpdate使用setState需要谨慎,最好配合条件判断语句,因为如果没有条件判断语句的话,可能会出现多次渲染,甚至是死循环。

深度探究生命周期变化的原因

我们知道,React在16版本引入了Fiber核心算法,它将同步渲染的过程改成了异步渲染,生命周期为了适配Fiber架构,所以也做出了调整。

以前的更新渲染过程是将新的虚拟dom树与旧的逐层对比,以达到定向更新,这一过程其实是一个递归的过程,时间长且不可打断,下面图就可以看出。

image.png

React15更新渲染过程

现在的渲染任务被Fiber算法分成了多个小任务,并且可根据任务的优先级去完成渲染操作,不同于同步渲染的是———在渲染期间任务还可以被打断。速度变快了,在渲染期间也多了可操作性,工作流程如下图。

image.png

React16根据任务可以打断的特性又分成了3个阶段:renderpre-commitcommit,我们可以看看下面的生命周期流程图。

image.png

render阶段

render阶段是可以被多次打断重复执行的,并且打断后是重新渲染而不是接着上一次的渲染内容往下执行,React15被废弃的3个函数:componentWillReceivePropscomponentWillMountcomponentWillUpdate以前都是在这个阶段,有很多人喜欢在这几个函数中执行setState异步请求操作真实dom等,若是在这几个函数执行期间render多次打断、多次重启,可想而知会带来多大的风险,举个例子,如果在componentWillMount执行付款的异步请求,由于一些原因被打断、重启,那么可能会出现多次付款的骚操作!

pre-commit阶段

可以读取到真实dom,getSnapshotBeforeUpate就处于这个阶段。

commit阶段

这个阶段可以进行一些副作用操作,安排更新。这里需要提一点的是:render阶段被设计成异步,是因为在render阶段,用户是不可见的,所以渲染任务可以被多次打断或重启,但是在commit阶段用户是可感知的,所以依然是同步渲染。

这下我们发现React生命周期的变化都是对Fiber算法适配的合理操作,限制了编程人员的一些不可控的行为,确保了React项目更加快速、稳定、可预测,是一次重大升级!

函数组件

Hooks 是React16.8 版本中引入的内置React 函数。 它们允许你在函数组件中使用React 库的功能,如生命周期方法、状态(state)和上下文(context),而不必担心将其重写为一个类。与生命周期相关的主要钩子函数有:

useEffect(callback,deps)
useLayoutEffect(callback,deps)

useEffect

useEffect钩子函数可以替代componentDidMountcomponentDidUpdatecomponentWillUnmount这个3个函数。这里总结一下它的几种使用场景:

1、deps不传,挂载和每次更新都会触发副作用

useEffect(()=>{
    //回调逻辑
})

2、deps传一个空数组,只在初次挂载阶段触发副作用

useEffect(()=>{
    //回调逻辑
},[])

3、deps数组中添加需要监听的参数,指定参数变化触发副作用回调(类似Vue的watch)

useEffect(()=>{
    //回调逻辑
},[a])

4、deps不传,return一个清除函数,组件卸载或每次更新都会触发清除函数

useEffect(()=>{
    //回调逻辑
    
    //返回清除函数
    return ()=>{
        console.log('执行卸载')
    }
})

5、deps传一个空数组,return一个清除函数,只有在组件卸载时触发清除函数

useEffect(()=>{
    //回调逻辑
    
    //返回清除函数
    return ()=>{
        console.log('执行卸载')
    }
},[])

useLayoutEffect

useLayoutEffect和useEffect使用方式基本上都是一致的,最主要的区别就是useLayoutEffect是同步执行,而useEffect是异步执行,当触发更新时,需要执行完useLayoutEffect中的方法后才完成渲染,因此这个hook会阻塞dom的渲染,需要谨慎使用。

这里还有一点需要提醒的是:在React16中useLayoutEffect和useEffect的清理函数(卸载执行的返回函数)执行都是同步的,但是在React17中useEffect的清理函数已经修改为了异步,因此在17版本的useEffect函数中执行清理函数,如果要访问会随着组件卸载而变化的值或实例时一定要存储在副作用内部。例如一些组件的ref实例,卸载后访问current会为null,因为异步再快也快不过同步,由于同步先执行,dom实例已被卸载,当执行异步清除函数时,自然就访问不到了。这里我以一小段代码为例:

useEffect(()=>{
    //在回调逻辑中存储,给到清除函数访问
    const instance=xxxRef.current
    //返回清除函数
    return ()=>{
        console.log('执行卸载')
        console.log('实例',xxxRef.current) //实例null
        console.log('实例',instance) //实例 object
        //执行实例的方法
        instance.fun()
    }
})

总结

通过对React生命周期演变过程及原因的探讨,我想小伙伴们对不同版本的React生命周期应该有了更加深刻的认识,有助于我们在后期开发中更加得心应手地解决数据变化带来的页面挂载或更新的问题,提高React项目的稳定性和性能。如果有什么疑惑,可以评论区提出,大家一起探讨。