Effect Hooks - useEffect
Side Effect
在编程中,Side Effect
(副作用)是一种描述函数或操作对其环境产生影响的术语。在纯函数式编程中,函数不应该有副作用,即它们只应根据输入值计算输出,而不修改任何外部状态。但在实际应用中,特别是在UI框架如React中,副作用是不可避免的,因为我们经常需要与浏览器环境交互,比如:
数据获取
:从服务器获取数据。订阅
:订阅某些事件源,如WebSocket连接、Redux store的变化等。手动修改DOM
:直接操作DOM元素。日志记录
:记录应用程序的运行信息。浏览器缓存
:使用localStorage或sessionStorage进行数据存储。定时任务
:设置setTimeout或setInterval。
useEffect语法
useEffect
语法如下:
useEffect(() => {// setup// ❓ optional clean upreturn () => {}}, [dependencies])
setup
: 处理 Effect 的函数cleanup?
: setup 函数选择性返回一个 清理(cleanup) 函数
dependencies?
: 依赖项数组(可选)
useEffect用法
不传递依赖项数组
如果不传递依赖项数组,则每次Rnder后都会执行effect
传递空的依赖项数组
当传递一个空的依赖数组时,effect 将只在Mount阶段执行一次,后续Render不再执行。
传递非空的依赖项数组
当传递一个非空的依赖数组时,仅在依赖项发生变动时执行effect
带cleanup函数
在上述所有示例中,我们都没有使用可选的cleanup,但在某些情况下,我们可能需要使用清理函数
假设我们有个需求:
- 开关打开时,监听鼠标移动
- 开关关闭时,不监听鼠标移动
拿到需求后我开发了第一个版本,但是我发现我只实现了需求1,当开关关闭时,页面仍在监听鼠标移动
仔细分析上面代码,能发现两个问题
- 开关关闭(tracking为false)后,因为上一次开关打开时注册的事件仍然存在,导致页面仍在打印鼠标的座标信息
- 开关再次打开时会重复注册
mousemove
事件
而使用cleanup是能够解决上面两个问题的
我们来分析下上面代码的执行逻辑
- 当组件挂载(第一次Render)时,effect执行,此时我们会注册事件监听,并返回一个cleanup
- 随着用户移动鼠标,
mousePosition
状态发生改变,重新触发Render,但mousePosition
并不时effect的依赖项,所以effect在这次Render中不会执行 - 用户点击按钮
tracking
状态由true变为false,又重新触发Render 且tracking
是effect的依赖项,所以effect会在这次Render中执行- 但在执行effect之前,React会先执行上一次effect执行时返回的cleanup(移除事件监听)
- cleanup执行之后,才会执行本次effect
- 因为此时
tracking
为fasle, effect具体逻辑会被if
忽略 - 每次点击开关都会触发上述操作
具体的操作顺序如下:
useEffect注意事项
非必要不用useEffect
当你需要基于props
或state
计算某些值时,不要把计算逻辑放到useEffect
中
function Dashboard({metrics}) {const [filteredMetrics, setFilteredMetrics] = useState([])useEffect(() => {const currentFilteredMetrics = filterMetrics(metrics)setFilteredMetrics(currentFilteredMetrics)}, [metrics])}
相反你可以放到render阶段, 好处就是代码量少,不易引发bug,且速度更快。
function Dashboard({metrics}) {const filteredMetrics = filterMetrics(metrics)}
避免无限循环
下面代码会造成无线循环:
- 首次Render后执行Effect
setViews
触发Re-Render user
对象重新创建,且user
是Effect的依赖项目,Effect再次执行, 又会执行setViews
, 再次触发Re-Render- 循环往复...
function App() {const [views, setViews] = useState(0)const user = {name: 'kevin', views: 5}useEffect(() => {setViews(prevState => prevState + user.views)}, [user])return <p>The count is: {views}</p>}
解决方案是: 使用useMemo
包裹user
对象,这将会阻止React在Re-Render时创建新的对象引用
另外注意,对于函数可以使用useCallback
去处理,用法类似
避免竞争条件
下面代码可能会出现以下现象:
- 第一次渲染时 userId = 1
- 第二次渲染时 userId = 2
- 第一次 fetch('/user/1') 耗时可能远远高于第二次 fetch('/user/2')
- 导致最终显示的是 userId为1的用户数据
export default function User({userId}) {const [userInfo, setUserInfo] = useState(null)useEffect(() => {const fetchUserInfo = async () => {const newUserInfo = await fetch(`/user/${userId}`)setUserInfo(newUserInfo)}fetchUserInfo()}, [userId])if(!userInfo) return null;return <div>{userInfo.name}</div>}
解决方案:使用AbortController
来取消旧的请求
export default function User({userId}) {const [userInfo, setUserInfo] = useState(null)useEffect(() => {const abortController = new AbortController()const fetchUserInfo = async () => {const newUserInfo = await fetch(`/user/${userId}`, {signal: AbortController.signal})setUserInfo(newUserInfo)}fetchUserInfo()return () => {AbortController.abort()}}, [userId])if(!userInfo) return null;return <div>{userInfo.name}</div>}
避免执行父组件传递的回调函数
比如:
const Dropdown = ({ onOpen, onClose }) => {const [isOpen, setIsOpen] = useState(false)useEffect(() => {if(isOpen) onOpen()onclose()}, [isOpen])return (<button onClick={() => setIsOpen(currState => !currState)}>Toggle dropdown</button>)}
解决方案: 在event handler中去处理
const Dropdown = ({ onOpen, onClose }) => {const [isOpen, setIsOpen] = useState(false)const toggleView = () => {const currIsOpen = !isOpensetIsOpen(currIsOpen)if(currIsOpen) onOpen()onclose()}return (<button onClick={toggleView}>Toggle dropdown</button>)}
自定义hook: useEffectAfterMount()
useEffect
会在首次渲染以及后续依赖发生变动时都会执行
我们自定义的useEffectAfterMount
会跳过首次渲染,仅在依赖项发生变动时执行。
import { useRef, useEffect } from 'react';export default function useEffectAfterMount(cb, deps) {const hasMounted = useRef(false)useEffect(() => {if(hasMounted.current) {return cb()}hasMounted.current = true}, [...deps, cb])}