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

相关推荐
wordbaby4 小时前
queries(查询)
前端·react.js
wordbaby4 小时前
important-defaults(重要的默认配置)
前端·react.js
余生H6 小时前
前端技术新闻(WTN-1):React.js & Next.js 爆出 CVSS 10.0 级严重漏洞,历史风险回顾与代码级深度分析
前端·javascript·react.js
古韵9 小时前
如何从Axios平滑迁移到Alova
vue.js·react.js·node.js
dorisrv9 小时前
React 状态管理:Zustand 快速上手指南
前端·react.js
前端老宋Running10 小时前
你的组件 API 为什么像个垃圾场?—— React 复合组件模式 (Compound Components) 实战教学
前端·react.js·架构
前端小臻10 小时前
react中的函数组件和类组件(快捷指令和区别)
前端·react.js·前端框架
用户81686947472510 小时前
beginWork 与 completeWork 的内部职责分工
前端·react.js
灰灰勇闯IT10 小时前
RN跨端适配与沉浸式体验:适配不同设备与系统
javascript·react native·react.js