react开发中遇到的use Effect闭包陷阱

349 阅读3分钟

产生场景

  • 事件绑定。比如示例代码中,在页面最初渲染完成后只绑定一次事件的情况,比如使用echarts,在useEffect中获取echarts的实例并绑定事件
  • 定时器。页面加载后注册一个定时器,定时器内的函数也会产生如此的闭包问题。

问题代码(一)

看一段因为useEffect导致的闭包问题代码

const btn = useRef();
const [v, setV] = useState('');

useEffect(() => {
    let clickHandle = () => {
        console.log('v:', v);
    }
    btn.current.addEventListener('click', clickHandle)
    
    return () => {
        btn.current.removeEventListener('click', clickHandle)
    }
}, []);
    
const inputHandle = e => {
    setV(e.target.value)
}

return (
        <>
            <input value={v} onChange={inputHandle} />
            <button ref={btn} >测试</button>
        </>
    )

useEffect的依赖项数组为空,所以在页面渲染完成之后,内部代码只会执行一次,页面销毁再执行一次。此时在输入框中输入任意字符,再点击测试按钮,得到的输出为空,之后无论如何输入任何字符,再点击测试按钮时,输出的结果仍为空。

为什么会这样呢?其实就是闭包所造成的。

产生原因

  1. 闭包捕获了旧的v

    • useEffect内部定义的clickHandle函数是一个闭包,它捕获了组件作用域中的v变量。
    • 由于useEffect的依赖数组为空,它只在组件加载时执行一次。此时,v的初始值是空字符串''
    • 因此,clickHandle函数捕获的v始终是初始的空值,而不是后续更新的值。
  2. v的更新不触发useEffect重新执行

    • 当你在输入框中输入字符时,inputHandle函数调用setV更新v的值,这会触发组件重新渲染。
    • 但在重新渲染时,useEffect不会重新执行,因为依赖数组为空。因此,clickHandle函数仍然引用的是初始的空v
  3. 闭包中的v不是最新的

    • 闭包捕获的是变量的引用,而不是变量的值。因此,即使v的值在后续被更新,闭包中的v仍然指向v的初始引用,而这个引用的值已经被更新。
    • 但在这种情况下,v的值确实发生了变化,但闭包中的v仍然指向旧的引用,导致输出为空。

用更通俗易懂的闭包示例来理解

function makeSizer(size) {
  return function() {
    document.body.style.fontSize = size + 'px';
    console.log(size)
  };
}

var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);

makeSizer(size) 返回的函数内部会捕获 size 参数。当调用 size12() || size14() || size16() 时,返回的闭包始终记住 size 是 12/14/16,哪怕外部再有 size 变量变化,这个闭包里的 size 永远是最初传入的值。

解决方法

要让clickHandle能够访问最新的v值,可以采取以下两种方法:

方法一:将v添加到useEffect的依赖数组中
useEffect(() => {
  let clickHandle = () => {
    console.log('v:', v);
  };
  btn.current.addEventListener('click', clickHandle);

  return () => {
    btn.current.removeEventListener('click', clickHandle);
  };
}, [v]); // 将v添加到依赖数组中    
  • 这样,每当v的值发生变化时,useEffect会重新执行,重新定义clickHandle函数,使其引用最新的v值。

方法二:使用useRef保存v的最新值

const btn = useRef();
const [v, setV] = useState('');
const vRef = useRef(v);

useEffect(() => {
  let clickHandle = () => {
    console.log('v:', vRef.current);
  };
  btn.current.addEventListener('click', clickHandle)
   return () => {
    btn.current.removeEventListener('click', clickHandle);
  };
}, []);

问题代码(二)

useEffect(() => {
  if (aiLoading) {
    fetchAiResultMutation.mutate(
      { ... },
      {
        onSuccess: res => {
          console.log('AI动画', aiLoading);
          handleTypedText(res?.data?.output);
        },
      }
    );
  }
}, [aiLoading]);

遇到的问题:

  • 当点击触发AI生成,把aiLoading设为true,接口开始请求。
  • 接口请求时间比较长,比如3s,此时如果切换用户,aiLoading设为false,但请求还在进行。
  • 等fetchAiResultMutation接口返回,onSuccess回调被执行,打印的aiLoading依然是true

原因分析

  • useEffect中,fetchAiResultMutation.mutate被调用时,传入的onSuccess回调会捕获当时的aiLoading状态。即使接口在调用期间不断地切换用户,把aiLoading设为false,但onSuccess回调是在mutate调用那一刻“快照”捕获的上下文。
  • 这里的onSuccess函数在mutate触发的一刻就已经绑定了当时的aiLoading值(true),即使后续setAiLoading(false),回调里的aiLoading依然是true。

解决方法

用 useRef 保存最新值