React-Hook 最佳实践
React Hook
新出现背景
类组件的问题
复用组件状态难,高阶组件+渲染属性
providers customers
,等一堆工具都是为了解决这个问题,但是造成了很严重的理解成本和组件嵌套地狱生命周期带来的负面影响,逻辑拆分严重
This 的指向问题
函数组件的局限
之前函数组件没有
state
和 生命周期,导致使用场景有限
React Hook
Hooks
是 React 16.8
新增的特性,它可以让你在不编写 class
的情况下使用 state
以及其他的 React
特性,无需转化成类组件
Hook
的使用和实践
useState
和 Hook
的闭包机制
快速点击下的情况下,想想 Hook
组件和函数式组件控制台打印出来的是什么?
类组件打印出来的是
3 3 3
Class
组件的state
是不可变的,通过setState
返回一个新的引用,this.state
指向一个新的引用setTimeout
执行的时候,通过this
获取最新的state
引用,所以这个输出都是3
函数组件打印的结果是
0 1 2
函数组件闭包机制,函数组件每一次渲染都有独立的
props
和state
每一次渲染都有独立的事件处理函数
每一次渲染的状态不会受到后面事件处理的影响
函数组件渲染拆解
既然每次渲染都是一个独立的闭包,可以尝试代码拆解函数式组件的渲染过程
三次点击,共
4
次渲染,count
从0
变为3
页面第一次渲染,页面看到的
count = 0
第一次点击,事件处理器获取的
count = 0
,count
变成1
, 第二次渲染,渲染后页面看到count = 1
,对应上述代码第一次点击第二次点击,事件处理器获取的
count = 1
,count
变成2
, 第三次渲染,渲染后页面看到count = 2
,对应上述代码第二次点击第三次点击,事件处理器获取的
count = 2
,count
变成3
, 第四次渲染,渲染后页面看到count = 3
,对应上述代码第三次点击
让函数式组件也可以输出 3 3 3
有种比较简单并且能解决问题的方案,借用 useRef
useRef
返回一个可变的ref
对象,其current
属性被初始化为传入的参数(initialValue)
useRef
返回的ref
对象在组件的整个生命周期内保持不变,也就是说每次重新渲染函数组件时,返回的ref
对象都是同一个useRef
可以类比成类组件实例化后的this
,在组件没有销毁的返回的引用都是同一个
这样修改一下,控制台输出的确实是
3 3 3
? 既然
Ref
对象整个生命周期都不变,修改current
属性也只是修改属性,那除了打印,这里的You clicked 0 times
,点击三次,会变成3
么?显然不能,这个组件没有任何的属性和状态改变,会重新渲染才怪,所以这里虽然点击了
3
次,但是不会像useState
一样,渲染4
次,这里只会渲染1
次,然后看到的都是You clicked 0 times
修复一个问题把另外一个更大的问题引进来,这很程序员。。。
参考 React 实战视频讲解:进入学习
useEffect
通过 useRef
虽然能解决打印的问题,但是页面渲染是不对的,这里还是使用 useState
的方案,配合 useEffect
可以实现我们想要的效果
看下
useEffect
的签名,effect
是函数类型,并且必填, 还有第二个可选参数,类型是只读数组useEffect
是处理副作用的,其执行时机在 每次Render
渲染完毕后,换句话说就是每次渲染都会执行,在真实DOM
操作完毕后。
配合这个 hook
, 如果每次 state
改变后渲染完之后,把 ref
里面的值更新,然后控制台打印 ref
的值,
这样子写可以符合我们的预期效果,页面展示从 0 1 2 3
, 然后控制台输出 3 3 3
,然后我们拆解下渲染过程。
三次点击,共
4
次渲染,count
从0
变为3
页面初始化渲染,
count = 0
,currentCount.current = 0
, 页面显示0
, 渲染完成,触发useEffect
,currentCount.current = 0
第一次点击,
count = 0
, 渲染完成后,count = 1
, 页面显示1
,触发useEffect
,currentCount.current = 1
第二次点击,
count = 1
, 渲染完成后,count = 2
, 页面显示2
,触发useEffect
,currentCount.current = 2
第三次点击,
count = 2
, 渲染完成后,count = 3
, 页面显示3
,触发useEffect
,currentCount.current = 3
三次点击完成,
currentCount.current = 3
,第四次渲染,页面看到count = 3
,setTimeout
中调用的是currentCount
这个对象,输出都是3
useEffect
的函数返回值
useEffect
的回调函数可以返回空,也可以返回一个函数,如果返回一个函数的话,在 effect
执行回调函数的时候,会先执行上一次 effect
回调函数返回的函数
这个 useEffect
,每次渲染完之后,控制台会先输出 last time effect return
,然后再输出 after render
useEffect 和 类组件生命周期
之前提到,useEffct
有两个参数,第二参数是个可选参数,是 effect
的依赖列表, React
根据这些列表的值是否有改变,决定渲染完之后,是否执行这个副作用的回调
如果不传这个参数,React
会认为这个 effect
每次渲染然之后都要执行,等同于 componentDidUpdate
这个生命周期无约束执行
如果这个参数是空数组,React
会认为组件内任何状态和属性改变,都不会触发这个 effect
,相当于这个 effect
是仅仅在组件渲染完之后,执行一次,后面组件任何更新都不会触发这个 effect
,等同 componentDidMount
如果配合 useEffect
回调函数的返回函数,可以实现类似 componentWillUnmount
的效果,因为如果是空数组的话,组件任何更新都不会触发 effect
,所以回调函数的返回函数只能在组件销毁的时候执行
如果依赖列表里面有值,则类似componentDidMount
有条件约束更新,只有当上一次的状态和这次的不一样,才执行
useEffect
和 闭包问题
假设组件需要在初始化的时候,定义一个定时器,让 count
自增,自然而然的可以写出以下的代码
但是实际运行的时候,类组件展示是对的,函数组件从 0
递增到 1
之后,页面渲染就再也不变了
之前有提过,类组件因为有
this
这个引用,很容易通过state
拿到最新的值函数组件每次渲染都是独立的闭包,这里因为写的依赖值是
[]
,所以只有首次渲染后,才会这行这个effect
,首次渲染后,count
就是0
,所以setCount(count + 1)
每次都是执行setCount(0 + 1)
,所以定时器工作是正常的,不过取的值有问题。
闭包问题的切入点和发生场景
闭包问题,大多发生在,有些回调函数执行,依赖到组件的某些状态,但是这些状态并没有写到 useEffect
的依赖列表里面。导致执行回调函数的时候,拿到组件的状态不是最新的。
主要的场景有:
定时器
事件监听的回调
各种
Observer
的回调
这些场景,通常只要在组件初始化渲染完之后,定义一次回调函数就好,但是如果回调函数依赖到组件的转态或者属性,这时候就要小心,闭包问题
例如这里的写法,在组件渲染完监听 hashchange
,回调函数是拿不到后续更新的 state
的,只能能到初始化时候的空字符串。
尝试解决闭包问题-监听state
变化
既然回调函数要每次都拿到最新的 state
,可以监听 state
的变化,state
变化的时候,重新定义事件监听器,改写一下
以上代码能用,但是 state
每次改变,就会重新定义一个 hashchange
回调函数,但是上一次的 hashchange
的事件监听器并没有清除,代码能跑,但是内存泄漏也太严重了,可以配合 useEffect
回调函数返回的函数配合清掉上一次的事件监听器
这样内存泄漏的问题被解决了,但是这种事情监听,正常来说设置一次就好,没必要重新定义,还有别的更好的方法么?
尝试解决闭包问题 - setState
另外一种更新组件状态的方式
useState
返回的更新状态的函数,除了可以传一个值,还可以传一个回调函数,回调函数带一个参数,这个参数是最新的 state
,像这样的话,之前那个定时器的例子,可以修改成这样。
这里我们改了一行代码,setCount(count + 1)
改成了 setCount((c) => c + 1)
,这样修改之后,其实定时器回调已经没有依赖到 count
这个值了,由 setCount
里面的回调函数,返回最新的 count
的值,就是setCount((c) => c + 1)
,里面的 c
.
同样的,对于事件监听器里面,我们也可以通过这个方式去获取最新的 state
,但是这里有几个问题
这个回调函数,其实也只要获取最新的
state
,所以在调用setState
的时候,拿到最新的值的同时,记得把setState
的值,设置成和当前同一个,如果没有返回,那调用setState
之后,state
的值会变成undefined
setState
返回一个同样的值,会不会导致组件和它的子组件重新渲染?找了下文档说明是这样的:调用 State Hook 的更新函数并传入当前的 state 时,React 将跳过子组件的渲染及 effect 的执行。需要注意的是,React 可能仍需要在跳过渲染前渲染该组件。不过由于 React 不会对组件树的“深层”节点进行不必要的渲染,所以大可不必担心看起来可行的,做一下简单的修改其实可以改成这样
这样基本就没问题了,做到了只定义了一次回调,然后也可以获取最新的 state
,一举两得,但是还是有问题的
setState
回调函数如果不写return stateCallback;
这段代码,会导致state
莫名其妙被设置成undefined
,而且非常不好发现,维护性太差setState
是用来改变组件的state
的,不是让你这样用的的,虽然这样用完全没问题。但是可维护性太差了,如果你的代码被接手,别人就会疑惑这里为什么要这么写,无注释和变量命名太糟糕的情况下,代码可以维护性基本为0
设置一个同样的
state
,虽然不会导致子组件重新渲染,但是本组件还是有可能重新渲染的,按官网的说法
这个方案不完美。思路再发散一下?执行回调函数的时候,需要获取到最新的 state
,能不能用一个不变的值缓存 state
? 等等?? 不变的值???
解决闭包问题最佳实践-useState
和useRef
useRef
的返回是在整个组件生命周期都是不变的一个对象,可以借助 useRef
来获得最新的 state
。例如这个例子可以改成这样:
stateRef.current
上面两种写法,都可以获得最新的 count
,回调函数里面里面直接读取 stateRef.current
的值,可以拿到最新的 state
闭包问题的最优解,节本就是这样了。
useRef
和 useState
的最佳实践
useState
和useRef
仔细想想和和类组件的什么属相很相似?是不是和this.state
和this
的属性很像在类组件中,如果是不参渲染的属性,直接挂
this
上就好了,如果需要参与渲染的属性,挂在this.state
上同样的,在
Hook
中,useRef
和useState
可以实现类似效果
例如以下的例子
再看看 useEffect
回调函数的返回值
确定是没有返回或者返回一个函数,所以下面这种写法是有问题的,虽然也没有明显标明返回体,就是没有返回一样,但是这个回调函数是异步函数,异步返回默认返回一个 Promise
对象,所以这种写法是不提倡的
为了规避这个问题,可以修改一下写法
useCallback
把函数写进里面没什么问题,官方也推荐,但是万一我的副作用里面需要处理多个函数或者一个超长的函数的话,一个是不美观,一个是太难维护
这个适用可以利用 useCallback
把函数抽离出去,useCallback
返回一个记忆化的函数,当且仅当依赖列表有任何属性改变的时候,它才会返回一个新的函数,所以这个特性比较适合传给子组件的回调函数
这里如果 count
改变的时候,getFetchUrl
的值也会改变,从而导致 useEffect
也触发
React.memo
React.memo()
返回一个记忆化的值,如果 React 内部会判定,如果重新渲染
props` 不相等,就会重新渲染,如果没有改变,就不会触发组件渲染
这个特性比较有用,因为如果父组件重新渲染的时候,子组件就会重新渲染,使用这个特性可以减少不必要的子组件重新渲染
React.useCallback
和 React.memo
为什么讲
useCallback
要把memo
拎出来讲,想一下useCallback
的作用,返回一个缓存的函数,在函数组件里面,每次渲染都会执行一次组件函数,组件函数每次执行,在组件内部的函数都会重新定义,这样的话,父组件传给子组件的回调函数每次渲染都会变再从
memo
的角度去看,父组件每次渲染,子函数组件如果不加memo
的话,就算是子组件无任何依赖,属性都不变的情况下,子组件也会重新渲染如果在父组件单独加为子组件的回调函数添加
useCallback
,这样可以避免回调函数重新定义,但是子组件如果不用memo
包裹,就算任何子组件属性没改变,还是会导致子组件重新渲染;同样的,如果子组件单独用
memo
包裹,父组件每次渲染,重新定义回调函数,还是会导致重新所以,
memo
和useCallback
必须都用上,不然是没用的,不仅达不到优化的效果,而且会加重 React 比较的负担。要不就别用,要不就都用上。
React.useCallback
和 React.memo
最佳实践
父组件用 useCallback
包裹函数,子组件用 memo
包裹组件,要不就都不用
Raect.memo
的局限
React.memo
包裹在组件上,可以对传给组件的属性进行判定,父组件导致子组件重新渲染的时候,memo
包裹的组件,会判定属性是否和上次渲染时候否改变,如果有改变,子组件重新渲染,否则不会重新渲染。React.memo
有个局限,只能防止来源于外部的属性,如果是来源于内部的属性,React.memo
是无作用的,例如通过useContext
直接注入组件内部的属性,它没法防止,可以看下下面这个简单的例子
上面的组件,
count
或者step
任意这个属性改变,都会导致两个子组件重新渲染,这显然是不对的。
React.useMemo
代替 React.momo
useMemo
和 memo
一样,返回一个记忆化的值,如果依赖项没有改变,会返回上一次渲染的结果,它和 useCallback
的差别就在一个是返回记忆化的函数,一个是返回记忆化的值,如果 useMemo
的回调函数执行返回一个函数,那它的效果和 useCallback
是一样的。
因而上面的组件可以改一下,下面这种写法就可以防止任意一个属性改变会导致两个子组件重新渲染的问题
React.momo
和 React.useMemo
React.momo
在防止子组件重新渲染方面,是最简单的,在类组件里面有个React.PureComponent
,其作用也是。但是它无法检测函数内部的状态变化,并且防止重新渲染,例如useContext
注入的状态。不过它自动比较全部属性,使用起来方面。React.memo
按照依赖列表是否有属性改变,决定是否返回新的值,一定程度上和Vue
的计算属性类似,但是需要说动声明依赖的属性。相比React.momo
,它的控制的粒度更细,但是一般的外部属性变化,用这个明显没有React.memo
方便
useReducer
useContext
useReducer
是useState
的一种替代方案,useState
的内部实现就是useReducer
它接收两个参数,和
redux
一样,一个是reducer
, 一个是初始值,有两个返回,一直是当前的state
,一个是dispatch
通过
dispatch
调用action
就可以修改state
里面的数据本质的作用是,让数据和函数组件解耦,让函数组件只要发出
Action
,就可以修改数据,由于数据不在组件内部,也不用处理内部state
变化带来的effect
。useContext
和useReducer
结合,一定程度上可以实现一个React Redux
其他 Hook
useImperativeHandle
,搭配useRef
和forwardRefs
可以实现定制父组件可以引用子组件的属性和方法,而不是直接引用整个子组件的实例,在父组件需要调用子组件属性和方法,但是又不想全部属性和方法都给父组件调用的时候使用useLayoutEffect
使用的不多,作用和useEffect
一样,但是这个hook
是在组件变化后,DOM
节点生成后,渲染之前调用,区别于useEffect
是渲染之后调用,不太推荐使用,会阻塞渲染useDebugValue
可用于在React
开发者工具中显示自定义hook
的标签。类似Vue
组件用的name
或者React
组件中的displayName
,不影响代码运行
组件复用
React Hook
有自定义 Hook
,React
类组件有高阶组件或者渲染属性
有个比较常见的场景,进入页面需要调用后端接口的问题,如果每个组件都写一次,很繁琐,假设处理数据的接口长这样子
高阶组件
高阶组件(HOC)
是 React
中用于复用组件逻辑的一种高级技巧。HOC
自身不是 React API
的一部分,它是一种基于 React
的组合特性而形成的设计模式。这种组件复用还挺常见的,比如 React-redux
里面的 connect
,React Router
的 withRouter
它可以做到:
属性代理,比如多个组件都使用到的公共属性,注入属性
包裹组件,比如将组件包裹在写好的容器里面
渲染挟持,比如权限控制
用处
代码复用
性能监测 打点
权限控制,按照不懂的权限等级,渲染不同的页面
高阶组件编写和使用
按上面请求的需求,做一个组件渲染完之后,就立即开始请求初始数据
使用方面,高阶组件可以修饰类组件或者函数组件
自定义 Hook
的编写和使用
自定义 Hook
的编写,一个简单的数据请求的 Hook
自定义 hook
只能在函数式组件使用,不能在类组件里面用
函数式式组件和类组件默认属性
类组件的问题被解决了么?
复用组件状态逻辑难
依赖自定义的 Hook,可以解决组件状态和逻辑复用的问题,但是自定义
Hook
编写需要对Hook
运行机制非常了解,门槛并不比高阶组件低
生命周期带来的负面影响,逻辑拆分严重
生命周期拆分逻辑的问题,在
Hook
里面切实被解决了,不会存在同一个逻辑被拆分在N
个生命周期里面了
This
的指向问题
这个问题在 Hook 里面也是解决了,因为函数没有
this
,就不会有this
的问题,但是相对的,如果需要一个不变的对象,请使用useRef
简单总结
useState
可以实现类似state
和setState
的效果useEffect
可以实现componentDidMount
componentDidUpdate
componentWillUnmount
这几个生命周期的功能,并且写法更加简单,在每次渲染后都会触发,触发的条件是依赖项有改变useRef
返回一个引用,每次渲染都返回同一个对象,和类组件this
属性一致useCallback
返回一个记忆化的回调函数,在依赖项改变的时候,回调函数会修改,否则返回之前的回调函数,对于一些需要传给子组件的函数,可以使用这个,避免子组件因为回调函数改变而改变useMemo
返回一个记忆化的值,依赖项改变,返回的值才会变,可用来记忆化值,和Vue
计算属性类似,避免重复计算,避免重复渲染自定义的
Hook
是实现状态和逻辑复用,作用和高阶组件还有渲染属性差不多useReducer
是useState
的底层实现,可以管理多个state
,把state
从组件内部抽离出来useContext
可以实现批量传值,注入多个组件,和useReducer
useMemo
使用可以实现Redux
的功能
使用感受
个人使用方面
函数式组件本身写起来就比类组件少写不少代码
闭包问题很影响开发和调试,提高了不少调试成本,如果不熟悉闭包机制,很难发现问题。
Hook
中的闭包问题,大多还是由于依赖项没有填写导致闭包带来的问题,比类组件
This
的更加恼人,主要调试不好发现问题,填不填依赖项也是一个让人纠结的活Hook
的依赖不能自动识别,必须手动声明,虽然有插件辅助添加,但是使用起来还是不如Vue
的Hook
在熟悉
Hook
的机制的情况下,Hook
开发体验还是比类组件好很多
团队协作方面
其实在推广
Hook
的时候,团队成员的Hook
水平是不太一致的,很多人员就遇到了闭包问题,还有依赖死循环的问题,这个可能大大小小都遇到过,就好像上面提到的,解决闭包问题,方式五花八门,其实也是我自己摸索过来的,然后看到团队成员其实差不多还使用者state
更新之后,重新设置监听的方式,这个并不是太好,只能说闭包问题解决了相对的,
React
官方也没有总结太多最佳实践,很多都靠自己实践过来的,所以团队成员在刚接触Hook
的时候,都是useEffect
useState
两把API
,甚至在React Hook
的官方文档里面 Hook 简介,对于这两个Hook
介绍的很多但对于其他常用的
Hook
,比如useRef
和useCallback
使用场景其实没有太好的例子去支撑这些API
的使用。倒是其实团队里面不少成员,面对着不参与渲染的属性,也是用useState
,而不是使用useRef
。就是很多新人接触Hook
容易犯的一个错误。有不少同学有些插件没有装上,导致
React
自动检测依赖项的插件没有生效,这无疑会给本身就难以发现的闭包问题加了一层霜所以我也定期在团队里面分享我认为是比较好的实践,去引导团队里面的同学
对于不喜欢用
React Hook
的同学,直接用类组件,类组件虽然代码写起来繁琐,但是起码没有闭包这些问题,而且代码被接手之后容易读懂,React Hook
只是一个工具,会使用会给你加分,但是不会使用只会用类组件,也不会对其他人代码有影响,比较类组件和函数组件是可以共存的
评论