我们在之前的章节当中已经整体的梳理一遍 React
当中的几种状态逻辑复用的方式,从 Mixin 到 HOC,再到最后的 Hook,虽然 Hook
算是比较新的内容,但是它也已经渐渐开始变得普及起来
所以在本章当中,我们就来深入的了解一下 Hook
当中的 useEffect
,主要参考的是官方 FAQ
当中的 如果我的 effect 的依赖频繁变化,我该怎么办?,更多详细内容可以参考文档
遇到的问题
首先先来看看我们在使用 useEffect
过程当中会遇到的一些问题,不过不用担心,这里涉及的问题我们都会在后面详细来进行介绍,涉及到的问题主要有以下几点
- 如何用
useEffect
模拟componentDidMount
? - 如何在
useEffect
里请求数据?[]
又是什么? - 是否应该把函数当做
effect
的依赖? - 为什么有时候会出现无限重复请求的问题?
- 为什么有时候在
effect
里拿到的是旧的state
或prop
?
如何用 useEffect 模拟 componentDidMount?
如果我们想要模拟生命周期,虽然可以使用 useEffect(fn, [])
,但它们并不完全相等,和 componentDidMount
不一样,useEffect
会捕获 props
和 state
,所以即便在回调函数里,我们拿到的还是初始的 props
和 state
,如果我们想要得到最新的值,可以使用 ref
来进行操作,不过通常会有更简单的实现方式,所以并不一定要用 ref
,关于这一点我们会在下面详细来进行介绍
但是我们需要注意的是 effect
的运行方式和 componentDidMount
以及其他生命周期是不同的,effect
更倾向于数据同步,而不是响应生命周期事件
如何在 useEffect 里请求数据?[] 又是什么?
如何请求数据相关内容可以参考 How to fetch data with React Hooks 这篇文章,十分详细,而 []
表示 effect
没有使用任何 React
数据流里的值,因此该 effect
仅被调用一次是安全的
但是 []
同样也是一类常见问题的来源,就是我们以为没使用数据流里的值但其实使用了,所以我们需要采用一些策略(useReducer/useCallback
等)来移除这些 effect
依赖,而不是错误地忽略它们
是否应该把函数当做 effect 的依赖?
一般建议把不依赖 props
和 state
的函数提到相关组件外面,并且把那些仅被 effect
使用的函数放到 effect
里面,这样做了以后,如果我们的 effect
还是需要用到组件内的函数(包括通过 props
传进来的函数),可以在定义它们的地方用 useCallback
包一层,为什么要这样做呢?
因为这些函数可以访问到 props
和 state
,因此它们会参与到数据流中,更多相关内容可以参考官方提供的 FAQ
为什么有时候会出现无限重复请求的问题?
这个通常发生于我们在 effect
里做数据请求并且没有设置 effect
依赖参数的情况,如果没有设置依赖,effect
会在每次渲染后执行一次,然后在 effect
中更新了状态引起渲染并再次触发 effect
,这样就形成了无限循环,但是也可能是因为我们设置的依赖总是在改变
我们可以通过一个一个移除的方式排查出哪个依赖导致了问题,但是移除所使用的依赖(或者盲目地使用 []
)通常是一种错误的解决方式,举个例子,比如某个函数可能会导致这个问题,我们可以把它们放到 effect
里,或者提到组件外面,或者用 useCallback
包一层,或者使用 useMemo
等方式都可以避免重复生成对象
为什么有时候在 effect 里拿到的是旧的 state 或 prop?
effect
拿到的总是定义它的那次渲染中的 props
和 state
(这个我们会在下面看到),之所以这样涉及是能够避免 一些问题,对于某些场景来说,我们可以明确地使用可变的 ref
来保存一些值,更多相关内容可以参考官方的 FAQ
我们在上面简单的介绍了在使用 useEffect
过程当中会遇到的一些问题,以及简单的处理方式,下面我们就来深入的了解一下 useEffect
,看看这些问题出现的根本原因以及如何更为优雅的来解决它们,让我们先从渲染开始看起
渲染
我们先从一个简单是示例开始看起,也就是官方文档当中那个计时器的示例,我们稍微的调整一下,如下
1 | function App() { |
这里我们注意来看 {count}
当中的内容,这里的 count
并不是是 React
帮我们监听状态的变化并而自动更新的,它仅仅只是一个用于显示的数字,我们的组件在第一次渲染的时候,从 useState()
拿到 count
的初始值 0
,当我们调用 setCount(1)
的时候,React
会再次渲染组件,这一次 count
是 1
当我们更新状态的时候,React
会重新渲染组件,每一次渲染都能拿到独立的 count
状态,这个状态值是函数中的一个常量,所以下面的这行代码没有做任何特殊的数据绑定
1 | <p>点击了 {count} 次</p> |
它仅仅只是在渲染输出中插入了 count
这个数字,这个数字由 React
提供,当我们调用 setCount
的时候,React
会带着一个不同的 count
值再次调用组件,然后 React
会更新 DOM
以保持和渲染输出一致
这里关键的点在于任意一次渲染中的 count
常量都不会随着时间改变,渲染输出会变是因为我们的组件被一次次调用,而每一次调用引起的渲染中,它包含的 count
值独立于其他渲染(关于这个过程更深入的探讨可以参考 React as a UI Runtime)
所以在这里我们可以发现,其实 React
当中的每一次渲染都有它自己的 props
和 state
,有了这个了解以后我们接着往下看,那么事件处理函数呢?是否也是一样的呢?我们将上面的示例稍微调整一下,我们添加一个显示点击数的事件
1 | function App() { |
如果我们首先将 count
增加到 3
,然后在点击显示点击数的按钮,等待 3
秒以后可以发现结果是我们所设想的 3
,但是如果我们先将 count
增加到 3
,然后点击一下显示点击数的按钮,并且在定时器回调触发之前将 count
点击增加到 5
,那么此时弹出的结果会是多少呢?是 5
还是 3
呢?
答案是 3
,也就是这个值是我们点击时候的状态,所以在这里我们也可以发现,除开 props
和 state
,每一次渲染也都有它自己的事件处理函数(How Are Function Components Different from Classes 这篇文章当中探讨了具体原因),但它究竟是如何工作的呢?
我们发现 count
在每一次函数调用中都是一个常量值,也就是说我们的组件函数每次渲染都会被调用,但是每一次调用中 count
值都是常量,并且它被赋予了当前渲染中的状态值,但是这并不是 React
所特有的,普通的函数也有类似的行为
1 | function sayHi(person) { |
在上面这个例子中,外层的 someone
会被赋值很多次(就像在 React
中,当前的组件状态会改变一样),然后在 sayHi
函数中,局部常量 name
会和某次调用中的 person
关联,因为这个常量是局部的,所以每一次调用都是相互独立的,结果就是当定时器回调触发的时候,每一个 alert
都会弹出它拥有的 name
这就解释了我们的事件处理函数如何捕获了点击时候的 count
值,这是因为每一次渲染都有一个新版本的 handleAlertClick
,每一个版本的 handleAlertClick
都记住它自己的 count
,我们用伪代码表示如下
1 | function App() { |
在任意一次渲染中,props
和 state
是始终保持不变的,如果 props
和 state
在不同的渲染中是相互独立的,那么使用到它们的任何值也是独立的(包括事件处理函数),它们都属于一次特定的渲染,即便是事件处理中的异步函数调用所得到的的也是这次渲染中的 count
值
Effect
我们在上面探讨了 props/state
以及事件处理函数,那么 effect
呢?其实 effect
并没有什么两样,我们还是以上面的示例为例,如下
1 | function App() { |
结果可以发现,两处的 count
的值是同步修改的,那么我们可能会有些疑惑,那就是 effect
是如何读取到最新的 count
状态值的呢?其实这并不是 count
的值每次在 effect
当中发生了改变,而是 effect
函数本身在每一次渲染中都不相同,每一个 effect
版本当中的 count
值都来自于它属于的那次渲染,我们用伪代码表示如下
1 | function App() { |
React
会记住提供的 effect
函数,并且会在每次更改作用于 DOM
并让浏览器绘制屏幕后去调用它,所以虽然我们说的是一个 effect
(这里指更新 document
的 title
的操作),但其实每次渲染都是一个不同的函数,并且每个 effect
函数获取到的 props
和 state
都来自于它属于的那次特定渲染,概念上我们可以想象 effect
是渲染结果的一部分,它属于某个特定的渲染,就像事件处理函数一样
所以在这里我们也可以发现,即每次渲染也都有它自己的 effect
,但是如果我们将上面的示例调整为下面这样,结果又会是如何呢?
1 | function App() { |
如果我们点击了很多次并且在 effect
里设置了延时,那么输出的结果会是什么呢?结果可能会出乎我们的意料,我们可能认为是某一个固定的值,但是运行后却可以发现,它会按顺序的输出每一次点击所对应的值,至于原因,也正是我们之前提及过的,因为每一个 effect
都属于某次特定的渲染,这里我们就不得不和 Class
当中的 this.state
来进行简单的对比了,如下
1 | componentDidUpdate() { |
如果调整成生命周期的模式,运行以后可以发现,this.state.count
总是指向最新的 count
值,而不是属于某次特定渲染的值,如果我们在定时器回调结束之前点击了 5
次,那么我们可以看到 5
次输出结果,并且每次输出打印的结果都是 5
到目前为止,综合我们上面所有的发现,可以明确地得出,即每一个组件内的函数(包括事件处理函数,effect
,定时器或者 API
调用等等)都会捕获某次渲染中定义的 props
和 state
,所以下面的两个例子是相等的
1 | function App(props) { |
也就是说,在组件内什么时候去读取 props
或者 state
是无关紧要的,因为它们不会改变,在单次渲染的范围内,props
和 state
始终保持不变,当然有时候我们可能想在 effect
的回调函数里读取最新的值而不是捕获的值,最简单的实现方法是使用 refs
(可以参考 How Are Function Components Different from Classes)
不过不推荐去进行这样的操作,因为需要从过去渲染中的函数里读取未来的 props
和 state
,但是有时候可能也需要这样做
1 | function App() { |
这样操作以后,我们可以发现点击 5
次以后,会输出 5
次最后操作的值,与我们上面 this.state.count
的计算结果是一致的,所以可以发现,在 Class
组件中 React
正是这样去修改 this.state
的,不像捕获的 props
和 state
,我们没法保证在任意一个回调函数中读取的 latestCount.current
是不变的
Effect 中的清理
在 官方文档 当中曾经提及,有些 effect
可能需要有一个清理步骤,本质上它的目的是消除副作用,比如取消订阅,我们在之前也提到过,每一个组件内的函数(包括事件处理函数,effect
,定时器或者 API
调用等等)都会捕获某次渲染中定义的 props
和 state
有了这些概念以后,我们来思考下面的代码
1 | useEffect(() => { |
我们假设第一次渲染的时候 props
是 {id: 10}
,第二次渲染的时候是 {id: 20}
,我们可能会认为清理操作也是按顺序执行的,也就是首先清除了 {id: 10}
的 effect
,然后渲染了 {id: 20}
的 UI
,最后再次执行了 {id: 20}
的 effect
,但是事实并不是这样
React
只会在 浏览器绘制 后运行 effect
,这会使我们的应用更为流畅,因为大多数 effect
并不会阻塞屏幕的更新,effect
的清除同样被延迟了,上一次的 effect
会在重新渲染后被清除,也就是说首先会渲染 {id: 20}
的 UI
,然后浏览器进行绘制,所以我们在屏幕上可以看到 {id: 20}
的 UI
,最后才会按顺序依次去执行清除 {id: 10}
的 effect
和 {id: 20}
的 effect
这里可能有一点会让人疑惑的地方,那就是如果清除上一次的 effect
是发生在 props
变成 {id: 20}
之后,那它为什么还能获取到旧的 {id: 10}
呢?答案显而易见,effect
的清除并不会读取最新的 props
,它只能读取到定义它的那次渲染中的 props
值,也就是我们之前提到过的,它的伪代码会是下面这样的
1 | // First render, props are {id: 10} |
也就是说,第一次渲染中 effect
的清除函数其实只能看到 {id: 10}
这个 props
,这也正是为什么 React
能做到在绘制后立即处理 effect
,并且在默认情况下会使我们的应用运行更为流畅
同步
比如我们有下面这样一个组件
1 | function App({ name }) { |
我们首先渲染 <Greeting name="zhangsan" />
,然后渲染 <Greeting name="lisi" />
,和我们直接渲染 <Greeting name="lisi" />
其实并没有什么区别,因为在这两种情况中,我们最后看到的结果都是 Hello, lisi
React
会根据我们当前的 props
和 state
同步到 DOM
,所以我们应该以相同的方式去思考 effect
,只不过 useEffect
可以使我们能够根据 props
和 state
同步 React tree
之外的东西而已
1 | function App({ name }) { |
这与我们所熟知的 mount/update/unmount
等生命周期是有一定区别的,不过如果在每一次渲染后都去运行所有的 effect
可能并不高效(并且在某些场景下,它可能会导致无限循环),所以我们该怎么解决这个问题呢?在这种情况下,我们就需要告诉 React
去比对我们的 Effect
我们都知道,React
的 diff
算法只会更新 DOM
真正发生改变的部分,而不是每次渲染都大动干戈,所以我们也可以使用这种类似的方式来处理 effect
,比如下面这个示例
1 | function App({ name }) { |
我们的 effect
并没有使用 counter
这个状态,所以我们的 effect
只会同步 name
属性给 document.title
,但是 name
并没有变,所以在每一次 counter
改变后重新给 document.title
赋值并不是理想的做法,所以在这种情况下我们可以提供给 useEffect
一个依赖数组参数(deps
)
1 | useEffect(() => { |
如果当前渲染中的这些依赖项和上一次运行这个 effect
的时候值一样,因为没有什么需要同步,所以 React
会自动跳过这次 effect
,但是需要注意,即使依赖数组中只有一个值在两次渲染中不一样,我们也不能跳过 effect
的运行,要同步所有
但是在平时我们可能经常会遇到下面这样的用法,也就是我们只是想在挂载的时候运行它一次而已,所以我们只传递一个 []
1 | function App() { |
这样虽然在某些情况下可以解决问题,但是在官方的建议当中,如果我们设置了依赖项,effect
中用到的所有组件内的值都要包含在依赖中,这包括 props
,state
,函数,组件内的任何东西
有时候虽然我们也是这样操作的,但是却会遇到无限请求的问题,解决的方法当然不是移除依赖项,而是我们应该先尝试更好地理解这个问题,下面就让我们来深入的了解一下 effect
中的依赖项
依赖项
我们有没有考虑过,如果设置了错误的依赖会怎么样呢?我们都知道,如果依赖项包含了所有 effect
中使用到的值,React
就能知道何时需要运行它
1 | useEffect(() => { |
但是如果我们将 []
设为 effect
的依赖的话呢?可以发现,新的 effect
函数是不会运行的
1 | useEffect(() => { |
在这个例子当中,问题看起来显而易见,我们设置的依赖是 []
,依赖没有变,所以不会再次运行 effect
,也就是说运行了一次以后就停下来了,但是我们再来看看下面这个示例,情况就有些不太一样了
1 | function App() { |
针对于这个示例,我们依然设置依赖为 []
,因为直觉上我们就只想让其运行一次 effect
,也就是帮助我们开启定时器即可,但是运行后的结果可能会出乎我们的意料,这个例子只会递增一次以后就停止了,因为在第一次渲染中,count
是 0
,因此 setCount(count + 1)
在第一次渲染中等价于 setCount(0 + 1)
,但是同时我们设置了 []
依赖,effect
不会再重新运行,所以其实它后面每一秒调用的都是 setCount(0 + 1)
其实仔细观察是可以发现我们的 effect
是依赖 count
的(但是我们仅仅只传递了一个 []
),又因为 React
会对比依赖,而我们的依赖没有变,所以就不会再次运行 effect
,也就是说会跳过后面的 effect
,针对于上面这种情况,我们很容易的可以想到,我们直接在依赖中包含所有 effect
中用到的组件内的值不就可以了吗,我们来试试
1 | useEffect(() => { |
问题的确是解决了,因为现在依赖数组正确了,但是我们仔细观察,却发现它可能不是太理想,因为现在每次 count
修改以后都会重新运行 effect
,也就类似于下面这种情况
1 | // First render, state is 0 |
问题虽然解决了,但是可以发现我们的依赖是在一直发生着变化的,这就导致 effect
也会重新运行,也就是说我们的定时器会在每一次 count
改变后清除和重新设定,这明显这不是我们想要的结果,所以下面我们就来看看另外一种解决方案,也就是修改 effect
内部的代码以确保它包含的值只会在需要的时候发生变更,我们不再传递 []
这种错误的依赖,而是修改 effect
,让它的依赖变得更少
为了实现这个目的,我们需要问自己一个问题,那就是我们为什么要用 count
呢?可以看到我们只在 setCount
调用中用到了 count
,在这个场景中,我们其实并不需要在 effect
中使用 count
,之所以依赖它是因为我们在之前的 effect
中写了 setCount(count + 1)
,所以 count
成为了一个必需的依赖,但是其实我们真正想要的只是把 count
转换为 count + 1
,然后返回给 React
而已,所以我们需要做的仅仅只是告知 React
,让其去递增状态,而不用管它现在具体是什么值
在这种情况之下,我们就可以采用 Class
组件当中 setState()
的使用方式,因为在使用 setState()
的过程当中我们已经知晓,当我们想要根据前一个状态更新状态的时候,我们可以使用它的函数形式,这里当然也可以这么使用,如下
1 | useEffect(() => { |
现在我们将更新值的方式调整成了 setCount(c => c + 1)
,同时也将依赖数组设置为了 []
,再次运行后可以发现,程序可以正常运行,此时我们已经移除了依赖,我们的 effect
也就不再读取渲染中的 count
值了,而依赖为 []
,它没有变化,所以不会再次运行 effect
,尽管 effect
只运行了一次,但是在第一次渲染中的定时器回调函数可以完美地在每次触发的时候给 React
发送 c => c + 1
更新指令,它也不再需要知道当前的 count
值了,因为 React
已经知道了
然而,即使是 setCount(c => c + 1)
也并不完美,它看起来有点怪,并且非常受限于它能做的事,如果我们有两个互相依赖的状态,或者我们想基于一个 prop
来计算下一次的 state
,它并不能做到,但是幸运的是,setCount(c => c + 1)
有一个更强大的模式,那就是 useReducer
useReducer
我们来修改上面的例子让它包含两个状态 count
和 step
,我们的定时器会每次在 count
上增加一个 step
值
1 | function App() { |
这个例子目前的行为是修改 step
后会重启定时器,因为它是依赖项之一,也就是清除上一次的 effect
然后重新运行新的 effect
,但是如果我们不想在 step
改变后重启定时器,我们该如何从 effect
中移除对 step
的依赖呢?
当我们想更新一个状态的时候,并且这个状态更新依赖于另一个状态的值,这个时候我们就可以考虑使用 useReducer
去替换它们,reducer
可以让我们把组件内发生了什么(actions
)和状态如何响应并更新分开表述,所以我们用一个 dispatch
依赖去替换 effect
的 step
依赖
1 | const initialState = { |
因为 React
会保证 dispatch
在组件的声明周期内保持不变,所以在上面例子中我们也就不再需要重新订阅定时器
但是这里有一点需要注意的就是,我们可以从依赖中去除
dispatch
,setState
和useRef
包裹的值因为React
会确保它们是静态的,不过我们设置了它们作为依赖也没什么问题(也就是说上面的[dispatch]
可以简写为[]
)
在上面的示例当中,相比于直接在 effect
里面读取状态,它 dispatch
了一个 action
来描述发生了什么,这使得我们的 effect
和 step
状态解耦,我们的 effect
不再关心怎么更新状态,它只负责告诉我们发生了什么,更新的逻辑全都交由 reducer
去统一处理
在上面我们已经知道如何移除 effect
的依赖,不管状态更新是依赖上一个状态还是依赖另一个状态,但假如我们需要依赖 props
去计算下一个状态,该如何处理呢?办法当然是有的,那就是把 reducer
函数放到组件内去读取 props
1 | function Counter({ step }) { |
这种模式会使一些优化失效,所以我们应该避免滥用它,不过如果有需要完全可以在 reducer
里面访问 props
,但是可能会疑惑,为什么在之前渲染中调用的 reducer
可以获取到最新的 props
呢?答案是当我们在 dispatch
的时候,React
只是记住了 action
,它会在下一次渲染中再次调用 reducer
,在那个时候,新的 props
就可以被访问到,而且 reducer
调用也不是在 effect
里的
函数
一个典型的误解是认为函数不应该成为依赖,比如下面这个示例
1 | function App() { |
上面的代码虽然可以正常工作,但这样做在组件日渐复杂的迭代过程中我们很难确保它在各种情况下还能正常运行,如果我们的代码做下面这样的分离
1 | function App() { |
如果我们忘记去更新使用这些函数(很可能通过其他函数调用)的 effect
的依赖,我们的 effect
就不会同步 props
和 state
带来的变更,这当然不是我们想要的,幸运的是,对于这个问题有一个更为简单的解决方案,那就是如果某些函数仅在 effect
中调用,我们可以把它们的定义移到 effect
中
1 | function App() { |
这样一来,我们不再需要去考虑这些间接依赖,因为在我们的 effect
中确实没有再使用组件范围内的任何东西,但是有时候我们可能不想把函数移入 effect
里,比如组件内有几个 effect
使用了相同的函数,我们不想在每个 effect
里复制黏贴一遍这个逻辑,也或许这个函数是一个 prop
,比如下面这个示例,它就有两个 effect
会调用 getFetchUrl
的示例
1 | function App() { |
可以发现,我们的两个 effect
都依赖 getFetchUrl
,但是如果我们将 getFetchUrl
添加到依赖数组当中,因为它每次渲染的内容都不同,所以我们的依赖数组会变得无用,一个可能的解决办法是把 getFetchUrl
从依赖中去掉,但是这并不是很好的解决方式,这会使我们后面对数据流的改变很难被发现从而忘记去处理,这会导致类似于我们之前的定时器不更新值的问题
相反的,我们有两个更简单的解决办法,第一个就是,如果一个函数没有使用组件内的任何值,我们应该把它提到组件外面去定义,然后就可以自由地在 effect
中使用
1 | function getFetchUrl(query) { |
这样一来我们就不需要把它设为依赖,因为它们不在渲染范围内,因此不会被数据流影响,另外一种方式就是将其包装成 useCallback Hook
1 | function App() { |
useCallback
本质上是添加了一层依赖检查,它以另一种方式解决了问题,也就是我们使函数本身只在需要的时候才改变,而不是去掉对函数的依赖,这样一来如果 query
保持不变,getFetchUrl
也会保持不变,我们的 effect
也不会重新运行,但是如果 query
修改了,getFetchUrl
也会随之改变,因此会重新请求数据
同样的,对于通过属性从父组件传入的函数这个方法也适用
1 | function Parent() { |
因为 fetchData
只有在 Parent
的 query
状态变更时才会改变,所以我们的 Child
只会在需要的时候才去重新请求数据,使用 useCallback
,函数完全可以参与到数据流中,我们可以说如果一个函数的输入改变了,这个函数就改变了,如果没有,函数也不会改变
类似的,useMemo
可以让我们对复杂对象做类似的事情
1 | function App() { |
但是到处使用 useCallback
是件挺笨拙的事,当我们需要将函数传递下去并且函数会在子组件的 effect
中被调用的时候,useCallback
是很好的技巧,但总的来说 Hook
本身能更好地避免 传递回调函数
竞态
下面是一个典型的在 Class
组件里发请求的例子
1 | class App extends Component { |
仔细观察可能已经发现,上面的代码埋伏了一些问题,它并没有处理更新的情况,所以通常的解决方法是下面这样的
1 | class App extends Component { |
这显然好多了!但依旧有问题,有问题的原因是请求结果返回的顺序不能保证一致,比如我们先请求 {id: 10}
,然后更新到 {id: 20}
,但 {id: 20}
的请求更先返回,请求更早但返回更晚的情况会错误地覆盖状态值,这被叫做竞态,这在混合了 async/await
(假设在等待结果返回)和自顶向下数据流的代码中非常典型(props
和 state
可能会在 async
函数调用过程中发生改变)
effect
并没有神奇地解决这个问题,尽管它会警告我们让我们直接传了一个 async
函数给 effect
,但是如果我们使用的异步方式支持取消,那我们就可以直接在清除函数中取消异步请求,或者最简单的权宜之计是用一个布尔值来跟踪它
1 | function App({ id }) { |
关于如何处理错误和加载状态,以及抽离逻辑到自定义的 Hook
可以参考 在 React Hooks 中如何请求数据? 来了解更多