别再说react闭包问题是react的错了

2,287 阅读4分钟

难道是 React 的问题?深入理解函数组件中的闭包陷阱

在 React 函数组件开发中,我们常常会遇到所谓的“闭包问题”。最经典的案例之一就是延迟执行时访问的状态不是最新值

9.gif

在这个例子中,当我们点击“延迟弹出”按钮并启动一个 3 秒后执行的 alert 时,如果在此期间不断点击让 count 自增,最终弹窗显示的仍然是初始值(如 0)。

这真的是 React 的问题吗?其实不然。要理解这一现象,我们需要明确两个核心概念:

  1. 函数是特殊的对象
  2. 函数的作用域是静态作用域(词法作用域)

一、函数是特殊的对象

JavaScript 中的函数不仅仅是可执行的逻辑单元,它还是一个对象,拥有属性和内部槽(internal slots)。其中,[[Scopes]] 属性记录了函数创建时所处的词法环境,也就是它能访问的所有变量的集合。

我们可以通过改造 handleClick 来验证这一点:

const handleClick = () => {
  const fn = () => {
    alert(count);
  };
  console.dir(fn);
  setTimeout(fn, 3000);
};

查看控制台输出的 fn 对象,可以看到其 [[Scopes]] 中捕获了当前作用域下的 count 变量:

image.png

此时 count 的值为 0,因此 fn 的闭包环境就固定了这个值。

二、静态作用域:闭包的根源

JavaScript 采用静态作用域(也称词法作用域),即函数的作用域在定义时就已经确定,不会因为调用时的环境而改变。

当我们点击 setCount 更新状态时,整个 Test 函数组件会重新执行(re-render),生成新的作用域。但之前通过 setTimeout 注册的 fn 函数,仍然是第一次渲染时创建的函数实例,它所引用的 count 依然是旧作用域中的值。

为了更直观地观察这一点,我们可以延迟打印 fn 的信息:

const handleClick = () => {
  const fn = () => {
    alert(count);
  };
  setTimeout(() => {
    console.dir(fn);
    fn();
  }, 3000);
};

image.png

可以看到,3 秒后执行时,fn[[Scopes]] 依然是第一次渲染时的环境,count 的值没有更新。

三、解决方案:使用 useRef 打破闭包限制

既然问题出在闭包捕获了旧值,那么解决方案就是使用一个在整个组件生命周期中保持不变的引用——useRef

useRef 返回的对象在组件的整个生命周期中是同一个引用,其 .current 属性可以随时读取和修改,且不会触发重新渲染。

const Test = function () {
  const [update, setUpdate] = useState(0); // 用于触发重渲染
  const count = useRef(0); // 使用 ref 存储可变值

  const handleClick = () => {
    const fn = () => {
      alert(count.current); // 读取最新值
    };
    setTimeout(() => {
      console.dir(fn);
      fn();
    }, 3000);
  };

  const handleAddClick = () => {
    count.current += 1;
    setUpdate(!update); // 触发 UI 更新
  };

  return (
    <>
      <button onClick={handleAddClick}>{count.current}</button>
      <button onClick={handleClick}>弹出</button>
    </>
  );
};

10.gif

此时,无论何时点击“延迟弹出”,3 秒后弹窗显示的都是最新的 count.current 值。

查看 fn[[Scopes]]

image.png

虽然 count 的引用没有变,但通过 .current 可以访问到最新的值,实现了“实时”读取。

核心结论:React 的“闭包问题”本质上是 JavaScript 函数作用域机制与函数组件频繁重渲染共同导致的结果,而非 React 本身的缺陷。

四、谨慎使用 useCallback:避免过时的函数引用

由于闭包的存在,useCallback 的使用需要格外谨慎。如果依赖项数组(dependency array)未正确设置,可能会缓存一个捕获了过时状态的函数。

案例:空依赖数组导致状态滞留

const Test = function () {
  const [count, setCount] = useState<number>(0);

  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, []); // 空依赖数组

  return <div onClick={handleClick}>click me {count}</div>;
};

11.gif

现象:无论点击多少次,count 最多只增加 1。

原因useCallback 在第一次渲染时创建了一个函数,该函数捕获了 count 的初始值 0。由于依赖数组为空,这个函数在整个组件生命周期中都不会更新,因此每次执行都是 setCount(0 + 1)

我们可以通过命名函数并打印其作用域来验证:

const handleClick = useCallback(function a() {
  console.dir(a); // 查看函数 a 的 [[Scopes]]
  setCount(count + 1);
}, []);

image.png

可以看到,函数 a 的作用域中 count 始终为 0

正确做法

  • 正确设置依赖项:如果函数依赖 count,应将其加入依赖数组:
    const handleClick = useCallback(() => {
      setCount(count + 1);
    }, [count]);
    
  • 使用函数式更新:避免依赖具体状态值:
    const handleClick = useCallback(() => {
      setCount(prev => prev + 1);
    }, []); // 此时可以安全使用空依赖
    
  • 使用高级 Hook:如 ahooks 提供的 useMemoizedFn,它能自动处理函数更新,避免闭包陷阱。

总结:理解闭包机制是掌握 React 函数组件的关键。合理使用 useRefuseCallback,并注意依赖项的管理,才能写出高效、无 bug 的 React 应用。