Skip to content

在 React 中,闭包是一个非常常见且重要的话念,尤其是在函数组件和 Hooks(如 useState, useEffect)被广泛使用之后。理解闭包对于写出健壮、可预测的 React 组件至关重要。

什么是闭包?

首先,我们快速回顾一下 JavaScript 中的闭包。闭包是指一个函数能够“记住”并访问其词法作用域(lexical scope)中的变量,即使该函数在作用域之外被执行。

简单来说,内部函数持有了外部函数作用域中变量的引用。

javascript
function outerFunction() {
  let count = 0; // 外部函数的变量

  function innerFunction() {
    // 内部函数,它创建了一个闭包
    console.log(count); // 它可以访问 `count`
  }

  return innerFunction;
}

const myClosure = outerFunction();
myClosure(); // 输出 0

React 中的闭包陷阱

在 React 函数组件中,每一次渲染都会重新执行整个函数,产生一个独立的“快照”(snapshot)。组件内的状态(state)、属性(props)和函数(如事件处理函数)都属于该次渲染的特定作用域。

问题通常出现在异步操作中,例如 setTimeout, setInterval, 或者 useEffect 的清理函数里。这些异步函数在定义时捕获了(或者说“闭包”了)当时渲染作用域中的 state 和 props。当异步函数在未来的某个时间点执行时,它访问的仍然是旧的、定义时的 state 和 props,而不是最新的值。

这就导致了所谓的 “陈旧闭包” (Stale Closure) 问题。


陷阱一:useEffectuseState

这是最经典的场景。假设我们想做一个每秒递增的计数器。

有问题的代码:

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

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

  useEffect(() => {
    const intervalId = setInterval(() => {
      // 这个回调函数是一个闭包
      // 它捕获了第一次渲染时的 `count` 值,也就是 0
      console.log(`Interval tick, count is: ${count}`);
      setCount(count + 1); // 这里的 count 永远是 0
    }, 1000);

    return () => clearInterval(intervalId);
  }, []); // 依赖项数组为空,意味着 effect 只在初始渲染时运行一次

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

问题分析:

  1. 初始渲染: count0useEffect 执行,setInterval 被调用。
  2. setInterval 的回调函数形成了一个闭包,它捕获了此时的 count 值,即 0
  3. 1秒后: setInterval 的回调执行。它读取闭包中的 count(值为 0),然后调用 setCount(0 + 1)count 状态被更新为 1,组件重新渲染。
  4. 2秒后: setInterval 的回调再次执行。关键点来了:这个回调函数还是那个在初始渲染时定义的函数,它闭包里的 count 依然是 0!所以它再次调用 setCount(0 + 1)
  5. 结果就是,count 的值会从 0 变成 1,然后就永远停在 1 了。

如何解决陈旧闭包问题?

有几种常见的解决方案,每种方案都有其适用的场景。

方案一:使用函数式更新 (Functional Updates)

这是 useStateset 函数提供的一个强大功能。你可以给 set 函数传递一个函数,而不是直接传递一个新值。这个函数会自动接收最新的 state 作为参数。

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

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

  useEffect(() => {
    const intervalId = setInterval(() => {
      // 传递一个函数给 setCount
      // React 会确保 prevCount 是最新的 count 值
      setCount(prevCount => prevCount + 1);
    }, 1000);

    return () => clearInterval(intervalId);
  }, []); // 依赖项仍然为空,因为我们不再直接依赖 count

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

优点:

  • 非常简洁,是解决这类问题的首选方案。
  • 可以避免在 useEffect 的依赖项数组中加入 state,从而减少不必要的 effect 清理和重新设置。

方案二:正确设置 useEffect 的依赖项

useEffect 的依赖项数组 (deps) 就是为了解决这类问题而设计的。通过将 count 添加到依赖项中,我们告诉 React:“当 count 变化时,请重新运行这个 effect”。

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

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

  useEffect(() => {
    // 这个 effect 现在依赖于 count
    console.log('Effect is running with count:', count);
    const intervalId = setInterval(() => {
      setCount(count + 1);
    }, 1000);

    // 清理函数至关重要!
    return () => {
      console.log('Clearing interval for count:', count);
      clearInterval(intervalId);
    };
  }, [count]); // 将 count 添加到依赖项

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

工作流程:

  1. 初始渲染: count0。Effect 运行,设置一个 setInterval
  2. 1秒后: setInterval 回调执行,调用 setCount(0 + 1)count 状态变为 1
  3. 组件重新渲染: 因为 count 变了,React 会先执行上一次 effect 的清理函数 (clearInterval),清除旧的 interval。
  4. 运行新 Effect: 然后 React 会用新的 count 值 (1) 重新运行 effect,设置一个新的 setInterval。这个新的 interval 的闭包里,count 的值就是 1
  5. 这个过程不断重复,计数器正常工作。

缺点:

  • 会频繁地设置和清除定时器,对于简单的场景可能没什么问题,但对于复杂的 effect(比如涉及网络请求、库的订阅等),可能会带来性能开销和不必要的逻辑。

方案三:使用 useRef

useRef 可以创建一个可变的引用对象,其 .current 属性可以在多次渲染之间保持不变,并且修改它不会触发组件的重新渲染。我们可以利用它来存储需要被闭包访问的最新值。

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

function Counter() {
  const [count, setCount] = useState(0);
  // 创建一个 ref 来持有最新的 count 值
  const countRef = useRef(count);

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

  useEffect(() => {
    const intervalId = setInterval(() => {
      // 从 ref 中读取最新的 count 值
      setCount(countRef.current + 1);
    }, 1000);

    return () => clearInterval(intervalId);
  }, []); // 依赖项为空

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

工作流程:

  1. setInterval 的回调函数在定义时,闭包了 countRef 这个对象。
  2. 由于 countRef 对象本身在多次渲染中是同一个对象,所以闭包始终可以访问到它。
  3. 我们通过另一个 useEffect 保证 countRef.current 的值在每次渲染后都是最新的。
  4. 这样,setInterval 回调通过 countRef.current 总能拿到最新的 count 值。

优点:

  • 可以在不重新触发 effect 的情况下,访问到最新的 state 或 props。在处理事件监听器或订阅时非常有用。

总结

场景推荐解决方案解释
仅需要根据前一个状态计算新状态函数式更新 (setCount(c => c + 1))最简单、最高效。避免了不必要的依赖和 effect 重启。
Effect 逻辑确实依赖于 state/props设置 useEffect 依赖项 ([count])这是 useEffect 的标准用法。确保 effect 总是在最新的上下文中运行。别忘了清理函数!
需要在异步回调中访问最新 state/props,但又不想重启 effectuseRef当 effect 的设置/清理成本很高时(如复杂的订阅),使用 useRef 来创建一个指向最新值的“后门”。

理解 React 中的闭包问题,本质上是理解 “渲染快照”“异步执行” 之间的交互。一旦掌握了上述几种解决方案,你就能更自信地处理 React 中的各种异步逻辑了。