返回

React useEffect 定时器: 为何 useRef 优于普通变量?

javascript

解密 React 中 useEffect 与 setInterval 的诡异行为:useRef 为何胜出?

写 React 代码时,咱们经常会碰到需要在 useEffect 里搞点定时任务,比如用 setInterval 做个倒计时。通常这挺简单的,但有时一些小细节没注意,就会冒出些让人挠头的怪事。

这次咱们就来聊聊一个经典场景:为啥用 useRef 来存 setInterval 返回的 ID 能正常清除定时器,而用一个普通的 let 变量就不行了?

问题复现:两种写法的不同结局

先来看能正常工作的代码,这里用了 useRef:

import React, { useRef, useState, useEffect } from 'react';

function Parent() {
  const [count, setCount] = useState(5);
  // 使用 useRef 来保存 interval ID
  const intervalRef = useRef(null); // 初始化为 null 或 0 都可以

  const countdown = () => {
    // 先清除可能已存在的定时器,防止重复启动
    if (intervalRef.current) {
        clearInterval(intervalRef.current);
    }
    
    // 启动新的定时器,并将 ID 存入 ref.current
    intervalRef.current = setInterval(() => {
      setCount((c) => {
        // 使用回调函数形式更新 state,确保拿到最新的 count 值
        // 并且在 count <= 1 时就停止,避免出现 0 到 -1 的瞬间
        if (c <= 1) {
            clearInterval(intervalRef.current); // 直接在 interval 内部判断并清除
            return 0; // 最终停在 0
        }
        return c - 1;
      });
    }, 1000);
  };

  // 组件卸载时,确保清除定时器,防止内存泄漏
  useEffect(() => {
    // 返回一个 cleanup 函数
    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, []); // 空依赖数组,表示这个 effect 只在挂载和卸载时运行

  // 注意:原代码中依赖 [count] 的 useEffect 清除逻辑可以移到 setCount 回调或者保留,
  // 但更推荐在 unmount 时和启动新 interval 前清除。
  // 如果仍需根据 count 值在外部清除,可以保留下面这个 effect,但上面 unmount 清理是必须的。
  /*
  useEffect(() => {
    if (count < 1 && intervalRef.current) { // 确保 intervalRef.current 有值再清除
        clearInterval(intervalRef.current);
    }
  }, [count]); 
  */

  return (
    <>
      <h3>Timer : {count}</h3>
      <br />
      <button onClick={countdown}>countdown</button>
    </>
  );
}

export default Parent;

这段代码跑起来没毛病,倒计时到 0 就停了。

接下来,看看把 useRef 换成普通 let 变量的版本:

import React, { useState, useEffect } from 'react';

function Parent() {
  const [count, setCount] = useState(5);
  // 使用普通变量尝试保存 interval ID
  let hold = 0; // 或者 null

  const countdown = () => {
    // 每次点击,尝试用 hold 变量存储 interval ID
    hold = setInterval(() => {
        console.log('Interval is running, current hold:', hold); // 方便调试
        setCount((c) => {
           if (c <= 1) {
               // 问题来了:这里的 hold 可能不是我们期望的那个 ID
               clearInterval(hold); 
               return 0;
           }
           return c - 1;
        });
    }, 1000);
    console.log('Interval started, hold assigned:', hold); // 记录刚生成的 ID
  };

  useEffect(() => {
    // 当 count 变化时,这个 effect 会执行
    console.log('Effect running, current hold value:', hold); // 查看 effect 执行时的 hold 值
    if (count < 1) {
      // 关键:这里的 hold 几乎总是 0(或其他初始值)!
      clearInterval(hold); 
      console.log('Trying to clear interval with hold:', hold);
    }
  }, [count]); // 依赖 count

  // 组件卸载时的清理同样重要,但这里也会遇到同样的问题
  useEffect(() => {
    return () => {
        // 卸载时,hold 的值同样是最后一次渲染时 re-initialize 的值
        console.log('Component unmounting, clearing hold:', hold);
        clearInterval(hold);
    }
  }, []);


  return (
    <>
      <h3>Timer : {count}</h3>
      <br />
      <button onClick={countdown}>countdown</button>
    </>
  );
}

export default Parent;

一运行这段代码,你会发现倒计时根本停不下来,clearInterval(hold) 似乎没起作用。为啥呢?明明看着很像普通 JavaScript 的逻辑啊。

深入探究:为何普通变量 hold 失效?

这背后的原因,和 React 函数组件的运行机制以及变量的生命周期息息相关。

  1. 函数组件的重新渲染 (Re-renders)
    在 React 里,当组件的 state (比如这里的 count) 或者 props 发生变化时,React 会重新调用(执行)这个函数组件,来计算新的 UI 输出。这个过程叫做重新渲染。

  2. 普通变量的“宿命”
    每次函数组件重新执行时,函数内部用 letconst 声明的普通变量(比如 let hold = 0;)都会被 重新创建和初始化 。它们不记事儿,每次渲染都是一个全新的开始。

  3. 一步步看 hold 变量的“悲剧”

    • 首次渲染 : Parent 组件执行,let hold = 0; 执行。hold0
    • 点击按钮 : countdown 函数执行。setInterval 启动,返回一个定时器 ID (比如 123)。hold = 123; 这句执行了。但是 ,这个 hold 变量是属于 这次特定渲染countdown 函数闭包里的。
    • setInterval 回调触发 : 一秒后,setInterval 的回调函数执行,调用 setCount((c) => c - 1)
    • 状态更新,触发重渲染 : setCount 更新了 count 状态 (从 5 变成 4)。React 检测到状态变化,于是 重新执行整个 Parent 函数
    • 再次渲染 : Parent 函数从头开始跑。关键来了:let hold = 0; 又一次执行,hold 被重置回 0
    • useEffect 执行 : 因为 count 变了(从 5 到 4),依赖于 [count]useEffect 会在这次渲染完成后执行。它去读取 hold 变量的值。这时候它读到的 hold 是多少?是 刚刚被重置的那个 0 ,而不是按钮点击时存下的 123
    • 无效的清除 : clearInterval(hold) 实际上执行的是 clearInterval(0)0 不是一个有效的定时器 ID,所以啥也没发生。定时器(ID 为 123 的那个)还在后台欢快地跑着。
    • 循环往复 : 之后每次 count 减少,都会触发重渲染 -> hold 重置为 0 -> useEffect 执行 -> 尝试 clearInterval(0) -> 无效。最终 count 变成负数,定时器依然坚挺。

    你可能会问,setInterval 回调里面访问的 hold 呢?它访问的是创建那个 setInterval 时闭包捕获的 hold 值(也就是那个 ID 123)。但 useEffect 里面访问的 hold当前这次渲染 中被重置的那个 hold。它们不是同一个“实例”的变量了。

useRef:跨越渲染周期的桥梁

现在明白为啥 useRef 能行了。

  1. useRef 的特性 :
    useRef Hook 返回一个可变的 ref 对象,这个对象的 .current 属性被初始化为传入的参数 (比如 useRef(null) 初始化为 null)。关键在于:这个 ref 对象在组件的整个生命周期内保持不变 。即使组件多次重新渲染,useRef 返回的总是同一个 ref 对象。

  2. ref.current 的持久性 :
    因为 ref 对象本身是持久的,所以你可以把它想象成一个挂在组件实例上的“小盒子”。你可以随时往这个盒子的 .current 属性里存东西、读东西,而且存进去的东西不会因为组件重新渲染而被重置。

  3. useRef 版代码流程解析 :

    • 首次渲染 : Parent 执行,const intervalRef = useRef(null); 执行。React 创建了一个 ref 对象,intervalRef 指向它,此时 intervalRef.currentnull
    • 点击按钮 : countdown 函数执行。setInterval 启动,返回 ID (比如 456)。intervalRef.current = 456; 这句执行了。现在,那个持久的 ref 对象的 .current 属性被更新为 456
    • 状态更新,触发重渲染 : setCount 更新 countParent 函数重新执行。
    • 再次渲染 : Parent 函数跑。const intervalRef = useRef(null); 再次 执行,但 useRef 很智能,它发现这个组件已经有一个关联的 ref 对象了,于是它返回同一个 ref 对象 。所以 intervalRef 变量虽然是这次渲染新创建的,但它指向的还是那个“老”的 ref 对象,那个 .current 仍然是 456 的对象。
    • useEffect 执行 : 当 count 变为 0 时,useEffect 执行。它读取 intervalRef.current。因为 intervalRef 指向的是那个持久的 ref 对象,所以它能准确读到之前存进去的 ID 456
    • 有效的清除 : clearInterval(intervalRef.current) 实际上执行的是 clearInterval(456)。成功清除了定时器!

简单来说,useRef 提供了一个不受组件重新渲染影响的、用来存储可变值的地方。这正好满足了咱们需要跨渲染周期保存 setInterval ID 的需求。

解决方案与实践:拥抱 useRef 并优化

useRef 来管理 setInterval 是 React 中处理这类问题的标准且推荐的方式。

解决方案:使用 useRef

  • 原理 : 利用 useRef 创建一个在组件生命周期内持久化的 ref 对象,使用其 .current 属性来存储和访问 setInterval 返回的 ID,确保在后续渲染或 useEffect 中能拿到正确的 ID 进行清除。

  • 代码示例 : (同文章开头的第一个代码块,这里不再重复,关键点在于 useRef 的使用)

  • 操作步骤 :

    1. 导入 useRef: import { useRef } from 'react';
    2. 在组件顶部创建 ref: const intervalRef = useRef(null);
    3. 在启动 setInterval 的地方,将其返回的 ID 赋值给 ref.current: intervalRef.current = setInterval(...)
    4. 在需要清除定时器的地方(比如 useEffect 的 cleanup 函数、或者某个条件判断后),使用 clearInterval(intervalRef.current)
  • 安全与进阶技巧 :

    1. 必须在 useEffect 的 Cleanup 函数中清除 :
      这是最重要的一点!如果你的组件在倒计时完成之前就被卸载了(比如用户切换了页面),而你没有在卸载时清除定时器,那这个定时器就会变成“幽灵”,在后台继续运行,尝试更新一个已经不存在的组件的状态,这会导致内存泄漏和报错。

      useEffect(() => {
        // 这个 effect 可能做其他事,或者仅仅是为了 cleanup
      
        // 返回的这个函数就是 cleanup 函数
        // 它会在组件卸载时执行,或者在下一次 effect 执行前执行(如果依赖项变化)
        return () => {
          if (intervalRef.current) {
            console.log('Component unmounting or effect re-running, clearing interval:', intervalRef.current);
            clearInterval(intervalRef.current);
          }
        };
      }, []); // 空依赖数组 [] 意味着这个 cleanup 只在组件卸载时执行一次
             // 如果你的 effect 还有其他依赖项,比如 [dep1, dep2],
             // 那 cleanup 会在 dep1 或 dep2 变化导致 effect 重新运行前,以及组件卸载时执行。
             // 对于仅用于管理 interval 生命周期的场景,空数组通常足够。
      

      强烈建议 :总是为设置了 setIntervalsetTimeoutuseEffect 添加一个返回清除逻辑的 cleanup 函数。

    2. 防止启动多个 Interval :
      如果用户快速连续点击 “countdown” 按钮,你的代码可能会启动多个 setInterval 实例,导致行为混乱。最好在启动新的 Interval 之前,先检查并清除旧的。

      const countdown = () => {
        // 先尝试清除已存在的 Interval
        if (intervalRef.current) {
          clearInterval(intervalRef.current);
          console.log('Cleared previous interval:', intervalRef.current);
        }
      
        // 再启动新的 Interval
        intervalRef.current = setInterval(() => {
          setCount((c) => {
            if (c <= 1) {
              clearInterval(intervalRef.current);
              return 0;
            }
            return c - 1;
          });
        }, 1000);
        console.log('Started new interval:', intervalRef.current);
      };
      
    3. setInterval 回调内部清除 :
      如优化后的代码示例所示,一种更简洁的做法是直接在 setInterval 的回调函数内部检查条件并清除。这样 useEffect [count] 的逻辑甚至可以省略,只保留卸载时的 cleanup。

      intervalRef.current = setInterval(() => {
        setCount((prevCount) => {
          if (prevCount <= 1) {
            // 条件满足,立即清除定时器
            clearInterval(intervalRef.current); 
            return 0; // 设置最终状态
          }
          return prevCount - 1; // 继续递减
        });
      }, 1000);
      

      这种方式的好处是逻辑更内聚,并且能确保在状态更新为最终值的那一刻立即停止。

理解 React 的渲染机制和 Hooks (特别是 useState, useEffect, useRef) 的工作原理,是写出健壮、可预测的 React 应用的关键。下次再遇到类似需要在渲染周期之间“记住”点什么东西的场景,不妨想想 useRef 是不是能帮上忙。