React 中 useRef 和闭包问题详解

256 阅读5分钟

在 React 开发中,useState 是管理组件状态的常用钩子,但由于其工作机制,可能导致一些闭包问题。这些问题尤其常见于组件中定义的事件处理函数中。而 useRef 提供了一种优雅的解决方案。

为什么闭包会导致问题?

闭包的特性

闭包会捕获定义时的变量值,而不是其后续的变化。当某个函数被闭包捕获时,闭包引用的变量值将固定为函数创建时的值。以下是一个常见示例:

function Component() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('count:', count); // 这里的 count 永远是初始渲染时的值
      setCount(count + 1);          // 永远是 0 + 1
    }, 1000);
    
    return () => clearInterval(timer);
  }, []);

  return <div>{count}</div>;
}

为什么 useRef 能解决闭包问题?

useRef 能解决闭包问题的原因在于它的两个重要特性:

  1. useRef 返回的是一个可变的 ref 对象,它的 .current 属性可以被修改

  2. useRef 在组件生命周期内保持不变,即使组件重新渲染,引用仍然指向同一个对象

首先让我们通过一段具体的实例来明确一件事:

  1. 普通变量/state 闭包捕获的是值,如count
  2. useRef捕获的是引用对象,如countRef
function Component() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0);

  // 每次 count 变化时更新 countRef
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const timer = setInterval(() => {
      // 情况1: count 永远是0
      // 闭包捕获的是创建这个函数时的 count 值
      console.log('count:', count); 
      
      // 情况2: countRef.current 是最新值
      // 闭包捕获的是 countRef 这个引用对象
      console.log('countRef:', countRef.current); 
      
      setCount(count + 1); // 永远是 0 + 1
      // setCount(countRef.current + 1); // 这样能正确累加
    }, 1000);
    
    return () => clearInterval(timer);
  }, []);

  return <div onClick={() => setCount(c => c + 1)}>{count}</div>;
}

因此,useRef不会被闭包限制的根本原因在于useState和useRef所捕获的内容不同,那捕获内容的差异又是如何进一步避免闭包问题呢?

我们对比下两种变量不同的闭包行为

普通变量的闭包行为

// 第一次渲染
count = 0
closure = {
  count: 0  // 闭包捕获的值
}

// 点击后,第二次渲染
count = 1
closure = {
  count: 0  // 还是原来的闭包!
}

useRef 的闭包行为

useRef 返回的引用在整个生命周期中保持一致,通过捕获引用,而不是值,useRef 能绕过闭包问题。

// 第一次渲染
countRef = { current: 0 }  // 内存地址假设是 #001
closure = {
  countRef: #001  // 闭包捕获的是引用
}

// 点击后,第二次渲染
countRef = { current: 1 }  // 还是同一个对象 #001
closure = {
  countRef: #001  // 引用没变,但是能通过引用拿到最新值
}

useRef 和 useState 的引用管理区别

既然是因为引用数据类型的特性来避免闭包,那通过useState初始化一个对象是不是同样可以解决这个问题?其实并不是,虽然 useRef 和 useState 都能存储引用对象,但它们在 React 内部的更新机制不同:

useState
每次状态更新,React 都会创建一个新的对象,有新的内存地址和对象引用,导致旧的闭包依然引用的是先前的对象。

useRef
useRef 的引用在整个生命周期内保持不变,允许组件访问同一个对象的 .current 属性。

function Component() {
  // 每次渲染都会创建新的对象引用
  const [user, userState] = useState({ count: 0 });
  
  // 在组件的整个生命周期中保持同一个引用
  const userRef = useRef({ count: 0 });
  
  useEffect(() => {
    const timer = setInterval(() => {
      // 这里的 user 是定义这个函数时的那个对象(内存地址 #001)
      // 即使后来 state 更新了,这个闭包仍然引用的是旧的内存地址
      console.log(user.count); // 永远是初始值
      
      // 这里的 objRef 是同一个引用,可以拿到最新值
      console.log(userRef.current.count);
      
      setUser({ count: user.count + 1 }); // 这里会创建一个新对象,放在新的内存地址 #002
    }, 1000);
  }, []);
}

useState 和 useRef 的内存管理

// useState
// 首次渲染
const user1 = { count: 0 };  // 内存地址 #001
                             // user 变量指向 #001
// 调用useUser之                    
setUser({ count: user1.count + 1 }); // 创建新对象,内存地址 #002

// 调用 setUser 后的下一次渲染
const user2 = { count: 1 }  // 内存地址 #002
                           // user 变量现在指向 #002
                           // 但闭包中的 user 仍然指向 #001!

// useRef
const userRef = { current: { count: 0 } };  // 内存地址 #003
userRef.current.count += 1;                // 不变的引用,更新 .current 的值

这就是为什么 useRef 能解决闭包问题 —— 不是因为它避免了闭包,而是因为它巧妙地利用了引用类型的特性,让我们即使在闭包中也能访问到最新的值。

总结

  1. 闭包问题的本质

    • 普通变量和 useState 捕获的是值,闭包捕获后不会随状态更新而改变。
    • useRef 捕获的是引用,通过 .current 属性访问最新值。
  2. useRef 的核心优势

    • 引用在整个生命周期内保持不变。
    • 避免了状态更新带来的对象替换,能持续获取最新的 .current 值。
  3. 适用场景

    • 在需要处理闭包问题的场景下,使用 useRef 是更简洁且高效的解决方案。
    • 在状态需要触发 UI 重新渲染时,useState 仍是更好的选择。

通过理解 useRef 和 useState 的核心机制,可以发现,不是useRef避免了闭包,而是因为它巧妙地利用了引用类型的特性,让我们即使在闭包中也能访问到最新的值。