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

186 阅读3分钟

问题代码

看一段因为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';
  };
}

var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);
// 在 React 组件中,如果`useEffect`的依赖数组为空(`[]`),它的行为类似于`makeSizer`只调用一次
  • 闭包捕获的是“变量的引用”,而不是“变量的值”

    • 闭包记住的是变量的地址,而不是变量的值。因此,如果变量的值发生了变化,闭包仍然可以通过地址访问到最新的值。
    • 但在useEffect的例子中,闭包捕获的v是组件作用域中的一个变量。由于useEffect只执行一次,闭包中的v始终引用的是组件第一次渲染时的v,而不是后续更新的v
  • useEffect的依赖数组为空

    • 由于依赖数组为空,useEffect只在组件加载时执行一次。因此,clickHandle函数只捕获了v的初始值(空字符串),而不会随着v的更新而更新。
  • v的更新触发重新渲染

    • v被更新时,组件重新渲染,v的值发生了变化。但clickHandle函数仍然引用的是组件第一次渲染时的v,而不是最新的v

产生场景

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

解决方法

要让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);
  };
}, []);