区区十几个勾子,这次必须要全部拿捏(上篇)

2 阅读15分钟

随着业务需求越来越复杂,代码的复杂度也随之上升,在近期接手的一个公司项目中,发现大部分代码我连仅仅是看懂都很吃力,主要原因是因为代码中用到了好几个我没接触过的hook,要知道对于同一个组件来讲,不同的需求要使用的hook都是不一样的,如果连组件为什么要使用这个hook都不知道,那么就更不用说去开发什么功能了,顶多写几个bug,所以我也下定决心,这次要把常用的一些勾子都给学会

useState

这个hook应该是用的最多的,基本每个组件里面都能看到useState的影子,常见的用法就如下所示

image.png

每一次点击按钮都会重新给amount设置新的值,接着组件就会带着这个新的值重新渲染,渲染完成后按钮上显示的也是最新的值

002.gif

上面我们是通过按钮点击改变一次amount的更新,并且触发了渲染,如果我们在按钮点击后多设置几次amount的值,那么按钮上的值会变成多次设置后的最新值吗,我们改下代码试试

image.png

代码中,我们希望点击后按钮上的值可以增加3,实际效果如下所示

004.gif

很遗憾的发现按钮上的值仍旧是每次增加1,并没有达到我们预期的效果,原因就是useStateset函数每次设置完值后,最新的值是要通过渲染之后才可以拿到,所以我们如果直接在调用set函数之后去获取值,仍旧是更新前的值,如果想要达到我们要的效果,我们就要使用另一种方式去设置值,也就是将set函数的入参从最终状态值改为一个更新函数,代码如下

005.png

用这种方式有什么区别呢?用这种方式useState就会将函数放在一个队列中,到了下一次渲染的时候,队列中的函数就会依次执行,直到队列中没有函数了,那么计算出来的最新值就会渲染到组件上,我们看下

006.gif

上面举的例子都是更新单个值,但如果我们遇到useState里面存储的是一个对象,然后想要更改这个对象里面某个元素或者某几个元素的值,我们通常会这么做

007.png

这里是新建了一个对象,然后使用set函数来将旧的对象覆盖掉,这种做法没啥问题,不过我们还有更简单的做法

008.png

直接使用...运算符,然后将需要更改的元素和值写在后面就好了,这种做法不但使代码更加简洁,同时也提升了代码的可读性,同样的做法也适用在给一个数组添加新元素的场景中,比如下面这段代码

009.png

同样也是使用...运算符,将需要插入数组的新元素写在后面,就完成了给数组添加新元素的操作,比起将数组拿出来,push一遍,然后再set一下新的数组的这种方式,简便很多

useReducer

在上面的useState中我们举了一个计数器的例子来说明useState可以存储状态,但这里的状态还略微简单一些,如果遇到一些复杂的状态,涉及到一定计算和逻辑处理的,虽然也可以用useState来实现,但不可避免的需要添加一些与业务无关的代码在组件中,破坏了组件的职能单一性,所以为了解决这个问题,我们可以使用useReducer这个勾子,这个勾子可以理解为复杂一些的useState的,它的使用方式如下所示

010.png

使用useReducer的时候,接收三个参数,分别代表的意义如下

  • reducer:一个用来更新状态的函数,这个函数的入参有俩,一个是当前状态state,另一个是行为入参数actionaction是决定state如何更新的关键
  • initialArg:状态的初始值,用来作为useReducer初次加载时候的初始状态
  • init:初始值的初始化函数,它的入参为initialArg,这个参数为可选,如果声明了这个参数,通过init(initialArg)计算出来的值才是useReducer真正的初始值

useReducer同样也返回一个数组,数组里面state作为当前值,dispatch用来触发reducer函数更新,这部分跟useState里面[value,setValue]很像

下面来写个例子来感受下,还是一个计数器,不过这个计数器我们希望既可以加,也可以减,加减的大小也可以通过入参决定,那么这部分逻辑我们就可以写在reducer函数里面,看代码

011.png

我们将reducer函数写在组件外层,命名为sum,组件内部在useReducer内传入sum函数以及初始值,另外两个按钮分别用来触发加3以及减2的操作,按钮边上显示当前state的具体值,这样的做法既可以让state的状态更新与组件分离,也可以避免在组件重新渲染时候,重复创建sum函数,效果如下

012.gif

上面的代码中每一次组件重新挂载,计数器都会从0开始,现在希望组件在挂载后,初始值也可以通过计算得出,毕竟在有的场景中,我们参与计算的初始值可能会从缓存或者数据库中取出来,那么这个时候就要用到useReducer的第三个参数init函数,用法也很简单,我们再创建个函数,入参就是initialArg,这样组件在第一次挂载后初始值就会通过init函数计算获得,新增init函数如下

013.png

初始化的操作很简单,就是将initialArg加上一个1到4的随机值作为初始值,然后将这个函数作useReducer的第三个参数,这样就实现了组件的初始值通过init函数计算获得

014.png 015.gif

可以看到刚开始我刷新了几次页面,组件上的初始值也发生了变化,这个就是useReducerinit函数发挥的作用

useContext

经常会遇到这样的场景,组件之间的数据需要来回通信,比如父组件获取到数据id,然后需要把数据id传递到子组件中获取详情,又或者在子组件中拿到数据后,需要将数据回显至父组件展示,一般像这样的情况,我们会使用props进行传递,但是用props的话也是存在一些不足的,比如

  • props参数过多容易导致组件业务耦合度过高
  • 对于层级比较深的组件,props会提高代码的维护成本以及降低代码可读性

针对这些情况,我们可以使用useContext来解决,这个勾子可以允许我们可以从组件读取或者订阅上下文信息,通俗的说就是共享数据,要做到这一点首先需要去创建上下文,我们可以使用下面这段代码来创建

016.png

createContext创建了一个上下文,并且这个上下文有一个默认值abc,这段代码提供了两个信息,一个是上下文提供的数据类型是string类型的,另一个是上下文如果没有提供值,那么它内部组件获取到的上下文的数据就是abc,现在我们看下如何使用上下文

017.png

首先使用了CoffeeContext.Provider创建了一个上下文环境,并且使用value属性给上下文设置了一个值def,然后在CoffeeContext.Provider里面就是我们的组件了,这里的组件为FirstLevel,在这个组件里面,我们使用useContext获得了之前在CoffeeContext.Provider里面设置的值,并且通过点击按钮将这个值显示在页面上,我们看下效果

018.gif

可以看到我们并没有给组件FirstLevel传值,但是在FirstLevel里面已经可以获取到在父组件里面设置的值了,这就是useContext的基础用法,现在我们来更改一下例子,刚才我们使用的都是静态值,下面我们让上下文的数据可以动态设置,在上面的例子中加点代码

019.png

在父组件中通过按钮执行了函数requestCode,这个函数里面延迟执行了给上下文的数据设置了一个新的值,而在子组件里面,我们取消了刚才的按钮,直接改为显示当前上下文的数据,我们看下现在的效果会是怎么样

020.gif

可以看到我们改变上下文的数据之后,在子组件里面就算没有按钮去触发更新,它自己就直接将最新的上下文数据渲染出来了,从这里就能看出所有在上下文里面的组件其实都订阅了我们上下文的数据,只要上下文的数据改变了,所有子组件都会得到通知重新渲染,所以这里也是给我们提个醒,上下文数据虽好用,但不能滥用,否则会造成不必要的组件渲染以及性能开销,言归正传,刚才我们举的例子都是子组件里面显示父组件改变的上下文数据,那么如果想在子组件改变上下文数据,并在父组件显示,怎么做呢?我们就需要更改一下上下文的数据类型,看代码

021.png

此时上下文提供的数据已经不仅仅是个string类型了,而是一个string(string)=>void组成的object,就像是把整个useState都共享了出去

022.png

由于我们的共享数据已经不仅仅是一个string类型了,所以useContext得到的ctx其实是一个object,如果想要改变共享数据的code值,必须调用ctx里面的setCode函数,效果如下

023.gif

useRef

我们已经知道useState可以存储状态,并且触发组件渲染将最新状态拿出来使用,但是如果我们仅仅只是想将某些状态或者数据存储起来,不想渲染组件,那么useState显然就不是一个最佳方案了,这个时候我们就要使用useRef,这个勾子可以让我们存储一个值的引用并且不会触发组件渲染,比如我们想要操作页面上的一个dom元素,可以这样写

024.png

这里有个输入框和一个按钮,现在想要点击按钮之后让输入框获取焦点,我们就可以先用useRef获取输入框的引用,然后inputref.current就代表我们的输入框了,在函数inputFunc里面就是一个输入框获取焦点的操作,效果如下

025.gif

刚才我们按钮与输入框在同一个组件内,如果当需要操作的引用在不同的组件内,我们就需要将引用作为参数传到子组件内,看下面这段代码

026.png

我们将之前例子中的输入框挪到了子组件ChildInput里面去,这个时候父组件中的inputref就需要作为参数传到ChildInput内,同时ChildInput还要包在forwardRef中,这么做的作用就是让父组件可以获得子组件的元素,这段代码的执行效果与第一个例子是一致的,就不演示了,接着看下一个例子,开头我们说了,如果想要存储某些状态,但又不希望渲染组件,我们可以用useRef这个勾子,现在来写个例子来证明这一点

027.png

组件内有个计数器以一秒一次的频率在计数,并且我们将计数的值存在了timeRef中,timeRef是用useRef创建出来的引用,组件内还有一个按钮,当按钮点击后会将当前计数的值展示在页面上,为了证明状态存储在timeRef后不会触发组件重新渲染,我们还将按钮的背景色用一个随机值来表示,这样当组件重新渲染后,按钮的颜色就会不一样,这段代码的演示效果如下

028.gif

从效果中也证实了就如我们预期的那样,当timeRef的值不断被更新的时候,组件是不会被渲染的,只有重新调用了一下setTimeForNow函数,组件才会被渲染

useEffect

老实说所有勾子里面我第一个会用的并不是useState,而是useEffect,因为它的用法跟Compose里面的LaunchedEffect基本是一样的,我们什么时候需要用到useEffect呢,当我们的一个组件需要执行一些与组件渲染无关的动作的时候,比如网络请求,将数据渲染到组件上等,我们把这些动作叫做组件的副作用,useEffect内部有俩参数,一个setup函数和一个依赖数组

  • setup函数:组件副作用真正要执行的操作,它将会在组件渲染完成后或者依赖项发生改变后进行
  • 依赖项:这个是个可选参数,它是个数组,数组内部的元素只要有其中一个发生变化,就会触发副作用再一次执行它的setup函数

下面来写个例子,分别看看useEffect在有依赖项和没有依赖项的时候的区别

029.png

这个例子中分别有三个useEffect,并且分别没有依赖项目,有一个可变的依赖项和一个不可变的依赖项,三个useEffectsetup函数中也用message的形式给出了对应的提示,有两个按钮,一个按钮是用来更新变量amount,另一个更新变量name,现在看下当我们分别刷新组件,点击左边按钮和点击右边按钮时,究竟哪些useEffect被执行了

030.gif

我们看到依赖项为不可变的useEffect只在组件挂载的时候执行了一下,没有任何依赖项的useEffect在每次组件渲染的时候都会执行,而有依赖项的useEffect只在组件挂载以及依赖项发生改变的时候进行,好了现在我们知道了useEffect的执行时机,那么现在来想个问题,如果useEffect中执行了一个比较耗时的任务,当组件从页面上消失的时候,这个任务还会继续执行吗?来看下面这段代码

031.png

有一个按钮,在点击的时候将Timer组件展示出来,再点一次按钮则卸载Timer组件,Timer组件在挂载的时候就会执行一个计时器,这是个简单的计时器,只是在控制台打印出具体执行了多少秒,现在来看下Timer从挂载到卸载计时器的运行状态

032.gif

如效果图所示,当Timer被卸载的时候,内部的计时器却是依然还在进行,这显然是不合理的,所以useEffect也提供了一个cleanup函数来处理当组件被卸载时,释放内部资源,我们在代码中增加cleanup函数

033.png

增加cleanup函数很简单,在useEffect函数末尾return一个函数,函数内部执行释放资源的操作就好了,现在再来看下组件卸载时候计时器是否还在运行

034.gif

增加了cleanup函数后,useEffect内部的计时器也停止了

useLayoutEffect

这个勾子相对用的比较少一点,但是我们也要清楚它与useEffect的区别,以免在错误的时机去使用它引起不必要的问题,useLayoutEffectuseEffect在使用上完全一样,内部都是接收一个setup函数和一个依赖数组,而不一样的地方则是

  • 从触发时机上,useEffect会在浏览器绘制屏幕之后执行,而useLayoutEffect是在浏览器绘制屏幕之前执行
  • 从使用场景上,useEffect是当你需要处理一些与组件无关的操作比如请求接口,访问数据库,而useLayoutEffect则是当你需要获取DOM信息或者改变DOM的时候去使用

口说无凭,下面用一个例子来加深一下印象

035.png

页面上有一个色块,初始状态给它设置个绿色,然后在useEffect中阻塞一段时间后给色块设置成红色,效果如下

036.gif

我这里做了刷新操作,我们看到组件刷新后有一个从绿到红的闪烁过程,这也是因为useEffect是在页面渲染完成后进行的,所以色块才会先展示默认色,然后再变成红色,现在再把useEffect换成useLayoutEffect再看看

037.gif

可以从顶部的刷新图标看到,组件是已经刷新过了,但是我们的色块却没有像上面那样从绿色变成红色,而是一直展示为红色,这也证明了useLayoutEffect是在浏览器绘制前执行的,在绘制的时候,色块已经用的是更新完的色值,但是后面我们看到反复刷新后,页面有个明显的白屏效果,这里也说明了useLayoutEffect会阻塞浏览器渲染屏幕,用多了是会影响页面的体验效果的,官方文档也在介绍useLayoutEffect的时候,第一句就指出了这个勾子会影响性能,尽可能用useEffect

038.png

总结

本篇文章介绍的六个勾子算是在日常开发中比较常用的,不过就算是常用也会出现像我这样的新手在某些情况下乱用,不会用这些勾子,不过相信经过这篇文章的学习,在今后的开发过程中对这些勾子会有个新的认识,下一篇文章会介绍另外几个勾子,再见~