闭包是什么
要理解 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.current 在 setInterval 回调里被读取,每次读取都是最新的值,因为 ref 的 current 属性本身是可以变的。
但这种模式的代价是:每次渲染都会执行 countRef.current = count,虽然不会触发重新渲染,但会执行一次赋值。在大多数场景下这不是问题,但在极端高频渲染的场景下可能需要考虑。
什么时候该用 useCallback
闭包陷阱导致很多人倾向于给所有函数都加 useCallback。但 useCallback 不是免费的——它会增加代码复杂度,而且本身也有性能开销。
什么时候该用 useCallback:
传给子组件的函数,且子组件用了 React.memo。如果父组件重新渲染,传下去的函数引用会变化,导致 React.memo 的子组件认为 props 变了而重新渲染。这时候用 useCallback 可以稳定函数引用。
函数被用在依赖数组里的地方。如果一个函数是另一个 useEffect 或 useMemo 的依赖,那这个函数应该用 useCallback 包裹,并正确声明依赖。
什么时候不需要用:如果函数不会被传递给子组件,也不会被用在依赖数组里,不需要 useCallback。过度使用只会增加维护成本。
这一章想说的
Hooks 的闭包陷阱不是 Hooks 的 bug,而是 JavaScript 闭包特性在特定场景下的自然表现。
闭包问题的本质是:回调函数捕获了"定义时"的值,而不是"调用时"的值。在 React 里,这意味着每次渲染创建的回调都可能捕获当时的 state 或 props 值。
解决闭包陷阱的方式有几种:把正确的依赖放进依赖数组、使用函数式更新代替直接引用、使用 useRef 来存放会变化但不需要触发渲染的值。
理解闭包机制,而不是记住"依赖数组要写完整"这条规则,才能在复杂场景下写出正确的代码。