🕳️ 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!

相关推荐
xiaoxue..19 分钟前
React Hooks :useRef、useState 与受控/非受控组件全解析
前端·react.js·前端框架
C_心欲无痕1 小时前
react - createPortal魔法传送门
javascript·vue.js·react.js
沛沛rh452 小时前
React 学习笔记:State、hook —— 组件的记忆
前端·javascript·react.js
C_心欲无痕10 小时前
react - useImperativeHandle让子组件“暴露方法”给父组件调用
前端·javascript·react.js
web小白成长日记15 小时前
深入理解 React 中的 Props:组件通信的桥梁
前端·javascript·react.js
烟袅18 小时前
React 表单的控制欲:什么时候我们真得控制它了,什么时候该放养了?
前端·react.js
鱼鱼块1 天前
彻底搞懂 React useRef:从自动聚焦到非受控表单的完整指南
前端·react.js·面试
韭菜炒大葱1 天前
React Hooks :useRef、useState 与受控/非受控组件全解析
前端·react.js·前端框架
MoonPointer-Byte1 天前
MoonReader:基于 SpringBoot 3.4 & React 的沉浸式协作阅读平台
spring boot·后端·react.js
怕浪猫1 天前
第一章 JSX 增强特性与函数组件入门
前端·javascript·react.js