在 react 开发中, useEffect
是我们经常会使用到的钩子,一个基础的例子如下:
useEffect(() => {// some code here// cleanup 函数return () => {doSomething()}
}, [dependencies])
上述代码中, cleanup
函数的执行时机有如下两种:
- 组件卸载时会执行一次
- 每个依赖项(dependencies)变更后,页面重新渲染前会执行一次
在日常的开发中,我们很多人都会忽略掉 cleanup
函数,在一般情况下,不写 cleanup
函数不会有什么问题。但如果我们在 useEffect
中使用了定时器或者进行了网络请求,这种情况下,如果不在 cleanup
函数中执行一些清除逻辑,会导致一些潜在的 bug。下面来聊聊如何使用 cleanup
函数解决内存占用
和网络请求竞态
问题.
不在 cleanup 函数中清除旧定时器会发生什么?
我们使用 interval 写一个计数器例子,count 从 0 开始,每秒递增 1,如下:
function Counter() {let [count, setCount] = useState(0)useEffect(() => {console.log('effect')setInterval(() => {setCount(count + 1)}, 1000)}, [count])return (<div><div>{count}</div></div>)
}
上述例子,一开始递增到前面几个数字,页面看起来是正常的,但是越往后,就会发现,数字在闪烁,而且之后页面会卡住,一段时间后的结果如下:
上图中,count 递增到 15 ,但 useEffect
却运行了 95 次,也就是,此时页面上已经有了 95 个定时器,越往后,定时器越多,为什么会这样?正常的结果应该是 count 增加到多少, useEffect
就运行多少次 (除了初始渲染运行的那一次),因为我们把 count 作为 useEffect
的依赖项。
导致上述问题的根本原因是我们没有清除旧的定时器,count 发生变化时,组件会重新渲染, useEffect
重新运行,会创建一个新的定时器,而旧的定时器并没有被清除,导致多个定时器同时存在,占用了更多的内存,这就是后续页面卡住的原因。
而页面上的数字闪烁的问题是因为每次组件重新渲染时,都会重新调用 useEffect
,导致新的定时器开始运行,而旧的定时器还在继续工作。这样就会出现两个定时器交替执行的情况,导致数字的变化不稳定,造成闪烁的效果。
解决办法就是在 cleanup 函数中清除旧的定时器,代码如下:
function Counter() {// ...useEffect(() => {console.log('effect')const interval = setInterval(() => {setCount(count + 1)}, 1000)return () => {clearInterval(interval)}}, [count])// ...
}
清除旧的定时器之后,页面的表现就符合预期了,每次重新渲染,页面上有且只有一个定时器。
除了定时器这种场景,其他场景,如订阅、使用 IntersectionObserver 观察者,都需要注意在 cleanup
函数中执行对应的清除逻辑,以避免内存泄露问题。
cleanup 函数解决网络请求问题
case1:离开页面时请求未完成
一个在 useEffect
中进行网络请求例子如下:
function List() {const [lists,setLists] = useState([])useEffect(() => {fetch('https://jsonplaceholder.typicode.com/todos').then(res => res.json()).then(data => {alert('请求完成')setLists(data)})}, [])return (<div>{lists?.map((item) => (<p key={item.id}>{item.title}</p>))}</div>)
}export default List
上述代码是一个列表组件,通过 fetch 请求获取列表数据,最终渲染在页面上,这看起来没什么问题,我们平常就是这么写的。
我们增加一个 Home 组件,功能很简单,就是从 Home 页面跳转到上述的 List 页面,代码如下:
import { Link } from 'react-router-dom'function Home() {return (<div><Link to="/list">Go to lists</Link></div>)
}export default Home
我们从 Home 页面跳转到 List 页面,然后 List 页面开始请求 list 列表数据。跳到 List 页面后,在请求完成之前通过浏览器的回退按钮回到 Home 页面,交互过程如下:
浏览器后退按钮取消请求
视频中,我们把网络设置成弱网环境,在 List 页面的 fetch 请求还没完成,就回到了 Home 页面,我们期望 then 回调里的代码不应该执行,因为离开了 List 页面后,List 组件相当于销毁了,不应该再继续执行组件代码,然而结果却是执行了。
造成上述结果的原因是:fetch 请求是异步的,进入 List 页面请时求已经发出,离开 List 页面没有取消请求,所以请求继续进行,完成后就会执行 then 回调里的代码。
解决办法如下:
// ...
useEffect(() => {let isCancelled = falsefetch('https://jsonplaceholder.typicode.com/todos').then(res => res.json()).then(data => {if (!isCancelled) {alert('请求已完成')setLists(data)}})return () => {isCancelled = true}
}, [])
// ...
上述代码中,我们定义了一个标志位变量:isCancelled,表示当前请求是否需要取消(这里并不是真正的取消请求,如何取消请求下文会讲到),初始化为 false,在 then 回调里,只有判断请求不需要取消才会执行后续代码。最后,重点来了,前面讲到 cleanup
函数的执行时机之一是在组件销毁时,所以在离开 List 页面时,我们把 isCancelled 设置为 true,那么后续请求完成时,就不会进入 if (!isCancelled) {} 条件语句中。
case2:同时触发多个相同请求,网络竞态问题
定义一个 User 组件,如下:
import { useState, useEffect } from "react";
import {useLocation,Link} from 'react-router-dom'function User() {const [curUser,setCurUser] = useState({})const route = useLocation()const id = route.pathname.split('/')[2]useEffect(() => {fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then(res => res.json()).then(data => {setCurUser(data)})}, [id])return (<div style={{display: 'flex',flexDirection: 'column',alignItems: 'center'}}><p>name: {curUser.name}</p><p>username: {curUser.username}</p><p>email: {curUser.email}</p><Link to='/users/1'>fetch user 1</Link><Link to='/users/2'>fetch user 2</Link><Link to='/users/3'>fetch user 3</Link></div>)
}export default User
上述是一个获取用户信息并展示在页面上的例子,我们点击 Link 组件,更新路由中的用户 id,在 useEffect
中根据 id 去请求用户数据。在网络正常的情况下,我们快速切换用户 id,页面上的用户信息就会跟着改变,这看起来没什么问题。如果我们把网络设置成弱网环境,同样的操作结果如下:
网络请求竞态视频1
在上述视频中,当前用户是 user1,我们快速点击 fetch user2、fetch user3,并最终停留在 user3,正常情况下,最终页面展示的应该是 user3 的信息,但是实际结果是:先展示 user2 的信息,然后再展示 user3 的信息。
上述问题其实涉及到了网络竞态问题。网络竞态是:用户触发同一个请求多次,由于网络的波动,每个请求的响应时间是不一样的,最先触发的请求可能是最后一个返回响应的,最后触发的请求也可能是最先返回响应的,最终所有请求完成后,我们应该使用哪个请求的响应作为最终的数据结果?。答案使用最后一个触发的请求的响应作为最终的结果,因为这是最新的,最具时效性的数据。
在上述 fetch user 的例子中,我们应该丢弃 fetch user2 的响应,因为我们最后操作的是 fetch user 3,页面最终应该展示的是 user3 的数据。我们同样使用 cleanup
函数来解决网络竞态问题,写法和 case1 类似,如下:
// ...
useEffect(() => {let isCancelled = falsefetch(`https://jsonplaceholder.typicode.com/users/${id}`).then(res => res.json()).then(data => {if (!isCancelled) {setCurUser(data)}})return () => {isCancelled = true}}, [id])
// ...
前面讲到, cleanup
函数的另一个执行时机是:每个依赖项变更后,页面重新渲染前运行一次,所以我们快速点击 fetch user2、fetch user3,id 从 2 到 3,那么id 为 2 时对应的 isCancelled 变量被设置为了 true,所以此时就不会进入 if (!isCancelled) {} 条件语句中执行 setCurUser(data),那么 id 为 2 的这条响应就被丢弃了,最终页面就不会先展示 user2 的信息再展示 user3 的信息。最终结果视频所示:
网络请求竞态视频2
拓展
前面我们讲了如何借助 cleanup
函数来丢弃响应,但可能很多时候,对于无效的请求,我们希望能取消它,对于 fetch 请求,我们借助 fetch 的第二个参数中 signal 和 AbortController 实现,如下:
// ...
useEffect(() => {let controller = new AbortController()let signal = controller.signalfetch(`https://jsonplaceholder.typicode.com/users/${id}`,{signal}).then(res => res.json()).then(data => {setCurUser(data)})return () => {// 在 cleanup 函数中终止当前请求controller.abort()}
}, [id])// ...
结果如下:
取消网络请求
在上述视频中,我们看到在点击 fetch user3 之后,fetch user2 的请求状态变成了已取消。
在平时的开发中,我们一般会用 axios 进行请求,下面是 axios 取消请求的例子:
import axios from 'axios'
// ...
useEffect(() => {const CancelToken = axios.CancelTokenconst source = CancelToken.source()axios.get(`https://jsonplaceholder.typicode.com/users/${id}`,{cancelToken: source.token}).then(res => {setCurUser(res.data)})return () => {source.cancel()}
}, [id])// ...