一、概述
useEffectEvent 这个 API 的学习稍微有点绕,因此需要一点学习成本。useEffectEvent 是为了解决 useEffect 中的依赖项问题而设计出来的。这基于一个重要的前提:React 官方团队认为,我们应该将所有在 useEffect 中使用到的 state 与 props 都作为 useEffect 的依赖项。包括我们用到的 linter 规则中,也会要求我们把所有在 useEffect 中使用到的 state 与 props 都作为 useEffect 的依赖项。否则就会爆出警告。当然,这种观点与真实情况的项目开发中存在差异,在过去,我们都会通过修改 linter 的规则来忽略这种警告。
但是实际情况是,这样做之后,一方面是有可能会导致依赖项变得更冗余,另外一方面,这些依赖项中还可能存在一些我们并不希望作为依赖项的值。主要有两种情况
1、我们需要依赖项的变化,去重新执行 useEffect 中的回调函数
2、我们不需要依赖项的变化,去重新执行 useEffect 中的回调函数
对于第一种情况,这是合理的。但是对于第二种情况,就有可能会出现一些意想不到的 bug 或者冗余的计算。
所以在过往的开发中,对于我个人而言,在使用 useEffect 时,都会非常谨慎的去设计 useEffect 的依赖项,去避免这些问题的存在。如果这样会存在问题,那么官方文档为什么还要建议我们把所有在 useEffect 中使用到的 state 与 props 都作为 useEffect 的依赖项呢?
二、闭包陷阱
在学习过 JS 核心进阶之后,我们都知道,如果我们在两个不同的函数作用域中,使用了同一个变量,那么此时就会形成闭包。由于函数组件本身是一个函数,如果我们在 useEffect 的回调函数中使用了组件内部声明的 state 或者 props,那么此时就会形成闭包。由于类似的语法大量存在,因此闭包的存在在 React 中会显得非常普遍。
当然,并不是有了闭包就会导致问题的发生,知道了这些基础知识之后,我们来通过一个案例,来讲解闭包陷阱是怎么回事。这和 useEffect 的底层实现有不可分割的关系。我们来看一下这个案例
import { useState, useEffect } from 'react';
import Button from 'components/ui/button';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + increment);
}, 1000);
return () => {
clearInterval(id);
};
}, []);
function incrementHandler() {
setIncrement(i => i + 1);
}
function decrementHandler() {
setIncrement(i => i - 1);
}
function resetHandler() {
setCount(0);
}
return (
<div className='p-4'>
<div className='flex items-center justify-between'>
<div className='text-2xl font-bold font-din'>
Counter: {count}
</div>
<Button onClick={resetHandler}>Reset</Button>
</div>
<hr />
<div className='flex items-center gap-2'>
Every second, increment by:
<Button disabled={increment === 0} onClick={decrementHandler}>–</Button>
<span className='text-lg font-din'>{increment}</span>
<Button onClick={incrementHandler}>+</Button>
</div>
</div>
);
}
该案例中,我们设计了一个定时器,定时器每秒钟执行一次,并以累加的方式更新 counter 的值。同时,我们设计了另外一个值,用于控制定时器每次累加的值。当我们点击按钮时,可以增加或者减少这个值。在代码中,我们使用 useEffect 来定义定时器。
// 堆代码 duidaima.com
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + increment);
}, 1000);
return () => {
clearInterval(id);
};
}, []);
这里注意观察依赖项,由于我们并不需要重复执行 useEffect 中的回调函数,仅需要在初始化时定义一下定时器即可,因此我们传入了一个空数组作为依赖项。这也就意味着,在后续的渲染过程中,useEffect 中的回调函数不会被重复执行。但是,这样的写法就会存在一个问题:当我们点击递增或者递减按钮时,increment 的值虽然会发生变化,但定时器的递增值却不会变化。他依然会按照初始化时的 increment 值进行累加。
这就是闭包陷阱。我们来仔细分析一下原因。首先,函数作用域 Timer 与 setInterval 的回调函数中,都使用了同一个变量 increment。因此,他们之间形成了闭包。而此时,由于 useEffect 的依赖项为空,因此 useEffect 的回调函数会在初始化之后,就被缓存到内存中。在后续组件的多次重新渲染中,该缓存函数会一直存在,并且不会被更新。因此,在初始化时,所形成的闭包环境是始终稳定的。
所以,每次 setInterval 的回调函数执行时,使用的 increment 值都是初始化时的值。尽管后续 increment 的值已经发生了变化,但闭包环境中的 increment 值并没有发生变化。因此,为了解决这个闭包陷阱的问题,我们只需要做一个很简单的修改,那就是破坏闭包环境的稳定性即可。也就是将依赖项设置为 increment。
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + increment);
}, 1000);
return () => {
clearInterval(id);
};
}, [increment]);
由于依赖项发生变化,此时每当 increment 的值发生变化时,useEffect 的回调函数都需要被重新执行。因此,每次函数组件重新执行时,useEffect 的回调函数都需要在依赖项发生变化时重新赋值,从而让每次执行过程中形成的闭包环境无法稳定保存下来,这样每次都会读取到最新的 increment 值。
三、新的问题
通过新增依赖项的方式,闭包陷阱的问题虽然解决了。但是,我们最开始之所以没有把 increment 设置为依赖项,是因为我们并不需要依赖项的变化,去重新执行 useEffect 中的回调函数。这也就意味着,我们希望 useEffect 中的回调函数在组件的整个生命周期中,只执行一次。第二个案例中新增加的问题就是,useEffect 的回调函数的多次执行违背了我们的初衷。带来的运行效果就是:当我们快速重复点击递增或者递减按钮时,Counter 值的变化会在点击的过程中停下来。这并不符合我们想要的效果。

原因就是 useEffect 的回调函数在多次重复执行过程中,会反复的取消与创建定时器。那到底要怎么办才能更好的解决这个问题呢?
四、状态的逻辑属性与 UI 属性
在过往的开发经验中,我深刻的明白,大多数朋友会滥用 useState。因此在 React 知命境界的教学中,我们一直强调要分清楚,你定义的这个变量,是状态值,还是逻辑值。
状态值:用于驱动 UI 的变化
逻辑值:不直接驱动 UI 的变化,而是参与逻辑运算
状态值我们用 useState 来定义。
逻辑值我们用 useRef 来定义。而如果此时,我们需要定义的这个变量,他既是状态值,又是逻辑值,事情就麻烦了。这就会非常容易导致闭包陷阱的产生。正如上面那个案例的 increment 变量,他既是状态值,又是逻辑值。我们希望他以逻辑值的身份参与到 useEffect 的回调函数中,而不是以状态值的身份去添加到依赖项中因此,在过往的解决方案中,我们为了绕开闭包陷阱,但是又不想把 increment 作为依赖项,我们就会把这个变量一分为二,分别定义一个状态值,一个逻辑值。
// 状态值驱动 UI 变化
const [increment, setIncrement] = useState(1);
// 逻辑值参与 useEffect 的回调函数逻辑运算
const incrementRef = useRef(1);
然后在更新时,保证状态值与逻辑值的同步更新。
setIncrement(i => i + 1);
incrementRef.current += 1;
这样,我们就可以保证在 useEffect 的回调函数中,使用的 increment 值始终是最新的值,又不用把 increment 作为依赖项。完整的代码如下所示
import { useState, useEffect, useRef } from 'react';
import Button from 'components/ui/button';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const incrementRef = useRef(1);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + incrementRef.current);
}, 1000);
return () => {
clearInterval(id);
};
}, []);
function incrementHandler() {
setIncrement(i => i + 1);
incrementRef.current += 1;
}
function decrementHandler() {
setIncrement(i => i - 1);
incrementRef.current -= 1;
}
function resetHandler() {
setCount(0);
}
return (
<div className='p-4'>
<div className='flex items-center justify-between'>
<div className='text-2xl font-bold font-din'>
Counter: {count}
</div>
<Button onClick={resetHandler}>Reset</Button>
</div>
<hr />
<div className='flex items-center gap-2'>
Every second, increment by:
<Button disabled={increment === 0} onClick={decrementHandler}>–</Button>
<span className='text-lg font-din'>{increment}</span>
<Button onClick={incrementHandler}>+</Button>
</div>
</div>
);
}
五、useEffectEvent
过往的这种解法,虽然完美的解决了问题,但是在写法上理解起来就有点抽象。明明是一个值,却要拆分成两个变量来定义,这就会导致理解成本的增加。甚至许多 React 开发者都没有听说过状态值和逻辑值的概念。因此,React 还需要一种更加直观的解决方案,来避免将变量一分为二。这就是 useEffectEvent。
我们可以把逻辑运算的部分,从 useEffect 的回调函数中抽离出来,并使用 useEffectEvent 来定义。这样,在 useEffect 中,我们也不需要把逻辑值作为依赖项了。完整的代码如下所示
import { useState, useEffect, useEffectEvent } from 'react';
import Button from 'components/ui/button';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const incrementEvent = useEffectEvent(() => {
setCount(c => c + increment);
});
useEffect(() => {
const id = setInterval(incrementEvent, 1000);
return () => clearInterval(id);
}, []);
function incrementHandler() {
setIncrement(i => i + 1);
}
function decrementHandler() {
setIncrement(i => i - 1);
}
function resetHandler() {
setCount(0);
}
return (
<div className='p-4'>
<div className='flex items-center justify-between'>
<div className='text-2xl font-bold font-din'>
Counter: {count}
</div>
<Button onClick={resetHandler}>Reset</Button>
</div>
<hr />
<div className='flex items-center gap-2'>
Every second, increment by:
<Button disabled={increment === 0} onClick={decrementHandler}>–</Button>
<span className='text-lg font-din'>{increment}</span>
<Button onClick={incrementHandler}>+</Button>
</div>
</div>
);
}
在 useEffectEvent 的回调函数中,我们能够读取到最新的状态值(state or props),因此我们可以放下闭包陷阱的担忧放心使用。
不过使用时我们需要注意如下几点
1、useEffectEvent 是抽取的 useEffect 回调函数中的逻辑,因此只能在 effects 内部调用,而不能在 effects 外部调用
2、建议仅用于处理一个状态同时具备状态值与逻辑值的情况,如果该变量只有逻辑值的身份,那么应该使用 useRef 来定义