react-hooks
对react-hooks系统的做一次总结吧,不然总是写着查写着查
对于react-hooks不多赘述,就理解为这些hooks让函数组件拥有了类组件的大部分功能并代替类组件使用就行
然后react中有七大hooks
- useState 函数组件中使用State
- useEffect/ useLayoutEffect 函数组件中的副作用使用
- useContext 全局数据共享
- useReducer 简化版redux
- userMemo / useCallback 缓存, 优化性能
- useRef 引用实例,保存变量
- 自定义hook
useState
useState就是衍生自setState(),它让函数组件从无状态的简单组件变成了复杂组件,使用方式和setState()差不多,每个组件通过维护自己的state去处理一些业务逻辑,其实使用上是比较简单的,但深究起来…龟龟,就设计到react的底层逻辑了,就是每次setState后会发生什么。
所以,为了了解useState的用法,我们还是先探究一下setState()的工作机制。
对于setState的使用其实很简单,react官方文档的例子看看就能懂
1 | class Clock extends React.Component { |
具体我就不解释了,就是每次setState()会重新render然后又依次调用生命周期函数,然后DidMount后又tick了一下,this.state()指向一个全新的引用,又重新render,就出现了一个每秒钟递增的计时器效果。其他对于state的基本使用官方文档讲的已经很明白了,这里不赘述,我们在这里就说一下setState()会经常被提起的两个问题
- setState()是同步更新还是异步更新
- setState()是合并更新还是替换更新
先说第二点吧,直接结论,setState是合并更新,直接看代码
1 | this.state = { |
为什么是合并更新很好理解嘛,因为控制某个组件的状态都得放在this.state这一个对象里面,然后我们想想看,假设这个页面需要的状态很多,也就是要有一堆key value,但我们每次更改state应该只会更改其中的一项,那我们每次写都要
setState({...this.state, a: xx})
万一哪次我们忘了,会咋样,就看上面那块,整个state就变成了{d: 4},那用到a,b的地方渲染的就是undefined,Oh,然后GG,所以为了避免这种情况发生react就在底层做了这么一层浅合并的处理,当然,这也不完全是好的。我们在看下面这一串代码
1 | this.state = {a:1, b:'muxiMfe'} |
想想看,最后我们console.log(this.state),输出是什么?是{a: 2, b:”muxiMbe”}吗? 这个问题放在后面讲了异步同步的问题再来解答,大家伙好好想想。
而useState()是替换更新,这个待会儿再讲。所以,在明确setState()是合并更新后,我们再来看第一个问题
setState()是同步更新还是异步更新?
我们看看react官网怎么说
1 | State 的更新可能是异步的 |
啥意思呢,假设有如下代码 (为了方便就简写一些伪代码)
1 | this.state = {a: 1} |
大家应该都知道a会输出1,这就说明了setState是异步执行的,所以a拿到的还是之前的值,那为什么setState()会被异步执行?
1 | if (xxx) { |
如果是同步更新,会出现什么样的情况? 就是当代码执行到1的时候, 页面被重新render一次,然后到2,又重新render一次,所以我们每一次调用setState()都会重新渲染页面,这对性能的消耗是巨大的。
接下来说的要涉及到Js的事件循环,这部分也比较重要,说起来比较麻烦,这里就不讲了。可以自行了解。
为了解决这个问题,react就在底层实现了一个缓存队列,每次的setState()都不会立刻执行,都会放到这个缓存队列里面去(类似于原生Js的任务队列),然后去执行其他语句(执行栈),等到执行栈空后,再来合并更新缓存队列里面的state,这样哪怕一个事件处理函数里有多个setState(),也只会重新调用一次render。
那,官网上说 可能是异步的,所以什么时候是同步的呢?
我们先想想我们什么时候会用到setState()
- 生命周期里面发送请求后重绘页面
- 与用户发生交互
- 定时器触发
这里注意到,生命周期和与用户交互的合成事件,都是react自己封装的API,所以他们都会对这里面调用的setState()进行包装处理放入缓存队列中,也就是会异步执行,而定时器(又或者addEventsListener),是浏览器自带的API,react并没有对其封装重写,所以定时器里的setState()就会跳出react的控制,不会被放到缓存队列中,也就是说:
1 | this.state = {a: 1} |
而setState()是如何执行异步/同步的呢?
查看react源码发现,代码中有一个变量锁isBatchingUpdates,isBatchingUpdates表示是否进行批量更新,初始化时默认为false,batchedUpdates方法会将isBatchingUpdates设为true
1 | var ReactDefaultBatchingStrategy = { |
为了合并setState,我们需要一个队列来保存每次setState的数据,然后在一段时间后,清空这个队列并渲染组件,这个队列就是dirtyComponents。当isBatchingUpdates为true时,将会执行 dirtyComponents.push(component); 将组件push到dirtyComponents队列。
调用setState()时,其实已经调用了ReactUpdates.batchedUpdates,此时isBatchingUpdates便是true。
1 | if (!batchingStrategy.isBatchingUpdates) { |
至此,setState实现了合并和批量处理。
好,这时候我们再来看看刚刚那个遗留问题,console.log(a)到底会输出啥?答案是{a:1, b:”muxiMbe”}
为啥? 想想看,
1 | this.state = {a:1, b:'muxiMfe'} |
那再想想这个输出什么?
1 | this.state = {a:1, b:'muxiMfe'} |
可能现在还对setState()有点迷糊,但没事,哎,我们的重点是啥,useState()!
首先我们明确,useState()是让函数组件中有了state,返回一个xx(某个state的名字)和setXX() (设置这个state的方法),然后这个setXX,大部分特性setState()的特性是一样的,但又有些不一样,这里官方文档也提到了
1 | 注意 |
想想看为啥,类组件如何渲染虚拟DOM?render方法,他的state则是类的一个属性,所以无论render几次,this.state也只是进行浅拷贝合并更改而已,也就是说整个类不会被销毁重新创建,只是会依次调用相应的生命周期方法,而函数组件呢? 他只有一个return,所以每次状态改变,都会重新调用一次这个函数,函数作用域销毁重建,内部变量也是这样一个步骤。所以为了保存上一次的变量,要么用闭包(很多一些简易版的useState()的源码就是通过闭包实现),要么用ref,所以这里也可以解答为什么useState()是替换更新而不是合并更新了。
先看看一般大佬们自己封装的简易版useState()
1 | let memoizedState: any[] = [] // hooks 的值存放在这个数组里 |
可以很明显的看出来返回的setState就运用到了闭包来访问memmoizedState,源码更为复杂,比如这里就没有涉及到刚刚提及的异步问题,这里的useState()都是同步执行。
所以,useState()也和setState()一样在合成事件中是异步的吗?答案是肯定的。和setState一样,useState在合成事件以及useEffect这些react封装的API呈现异步更新,而在定时器等webAPI中的话就是同步执行。
而如何异步执行大体流程也和setState一样,先放入缓存队列后在批量更新,重新执行函数组件的每一行代码。那就带来另一个问题,重新执行每一行代码,那为什么useState中的state不会被传进去的initState覆盖? 这里看上面的代码memoizedState[cursor] = memoizedState[cursor] || initialValue
就能解释,react底层同样做了类似的处理(应该是更流弊的处理不过我还没看懂)来让initialValue只传入一次。
所以,其实弄懂了setState(),useState()也没什么特别难懂的地方。但还是要注意一些问题
- useState如何同步获取代码
既然都说了useState在大多数情况下其实是异步更新的?那现在有个需求,我就是想更新state后立刻拿到最新的state,我该如何操作?先看setState()
其实setState有两个参数,第一个就是新的state的值,第二个是一个callback函数,这个callback函数会在缓存队列被清空后,然后其他的生命周期都走完了,你的this.state的指向已经指向了一个新对象,然后再执行,这样就可以拿到最新的state了。 那函数组件呢? 哎,用useEffect, useEffect也会在每次函数组件更新时都执行一遍,在这里拿到state最新的代码就行(注意依赖)
- 用useState返回的setState更新状态后会发生什么
还是一样,先想想看setState后发生什么? 首先this.state指向一个新对象,然后重新调render(),其他的生命周期再走一遍
而useState没那么多复杂,比如你的函数组件叫App, 那在useState这个hooks里,更新你的state后,直接App(),对,就是直接再调用一下这个函数而已。
所以我们看一下代码,再来检测一下对setState/useState工作机制的掌握程度
1 | function Component() { |
所以点击两个按钮,分别会发生什么?
第一个两行输出,a 2 和 a 3 第二个只会输出一行 a 3 setState同理,具体原因不解释了,再仔细看看之前说的那些。
至此,对于state的机制应该算是说的差不多了,至于源码究竟是怎么实现的,目前还看不懂呜呜呜。
但你们以为这就结束了?不不不,我们再来想一个问题。
首先我们明确,react代码最后会被编译解释成原生的Js,那问题来了,对于setState(),他到底是宏任务还是微任务?
简单解释一下什么是宏任务和微任务,具体了解还得和前面提到的事件循环机制一起去好好看看。
就举个例子,我们把Js的任务队列当成一个银行柜台,然后里面的每个任务当成顾客,顾客一个个去银行柜台办理业务,任务就一个个执行。那银行柜台是怎么给顾客办理业务的?顾客按照先来后到的顺序编号,然后一个个叫号,叫到谁谁去,所以在这里,每一个顾客,就是一个宏任务,然后办理业务的时候,总会遇见一些大爷大妈,你给他办完了银行业务,准备叫下一个了,哎,他不走,他非要把你拽住,说“小姑娘我看你怪俊俏的嘞,我隔壁村头有个小伙子蛮不错的你们要不要认识一下?”然后柜台又得皱着眉头把这些杂七杂八的事给处理完,哎,这些事,就是微任务。
所以我们可以得出
宏任务永远在微任务前执行
微任务没有执行完成前不得进行下一个宏任务
宏任务有
1 | script(整体代码) |
微任务有
1 | Promise.then |
哎,那我们想想看,setState作为一个“异步操作”,那他到底是会被放入宏任务队列,还是微任务队列呢?
1 | handleClick = () => |
输出啥?
1 | setState |
在一个事件循环中,入执行栈的事件比微任务晚,但执行时间却早于微任务,怎么,setState是同步任务?可不对啊,前面不是很明白的说了setState在合成事件中和生命周期里面是异步任务吗?哎,都说了,是在react合成事件中和生命周期里面被react底层处理后,才显示异步的状态,那我们把他拿出来看看呢
1 | handleClick = () => { |
结果毫无意外的是
1 | 开始运行 |
所以!!!setState在本质上就是在一个事件循环中,在运行上是基于同步代码的实现,他并不会被推到任务队列里面去!他只是有异步的行为而已,说得通俗一点,setState是一个伪异步,或者可以称为defer,即延迟执行但本身还在一个事件循环,所以它的执行顺序在同步代码后、异步代码前。
至此,对于state的机制大概就到这里了,因为useState在hooks里面的特性还是与setState高度类似的,所以可能主要还是再讲setState。
接下来就看看另一个贼重要的hooks——useEffect()
useEffect
这个hooks,说难也难,说简单也简单,但我就想明确一点
千万 不要 把 useEffect() 和生命周期 划等号!!!
懂我意思?别把useEffect()看作函数组件的生命周期,函数组件没有生命周期!! 不然这个hook应该叫use生命周期()而不是useEffect()。好,接下来不扯淡了,我们开始聊一下useEffect(),先简单的看一下他的基本用法
1 | (1). Effect Hook 可以让你在函数组件中执行副作用操作(用于模拟类组件中的生命周期钩子) |
上面这些引用自我之前写的todolist,里面记了一些对hooks的基本使用。
这时候有人就说了,哎哎哎你自己都写了能把useEffect()看做成三个生命周期方法的组合,现在又说不能把他看作生命周期,你不是自己打自己脸嘛。
哎呀,凡事我们先看看官方文档嘛
1 | effect 的执行时机 |
看到没有,官方文档明确的说明,useEffect的执行时机都和生命周期不同了,那怎么可以把useEffect看作生命周期呢? 我们换个角度理解: effect 是什么意思,作用,影响,那Js函数中提到作用和影响第能想到什么?函数的副作用。 而函数组件从某种意义上来说是什么? 纯函数——接受同样的参数(props)就会返回同样的值(UI),所以useEffect就可以理解为,我们让函数组件这个纯函数有了副作用,让他变成了非纯函数,也就是—— **函数通过参数和返回值以外的渠道,和外界进行数据交换。比如读取/修改全局变量,都叫作以隐式的方式和外界进行数据交换。 **仔细想想,我们在useEffect()中订阅数据然后渲染UI,不就是一个非纯函数的实现?
所以,useState是让函数组件有了状态,而useEffect则是让函数组件有了副作用,而并不是有了生命周期。
那刚刚说到执行时机,我们在想想,执行时机不同?怎么个不同法?
这里,我们先简单的了解一下浏览器的渲染机制 参考链接
在了解浏览器渲染过程之前,先来了解一下页面的加载流程。有助于更好理解后续渲染过程。从浏览器地址中从输入url地址到渲染出一个页面,会经过以下过程。 1.浏览器输入的url地址经过DNS解析获得对应的IP 2.向服务器发起TCP的3次握手 3.建立链接后,浏览器向该IP地址发送http请求 4.服务器接收到请求,返回一堆 HMTL 格式的字符串代码 5.浏览器获得html代码,解析成DOM树 6.获取CSS并构建CSSOM 7.将DOM与CSSOM结合,创建渲染树 8.找到所有内容都处于网页的哪个位置,布局渲染树 9.最终绘制出页面
浏览器渲染机制
我们将要介绍的浏览器渲染过程主要步骤是5-9步,可以用下面的图来形象的展示
看图,就简单的说一下,详细的渲染过程大家还是自己去看。
- 第一步就是先根据html生成DOM树,在构建过程中,如果有图片 css等资源,会发起请求,但不会阻塞页面继续渲染
(react的虚拟DOM优化就在这,如果没有react,就是从一大串的html文件里面慢慢生成真实DOM,而有了react,则会在下面那一步的加载执行Js代码里面根据VDOM树渲染出真实的DOM树——执行Js脚本的速度比解析html中的真实DOM快的多)
- 根据css生成CSSOM树
(然后会加载执行Js代码)
- DOM树和CSSOM树结合生成Render树
这两种情况会重新构建render树
1)增删元素(包括插入后续请求到的图片资源)
2)改变盒模型
- 对Render树布局,就是你网页里每个区域的宽高啊,一个图片该放在哪儿啊,做这些计算布局(Layout)渲染
- 把最后形成的页面绘制到屏幕上,至此渲染过程完成
- 如果页面的DOM发生变化就会引起浏览器的重绘和回流,重绘就是重新执行第五步,回流则要重新执行第四步,DOM的外观发生变化,比如颜色啥的会引起重绘,而如果DOM的结构发生变化则会引起浏览器的回流,比如尺寸布局节点none等等等等,这也是直接操作DOM性能低的原因,因为react的VDOM会在每次更改后合并然后只更改diff,所以每次页面变更都只会进行一次重绘/回流
useEffect()在哪儿执行? 在第五步之后,浏览器绘制屏幕后才会执行。而生命周期函数呢? 在浏览器绘制之前,也就是第四步和第五步之间,所以真的想要生命周期的功能,还得用react提供的另一个API——useLayoutEffect(),注意这个Layout,不就刚好对应第四步的布局render树那一步?
所以,事实上,useLayout()才是react提供用来代替生命周期的hook,看官方文档怎么说
1 | 其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。 |
为什么useLayoutEffect()会阻塞视觉更新呢? 很明显啊,你这个hook在浏览器绘制前执行,然后你在这个hook里面写了一些计算量比较大的代码,那浏览器不得等你个贼娃子执行完了再开始进入渲染,所以这也算react团队做出来的一个性能优化。
可以拿以下代码去试试
1 | function App() { |
当我们快速点击的时候,会出现闪烁效果,为啥,你先setCount把界面渲染成0了,然后又执行useEffect()中的setCount,页面其实被快速的重绘了两次——从0跳到randomNum,而生命周期函数和useLayoutEffect()就不会闪烁,他们会同步更新执行setState,直接渲染出randomNum。
讲明白这个后,对于useEffect就好理解了,接下来再明白这几个概念就行
- useEffect会捕获当前渲染的props 和 state
- useEffect的清除副作用的回调函数是在下一次浏览器绘制后,下一个useEffect执行前 执行的
先看 useEffect会捕获当前渲染的props 和 state 这句话怎么理解? 我们之前在useState()那里讲过,每一次我们改变函数组件的状态,都会重新调用该函数组件,所以,我们如果有如下代码
1 | const App = () => { |
每次调用setCount的时候 可以理解为
1 | // 首次渲染 |
所以,看下面这串代码,点击show alert,然后点击把count增加到3,最后alert的值是多少?
1 | function Counter() { |
是0,对吧,因为首次渲染把定时器推入到任务队列中,他所捕获到count值就是0,不会随着后续count的值改变
所以这串代码呢
1 | function Counter() { |
1 - 5 依次打印,是吧,牢记住“捕获”这个点就行,哎,那一直说了和生命周期不同,我们把这串代码放到类组件里面用生命周期表示呢?
1 | componentDidUpdate() { |
这就会打印五个五了,为啥,哎这生命周期方法不也是个函数嘛,他为什么不捕获呢? 他确实捕获了,但他捕获的不是this.state.count,他捕获的很简单,就是这个this的指向,我们可以把这个this理解为一个指针嘛,指向这个class组件实例,好,我不断点击,我就不断捕获这个指针变量,最后我打印,我就拿着这个指针去找啊,this..嗯..state..嗯,哎count!找到了,5!那我直接输出5。 所以这就是五个5出现的原因,那我再修改一下
1 | componentDidUpdate() { |
这就输出啥? 1,2,3,4,5依次输出,很好理解,每次都会捕获this,this不变,this指向的count在改变,所以直接打印count的话就是1,2,3,4,5依次输出了。
那我要是再想用useEffect()打印5个5呢?函数组件又没有this,哎,那我们可以用ref
1 | function Example() { |
为啥? 因为ref在整个渲染过程中,是一个不变的对象,所以对于他指针地址的捕获是不变的,只是改变他身上原本的属性而已,和class不是一致?class在渲染过程中this始终不变,仅仅是他的state.count改变了而已。
useEffect的清除副作用的回调函数是在下一次浏览器绘制后,下一个useEffect执行前 执行的 这个怎么理解?之前不说说过,useEffect是在浏览器绘制后执行嘛,那我这里面有个定时器控制轮播图,我渲染成功后轮播图开始播,然后我直接return一个cleanInterval(),得嘞,轮播图瞬间变海报。所以这个清除副作用的函数只能在下一个useEffect执行前执行。(这里简单的提一下,react对hooks的实现是把所有的hooks放在了一个链表里面——hooks链表然后依次执行hook的,这个有时间在自己去深挖吧。)
最后不得不提的一点就是useEffect的依赖了,注意点比较多,然后有一些细究和优化的写法,比如用useCallback/useMemo解决依赖问题,把函数提到最外层解决依赖,使用useReducer解决依赖等等。参考这篇文章就行