人人都会AI编程

30.3 闭包导致的状态不同步问题

更新时间:2026-07-10

在 React 中,闭包陷阱(Stale Closure)是最常见且最隐蔽的状态问题之一。它通常出现在 useEffectuseCallbackuseMemo 等依赖闭包的 Hooks 中,表现为看到的状态和实际渲染的状态不一致

典型场景:定时器中的“过期”状态

假设你有一个计数器,希望在每次点击后 3 秒显示当前计数:

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

  const handleClick = () => {
    setCount(count + 1);
    setTimeout(() => {
      alert(`当前计数:${count}`); // 预期显示点击时的最新值
    }, 3000);
  };

  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={handleClick}>+1 并 3 秒后提示</button>
    </div>
  );
}

实际运行结果: 快速点击三次按钮,三次弹窗都显示“当前计数:0”,而不是 1、2、3。

原因分析:闭包捕获了旧的状态值

每次组件重渲染时,Counter 函数都会重新执行,生成一个全新的闭包环境:

  • 第一次渲染:count = 0handleClick 捕获了这个 0
  • 点击按钮,调用 setCount(0 + 1),触发 React 安排一次重渲染。
  • 当前已执行的 handleClick 闭包中的 count 仍然是 0(它不会随重渲染而改变,因为 JavaScript 的闭包是词法作用域绑定,值在创建时确定)。
  • setTimeout 的回调在 3 秒后执行时,仍然引用着创建时的 count(即 0)。

连续快速点击多个按钮,每次点击都会创建一个新的闭包和新的定时器,但每个闭包都“记住”了当时渲染的快照。即使界面的 count 已经更新,这些早已注册的回调函数却停留在过去,这就是过期闭包

为什么 React 不自动修正这个“问题”?

React 的渲染是声明式快照——每次渲染的 UI 和关联的事件处理函数都是基于当时的状态和 Props 计算出的。闭包捕获过时的值并非 bug,而是 JavaScript 闭包机制和 React 快照渲染模型的自然结果。它保证了在一次渲染内,状态、Props、事件处理函数的一致性:如果你在同一渲染周期内多次读取某个状状态,它们总是返回相同的值。

解决方案

方法一:使用函数式更新

如果新状态依赖旧状态,应使用 setState 的函数形式,它能保证获取到最新的状态值,而不依赖闭包中的 count

const handleClick = () => {
  setCount(prevCount => prevCount + 1);
  setTimeout(() => {
    setCount(prevCount => {
      alert(`当前计数:${prevCount}`);
      return prevCount;
    });
  }, 3000);
};

但注意,alert 显示的是定时器执行时那一刻的状态(即 3 秒后的状态),并非点击那一刻的状态。如果你需要点击那一刻的值,可以结合 useRef

方法二:用 useRef 保存最新值

useRef 返回一个 mutable 对象,其 current 属性在整个组件生命周期内保持不变,且修改不会触发重渲染。你可以用它来“逃逸”闭包的快照限制,始终持有最新状态。

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

  // 将每次最新的 count 同步到 ref
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  const handleClick = () => {
    setCount(prev => prev + 1);
    setTimeout(() => {
      alert(`点击那一刻的计数:${countRef.current}`);
    }, 3000);
  };

  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={handleClick}>+1 并 3 秒后提示</button>
    </div>
  );
}

现在 alert 总能显示最新的 count

方法三:正确设置 useEffect 的依赖项

useEffect 中引用状态或 Props 时,必须将它们列入依赖数组,否则 effect 中的逻辑会一直使用旧的快照。

// 错误:漏掉了 count 依赖
useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // 始终打印 0
  }, 1000);
  return () => clearInterval(timer);
}, []); // 缺少 count

修正后:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]);

但这样会导致定时器频繁清除重建,性能不佳。更优雅的做法是结合 useRef

useEffect(() => {
  const timer = setInterval(() => {
    console.log(countRef.current);
  }, 1000);
  return () => clearInterval(timer);
}, []); // 空依赖,定时器只创建一次

方法四:使用 useCallback + 依赖

如果回调函数需要通过 Props 传递给子组件,应使用 useCallback 并正确声明依赖,以避免闭包过期。

const handleDelete = useCallback((id) => {
  // 这里依赖 userId
  if (userId) {
    deleteItem(id, userId);
  }
}, [userId]);

排查闭包问题的两个实用技巧

  1. 在 effect 中打印变量,并对比 DevTools 中的最新值:如果控制台打印的值与组件显示的不一致,就是闭包问题。
  2. 使用 lint 规则 react-hooks/exhaustive-deps:它能自动检测 useEffect / useCallback 的依赖是否完整,避免因漏掉依赖导致闭包过期。

总结

闭包导致的状态不同步是 React Hooks 开发中的高频问题,其根源在于每次渲染都会创建新的闭包,捕获当时的状态快照。规避原则:

  • 状态更新依赖旧值时,使用函数式 update。
  • 需要在异步回调中访问最新状态时,使用 useRef 作为“逃生舱”。
  • 严格遵循 Hooks 依赖规则,避免遗漏依赖。

理解这一点,你就能从容应对绝大多数因闭包引起的“幽灵 bug”。