在React函数组件中,自定义Hook是复用状态逻辑的重要手段。但不少人在抽取Hook时会遇到一个隐蔽的问题:在Hook内部定义的定时器、订阅回调或者事件处理函数,读取到的state或props始终是初次创建时的旧值,导致界面更新异常。这种现象背后的根源是JavaScript闭包与React渲染机制叠加产生的陷阱。

一、闭包陷阱产生的底层原理
函数组件每一次渲染,都会重新执行函数体,从而生成全新的局部变量与函数作用域。自定义Hook本质上也是一个函数,它在每次渲染时同样会重新执行。当我们在Hook里通过useEffect注册了一个回调函数,例如setInterval,这个回调函数会捕获当时渲染作用域中的变量。由于闭包的特性,它记住的是那一次渲染时变量的引用,而不是后续渲染产生的新变量。
React并不会在状态变化后自动去修改已经创建的闭包内部绑定。也就是说,如果某个effect的依赖数组没有包含对应的state,或者使用了空数组,那么回调内部看到的就永远是首次渲染的快照。下面这段自定义Hook代码就存在典型问题:
import { useState, useEffect } from 'react';
function useCounterWrong() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// 这里的count永远是初始渲染时的0
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖,effect只执行一次
return count;
}
上述代码中,useEffect的依赖数组为空,意味着副作用函数只在挂载时运行一次。setInterval里的箭头函数形成了闭包,捕获了首次渲染的count变量。即使组件后续因setCount重新渲染,定时器回调里的count依旧是0,界面上的数字会停留在1不再增长。
二、使用useRef保存最新值的方案
useRef返回的引用对象在组件的整个生命周期中保持稳定,其current属性可以被随意读写且不会触发渲染。我们可以借助它来打破闭包,让回调始终读取到最新的变量。具体做法是在每次渲染时把最新值写入ref,回调内部改为读取ref.current。
import { useState, useEffect, useRef } from 'react';
function useCounterRef() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
// 每次渲染同步最新count到ref
countRef.current = count;
useEffect(() => {
const id = setInterval(() => {
// 读取ref中的最新值
setCount(countRef.current + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return count;
}
这种写法的优势在于effect无需重复绑定,适合封装独立运行的轮询逻辑。不过它需要手动维护ref与state的同步,若在多个状态场景下容易遗漏赋值。此外,由于ref的写入发生在渲染阶段,在极端并发渲染情况下需确认赋值时机是否可靠。
三、正确声明依赖与函数式更新
另一种更贴合React设计理念的方式是补齐依赖数组,或者使用setState的函数式更新形式。函数式更新接收前一个状态作为参数,不依赖闭包里的旧变量,因此能天然避开陷阱。
import { useState, useEffect } from 'react';
function useCounterSafe() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// 函数式更新,不读取闭包中的count
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return count;
}
上例中setCount直接以prev => prev + 1方式更新,定时器回调不再捕获count,也就不存在旧值问题。如果业务逻辑必须依赖最新的props或其他状态,则应将这些值放入依赖数组,使effect在变化时重新执行并生成新闭包。两种思路可以组合使用,复杂Hook建议优先采用函数式更新降低维护成本。
四、在事件监听场景下的处理
自定义Hook经常需要绑定window或document的事件。若监听器内部使用了state,同样会落入闭包陷阱。推荐将处理函数声明在effect内部,并列出依赖,或者利用ref转发。下面演示带依赖的写法:
import { useState, useEffect } from 'react';
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const onResize = () => {
setWidth(window.innerWidth);
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return width;
}
该例子没有依赖旧state,仅在事件触发时读取实时window.innerWidth,因此不会出错。若回调需用到外部变量,则应将其加入依赖,或使用前面提到的ref模式。理解闭包与依赖数组的协作关系,是编写健壮自定义Hook的核心能力。