skip to content
月与羽

闭包陷阱

/ 15 min read

什么是闭包?为什么在 React 中如此重要?

首先,我们快速回顾一下 JavaScript 中的闭包。闭包是指一个函数能够“记住”并访问其所在的词法作用域(lexical scope),即使该函数在其词法作用域之外执行。 在 React 函数式组件中,每一次渲染(render)都是对组件函数的一次重新调用。这意味着:

  1. 每次渲染都有一个独立的“快照”:在该次渲染中,所有的 state、props 和局部变量都有一个特定的值。
  2. 函数是“一等公民”:在组件内部定义的函数(如事件处理器、useEffect 的回调)都是在当次渲染中创建的。 因此,这些内部函数就形成了一个闭包,它们“捕获”了当次渲染的 state 和 props。这就是所有闭包陷阱的根源:函数引用的 state/props 是它被创建时的值,而不是最新的值。

陷阱一:useState 中的陈旧状态 (Stale State)

这是最基础也是最常见的闭包陷阱。

场景描述

假设我们有一个计数器,我们希望在点击按钮后,延迟 3 秒再增加计数。

import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const handleAlertClick = () => {
setTimeout(() => {
alert('You clicked on: ' + count);
}, 3000);
};
const handleIncorrectIncrement = () => {
// 如果快速连续点击两次,你期望 count 增加 2,但实际上只会增加 1
setCount(count + 1);
setCount(count + 1);
};
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
<button onClick={handleAlertClick}>Show Alert After 3s</button>
<button onClick={handleIncorrectIncrement}>Incorrect Increment</button>
</div>
);
}

问题分析

定时器中的闭包

handleAlertClick:当你点击 “Show Alert” 按钮时,假设此时 count 的值是 5handleAlertClick 函数被调用,它创建了一个 setTimeout 回调。这个回调函数是一个闭包,它捕获了当时count 值,也就是 5。即使你在接下来的 3 秒内继续点击 “Click me” 按钮,将 count 增加到 10,3 秒后弹出的 alert 依然会显示 “You clicked on: 5”。因为那个闭包里的 count 永远是它被创建时的那个快照值。

setState 陈旧闭包

handleIncorrectIncrement:当你点击 “Incorrect Increment” 按钮时,setCount(count + 1) 被调用了两次。假设当前 count0。 - 第一次调用 setCount(0 + 1),它将一个更新任务排入队列,请求将 state 设置为 1。 - 第二次调用 setCount(0 + 1),它也读取了当次渲染中的 count(仍然是 0),然后也将一个更新任务排入队列,请求将 state 设置为 1。 - React 在处理这些更新时,最终结果是 state 变成了 1,而不是 2

解决方案:

函数式更新解决setState 更新

setState 函数可以接受一个函数作为参数,而不是一个值。这个函数会接收前一个 state 作为参数,并返回新的 state。React 保证传递给这个函数的 state 是最新的。

function Counter() {
const [count, setCount] = useState(0);
const handleCorrectIncrement = () => {
// 这样就能正确地增加 2
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
};
// ... 其他代码
return (
)
}

useRef解决 定时器

import React, { useState, useRef, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// 1. 创建一个 ref,用来存储最新的 count
const latestCountRef = useRef(count);
// 2. 使用 useEffect 在每次 count 更新后,同步到 ref
useEffect(() => {
latestCountRef.current = count;
}, [count]); // 依赖数组 [count] 确保只在 count 变化时执行
const handleAlertClick = () => {
setTimeout(() => {
// 3. 从 ref 中读取最新的 count 值
alert('You clicked on: ' + latestCountRef.current);
}, 3000);
};
// ... 其他函数保持不变
return (
);
}

陷阱二:useEffect 中的陈旧闭包

这是最隐蔽也最容易导致 bug 的陷阱,尤其是在处理订阅、定时器或异步请求时。

场景描述

我们想创建一个每秒钟更新一次的计数器。

import React, { useState, useEffect } from 'react';
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// 这个闭包里的 count 永远是 0
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 依赖数组为空,effect 只在组件挂载时运行一次
return <h1>{count}</h1>;
}

问题分析

  1. useEffect 的回调函数在组件首次渲染后执行。
  2. 此时,count 的值是 0setInterval 的回调函数(一个闭包)被创建,它捕获了 count 的值,即 0
  3. 由于依赖数组是 [],这个 useEffect 只会运行一次。它设置的 setInterval 将永远存在(直到组件卸载)。
  4. 每一秒,setInterval 的回调执行 setCount(count + 1)。但它引用的 count 永远是它被创建时捕获的那个 0。所以,它实际执行的是 setCount(0 + 1)
  5. 结果是:count 在第一秒从 0 变成 1,之后就再也不变了。

解决方案

方案一:添加依赖项(The React Way)

count 添加到 useEffect 的依赖数组中。

useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]); // 每次 count 变化,都重新设置 effect

工作原理

  1. 初始渲染,count0useEffect 设置一个定时器,该定时器在一秒后执行 setCount(0 + 1)
  2. count 变为 1,组件重新渲染。
  3. React 检测到 count 变化,会先执行上一个 effect 的清理函数clearInterval),清除旧的定时器。
  4. 然后,它会用新的 count 值(1)重新运行 useEffect,设置一个新的定时器,这个新定时器会在一秒后执行 setCount(1 + 1)
  5. 这个过程不断重复,实现了我们想要的效果。 缺点:频繁地设置和清除定时器,在某些复杂场景下可能会有性能开销或逻辑问题。

方案二:使用函数式更新(推荐)

这是解决此类问题的最佳实践。

useEffect(() => {
const id = setInterval(() => {
// 使用函数式更新,无需依赖外部的 count 变量
setCount(prevCount => prevCount + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 依赖数组可以为空

工作原理
setInterval 的回调不再需要从外部作用域捕获 count。它直接告诉 React:“请给我最新的 count,然后加 1”。这样,useEffect 自身就不再依赖 count,所以依赖数组可以为空,定时器也只需设置一次。

方案三:使用 useRef

useRef 返回一个可变的 ref 对象,其 .current 属性可以被自由修改,并且 useRef 对象本身在组件的整个生命周期内保持不变。我们可以利用它来保存那些不希望触发重新渲染,但又需要在闭包中访问最新值的数据。

function Timer() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count; // 每次渲染都更新 ref
useEffect(() => {
const id = setInterval(() => {
// 访问最新的 count 值
setCount(countRef.current + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}

工作原理

  1. setInterval 的闭包捕获的是 countRef 这个对象。countRef 对象在所有渲染中都是同一个。
  2. 每次组件重新渲染时,我们都手动将最新的 count 值同步到 countRef.current
  3. 定时器回调通过 countRef.current 总能读取到最新的 count 值。 适用场景:当闭包内不仅需要读取最新的 state,还需要读取最新的 props 或其他计算值,而你又不想将这些值加入依赖数组以避免 effect 重复执行时,useRef 是一个非常有用的“逃生舱口”。

陷阱三:useCallback 和事件处理器

useCallback 用于记忆一个函数,避免在子组件中因为函数引用的变化而导致不必要的重新渲染。但如果使用不当,它会制造出陈旧的闭包。

场景描述

import React, { useState, useCallback, memo } from 'react';
// 一个被 memo 优化的子组件
const MemoizedButton = memo(({ onClick, children }) => {
console.log(`Rendering button: ${children}`);
return <button onClick={onClick}>{children}</button>;
});
function Parent() {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
// 错误的做法:依赖数组为空,但函数体内部使用了 countA
const handleIncrementA = useCallback(() => {
setCountA(countA + 1);
}, []); // <--- 陷阱在这里
const handleIncrementB = () => {
setCountB(countB + 1);
};
return (
<div>
<p>Count A: {countA}</p>
<p>Count B: {countB}</p>
<MemoizedButton onClick={handleIncrementA}>Increment A</MemoizedButton>
<MemoizedButton onClick={handleIncrementB}>Increment B</MemoizedButton>
</div>
);
}

问题分析

  1. handleIncrementAuseCallback 包裹,并且依赖数组是 []。这意味着这个函数只在组件初次渲染时创建一次,之后永远返回同一个函数实例。
  2. 初次渲染时,countA0handleIncrementA 这个闭包捕获了 countA 的值为 0
  3. 当你点击 “Increment A” 按钮,它调用 setCountA(0 + 1)countA 变为 1。组件重新渲染。
  4. 当你再次点击 “Increment A” 按钮,调用的仍然是那个旧的 handleIncrementA 函数,它闭包里的 countA 仍然是 **0**!所以它再次执行 setCountA(0 + 1)countA 永远只能在 01 之间切换。
  5. 与此同时,当你点击 “Increment B”,countB 变化,Parent 组件重新渲染,handleIncrementA 因为被 useCallback 记忆了,所以 MemoizedButton A 不会重新渲染。

解决方案

方案一:添加依赖项

正确地将 countA 添加到依赖数组。

const handleIncrementA = useCallback(() => {
setCountA(countA + 1);
}, [countA]); // <--- 正确的做法

工作原理
每当 countA 改变时,useCallback 都会废弃旧的函数,并用新的 countA 值创建一个新的 handleIncrementA 函数。这个新函数会被传递给 MemoizedButton,虽然这会导致 MemoizedButton 重新渲染,但这是正确且必要的,因为它需要一个新的、包含正确 countA 值的回调函数。

方案二:使用函数式更新

这同样是避免不必要依赖的最佳方法。

const handleIncrementA = useCallback(() => {
setCountA(prevCountA => prevCountA + 1);
}, []); // <--- 依赖数组可以为空

工作原理
handleIncrementA 的实现不依赖于外部的 countA 变量。因此,它不需要在 countA 变化时重新创建。这样既保证了逻辑的正确性,又实现了性能优化(MemoizedButton A 不会因为 countA 的变化而重渲染)。


总结与防范策略

  1. 理解核心原因:React 的每一次渲染都是一个状态快照。在组件内部定义的函数会捕获该次渲染的 props 和 state。
  2. 优先使用函数式更新:对于 useStatesetState 函数,当新状态依赖于旧状态时,永远优先使用函数式更新 setState(prevState => ...)。这可以让你在 useCallbackuseEffect 中减少不必要的依赖。
  3. 正确填写依赖数组useEffect, useCallback, useMemo 的依赖数组至关重要。
    • 不要欺骗 React:不要为了避免重新执行而省略必要的依赖。这会导致陈旧闭包和难以察觉的 bug。
    • 开启 eslint-plugin-react-hooks:这个 ESLint 插件(Create React App 默认集成)会自动检查并警告你缺失的依赖项。请务必信任并遵循它的建议
  4. 善用 useRef:当你需要在 useEffectuseCallback 的闭包中引用一个最新的值,但又不希望这个值的变化触发 effect/callback 的重新创建时,useRef 是你的好朋友。它是一个“逃生舱口”,可以让你绕过闭包陷阱。
  5. useReducer 作为替代:对于复杂的状态逻辑,useReducerdispatch 函数是身份稳定的,即在多次渲染之间不会改变。这意味着你可以安全地在 useEffectuseCallback 的闭包中调用 dispatch,而无需将其添加到依赖数组中,从而避免很多闭包问题。

通过深入理解这些陷阱的成因和解决方案,你不仅能写出更健壮的 React 代码,还能更好地掌握 React Hooks 的设计哲学。