🕳️ React 避坑指南:"闭包陷阱"

写在前面:如果你是 React 新手,或者刚从 Class 组件转到 Hooks,这篇文章或许可以帮你省下几根头发。
广告植入:欢迎访问我的个人网站:hixiaohezi.com


案发现场

事情发生在两年前的一个周五下午(是的,墨菲定律通过不缺席),当时我正在写一个极其简单的功能:倒计时

需求很简单:用户点击按钮开始倒计时 60 秒,每秒更新页面上的数字,倒计时结束后自动重置。

作为一名老菜鸟,我脑子一扔,直接敲下了如下代码:

tsx 复制代码
function Timer() {
  const [count, setCount] = useState(60);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前倒计时:', count); // 用于调试
      
      if (count > 0) {
        setCount(count - 1); // 逻辑很完美对吧?
      } else {
        clearInterval(timer);
      }
    }, 1000);

    // 清理定时器
    return () => clearInterval(timer);
  }, []); // 空依赖数组,因为我只想让定时器启动一次

  return <div>倒计时:{count} 秒</div>;
}

我保存了代码,刷新了浏览器,准备提交代码然后下班跑路。

诡异的现象

屏幕上的数字从 60 变成了 59。 然后... 就定格在了 59

但我打开控制台一看,控制台在疯狂输出:

makefile 复制代码
当前倒计时: 60
当前倒计时: 60
当前倒计时: 60
...

What???

我的 count 已经变成 59 了啊(页面都变了),为什么 setInterval 打印出来的还是 60?为什么它一直在重复 60 - 1 = 59 这个动作,却再也下不去了?

我当时的第一反应是:

  1. React 坏了?
  2. 浏览器坏了?
  3. 宇宙射线干扰了 CPU?

唯独没想过是自己菜。

破案:该死的"闭包"

在对着屏幕发呆了 n 分钟,查阅了无数 StackOverflow 之后,我终于明白了真相。

这个坑的名字叫:Stale Closure (陈旧闭包 / 僵尸闭包)

简单用人话解释一下:

当你写下 useEffect(..., []) 且依赖数组为空时,这个 Effect 只会在组件挂载时执行一次 。 这时候,setInterval 被创建了。它捕获了当时 环境下的 count 变量。 当时count 是 60。

无论组件后来重新渲染了多少次,无论页面上的 count 变成了 59、58 还是 0,定时器里的那个回调函数,依然是第一次创建时的那个函数 。 在那个函数"冻结"的记忆里,count 永远是 60。

所以它每一秒都在做同一件事:

"噢,现在 count 是 60,我要把它变成 59。"

像不像一条只有 7 秒记忆的鱼?

解决方案

找到了原因,解决就很简单了。这里提供两种方案,但我强烈推荐第一种。

方案一:函数式更新 (推荐 ✅)

这是最优雅的解法,不需要重置定时器。

tsx 复制代码
useEffect(() => {
  const timer = setInterval(() => {
    // 重点在这里!!!
    // prevCount 是 React 传进来的最新值,不依赖外部闭包
    setCount((prevCount) => {
      if (prevCount <= 1) {
        clearInterval(timer);
        return 0;
      }
      return prevCount - 1;
    });
  }, 1000);

  return () => clearInterval(timer);
}, []); // 依然可以是空数组

为什么有效? :因为 setState 如果接收一个函数,React 会保证把最新的 状态值传给你。你不需要读取外部的 count 变量,从而绕过了闭包陷阱。

方案二:useRef 大法 (万能 ✅)

如果你不仅要更新状态,还要在定时器里读取 最新的 props 或其他状态做判断,useRef 是救命稻草。

tsx 复制代码
const countRef = useRef(count);

// 每次渲染都更新 ref.current
useEffect(() => {
  countRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setInterval(() => {
    // ref.current 永远是最新的,因为它是一个引用对象
    if (countRef.current > 0) {
      // do something...
    }
  }, 1000);
}, []);

如果这个坑你也踩过,握个爪。

最后,再加个广告,欢迎访问我的个人网站:👉 hixiaohezi.com

(这里是我的个人网站,虽然没有 Hooks 那么复杂,但绝对真诚)

祝大家的代码永远没有 Bug,只有 Feature!

相关推荐
摘星编程1 小时前
OpenHarmony + RN:Bluetooth连接蓝牙外设
react native·react.js·harmonyos
摘星编程3 小时前
在OpenHarmony上用React Native:ActionSheet确认删除
javascript·react native·react.js
摘星编程4 小时前
OpenHarmony环境下React Native:Zustand持久化存储
javascript·react native·react.js
摘星编程4 小时前
在OpenHarmony上用React Native:Recoil选择器异步数据
javascript·react native·react.js
Marshmallowc8 小时前
React 合成事件失效?深度解析 stopPropagation 阻止冒泡无效的原因与 React 17+ 事件委派机制
前端·javascript·react.js·面试·合成事件
Jinuss9 小时前
源码分析之React中createFiberRoot方法创建Fiber根节点
前端·javascript·react.js
Jinuss10 小时前
源码分析之React中ReactDOMRoot实现
前端·javascript·react.js
摘星编程10 小时前
React Native鸿蒙版:React Query无限滚动
javascript·react native·react.js
摘星编程13 小时前
React Native鸿蒙:TabBar自定义图标样式
react native·react.js·harmonyos
智绘前端13 小时前
React 组件开发速查卡
前端·react.js·前端框架