Hooks 的闭包陷阱

第五编 · 第五章:Hooks 的闭包陷阱的深入分析


闭包是什么

要理解 Hooks 的闭包陷阱,首先得搞清楚闭包是什么。

闭包是指一个函数能访问它定义时所在的词法作用域里的变量。这听起来像教科书定义,但它的实际含义很具体:

function outer() {
  const x = 1;

  function inner() {
    console.log(x); // inner 能访问 outer 里的 x
  }

  return inner;
}

const fn = outer();
fn(); // 输出 1

inner 函数在 outer 函数内部定义,但它被 return 出去、在 outer 执行完毕后仍然能访问 x。这就是闭包:inner 函数"记住"了定义时它能访问的那些变量。

闭包是 JavaScript 的基础特性,不是 React 发明的。Hooks 的闭包陷阱只是闭包特性在特定场景下带来的副作用。


Hooks 和闭包的关系

Hooks 的回调函数——无论是 useEffect 的回调、useCallback 的回调、还是事件处理函数——都会形成闭包。

每次组件渲染,组件函数会重新执行,函数内部的箭头函数和普通函数会被重新创建。这意味着每次渲染都可能创建一个新的闭包,捕获"这一次渲染时"的状态值。

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

  // 每次渲染,这个函数都是新的
  function handleClick() {
    console.log(count); // 捕获的是这次渲染的 count
  }

  return <button onClick={handleClick}>Click</button>;
}

第一次渲染,handleClick 里的 count 是 0。点击按钮后 setCount 触发重新渲染,新的 handleClick 被创建,count 是 1。两次渲染里 handleClick 捕获的是不同的 count 值。

这个机制本身没问题,是 Hooks 正常工作的基础。但它会在某些场景下导致意想不到的行为。


场景一:setInterval 和过期的值

这是最经典的闭包陷阱:

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // 永远是 0
    }, 1000);
    return () => clearInterval(id);
  }, []); // 空依赖数组

  return <button onClick={() => setCount(count + 1)}>Click</button>;
}

useEffect 的回调只运行一次,因为依赖数组是空的。它创建的 setInterval 回调形成了一个闭包,捕获了首次渲染时的 count 值——也就是 0。

无论 count 怎么变化,setInterval 的回调里永远是 0,因为这个回调是"首次渲染时的 count"的闭包。

解决方式是把 count 放进依赖数组,但这样会导致每次 count 变化都重新创建 setInterval

useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(id);
}, [count]); // count 变化就重新创建 interval

更好的方式是使用函数式更新,不直接依赖 count

useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // 不依赖外部的 count
  }, 1000);
  return () => clearInterval(id);
}, []);

这里 setCount 接收一个函数,React 会把最新的状态值传进去,所以不需要依赖外部的 count


场景二:事件处理函数里的 stale 值

闭包问题不只出现在 useEffect 里,也会出现在普通的事件处理函数里:

function Profile() {
  const [name, setName] = useState('Tom');

  function handleSave() {
    // 发送 API 请求
    saveProfile(name); // 这里 name 是哪个版本的值?
  }

  return (
    <>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button onClick={handleSave}>Save</button>
    </>
  );
}

假设用户把 name 从 'Tom' 改成了 'John',然后点击 Save。handleSave 里的 name 应该是 'John',不是 'Tom'。

在这个例子里,因为 handleSave 是在当前渲染的函数作用域里定义的,它捕获的是"这次渲染"的 name,也就是更新后的值。所以这个代码是正确的。

但如果 handleSave 被传给子组件、或者通过其他方式被保存下来之后在某个时刻才调用,就会遇到闭包问题:

function Profile() {
  const [name, setName] = useState('Tom');

  const handleSave = useCallback(() => {
    saveProfile(name); // 捕获的是首次渲染时的 'Tom'
  }, []); // 空依赖数组

  return <Button onClick={handleSave}>Save</Button>;
}

这里 useCallback 包裹了 handleSave,但依赖数组是空的,所以 handleSave 永远是首次渲染时创建的那个版本,它捕获的 name 永远是 'Tom'。

name 放进依赖数组可以解决这个问题:

const handleSave = useCallback(() => {
  saveProfile(name);
}, [name]); // name 变化就创建新的 handleSave

场景三:useRef 解决闭包问题

useRef 提供了一个特殊的机制来绕过闭包陷阱。

useRef 返回一个 ref 对象,ref.current 属性可以存放任何值,而且修改 ref 不会触发组件重新渲染。更重要的是,ref 的值是"响应式"的——它在一个渲染周期内保持不变,但下次渲染会拿到新值。

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

  // 每次渲染都更新 ref,但不触发重新渲染
  countRef.current = count;

  useEffect(() => {
    const id = setInterval(() => {
      console.log(countRef.current); // 永远是最新的值
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <button onClick={() => setCount(count + 1)}>Click</button>;
}

countRef.currentsetInterval 回调里被读取,每次读取都是最新的值,因为 ref 的 current 属性本身是可以变的。

但这种模式的代价是:每次渲染都会执行 countRef.current = count,虽然不会触发重新渲染,但会执行一次赋值。在大多数场景下这不是问题,但在极端高频渲染的场景下可能需要考虑。


什么时候该用 useCallback

闭包陷阱导致很多人倾向于给所有函数都加 useCallback。但 useCallback 不是免费的——它会增加代码复杂度,而且本身也有性能开销。

什么时候该用 useCallback

传给子组件的函数,且子组件用了 React.memo。如果父组件重新渲染,传下去的函数引用会变化,导致 React.memo 的子组件认为 props 变了而重新渲染。这时候用 useCallback 可以稳定函数引用。

函数被用在依赖数组里的地方。如果一个函数是另一个 useEffectuseMemo 的依赖,那这个函数应该用 useCallback 包裹,并正确声明依赖。

什么时候不需要用:如果函数不会被传递给子组件,也不会被用在依赖数组里,不需要 useCallback。过度使用只会增加维护成本。


这一章想说的

Hooks 的闭包陷阱不是 Hooks 的 bug,而是 JavaScript 闭包特性在特定场景下的自然表现。

闭包问题的本质是:回调函数捕获了"定义时"的值,而不是"调用时"的值。在 React 里,这意味着每次渲染创建的回调都可能捕获当时的 state 或 props 值。

解决闭包陷阱的方式有几种:把正确的依赖放进依赖数组、使用函数式更新代替直接引用、使用 useRef 来存放会变化但不需要触发渲染的值。

理解闭包机制,而不是记住"依赖数组要写完整"这条规则,才能在复杂场景下写出正确的代码。