闭包陷阱与 useCallback: 为什么你总是拿到旧值?

如果说前一篇解决的是「哪些逻辑不该放进 useEffect」,那这一篇要解决的,就是那些留下来的 effect 和回调,为什么还总是出现一些古怪行为。
你大概见过这些场景:
定时器里打印出来的 state 永远是旧的。
请求回调里拿到的参数不是当前输入框的值。
给子组件传了个函数,结果为了“优化”加上 useCallback 之后,逻辑反而坏了。
ESLint 提示依赖没写全,你一补上就疯狂重跑;你一删掉,又开始出现诡异 bug。
这些问题表面上像是 React 的坑,实际上,它们是 JavaScript 闭包和 React 按渲染快照工作这两件事,叠加在一起的结果。
一、先把问题说透:什么叫“旧值”?
先看一个最经典的例子:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log('count:', count);
}, 2000);
return () => clearInterval(id);
}, []);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}很多人第一次写到这种代码时,直觉会觉得:
count 变了,定时器里应该也能看到最新的 count。
但实际情况是:
effect 只在初次渲染后执行一次。
setInterval里的回调闭包住的是第一次渲染时的 count。所以后面不管你点多少次按钮,控制台里看到的都还是 0。
这就是所谓的 stale closure,也就是“陈旧闭包”问题。
要理解它,有一个非常关键的心智模型:
React 函数组件的每一次渲染,都会产生一套新的 props、state 和函数;你在这次渲染里定义的 effect、回调、事件处理器,拿到的是这一版渲染的快照。
所以不是 React “没更新”,而是你创建出来的那个函数,本来就活在旧的那次渲染里。
二、为什么依赖数组会和闭包扯上关系?
依赖数组的本质,不是“优化项”,而是在告诉 React:
这个 effect / memo / callback 用到了哪些响应式值;
当这些值变化时,你需要重新创建这段逻辑。
TkDodo 对这个问题讲得很透:Hooks 中的依赖关系,本质上就是在管理闭包什么时候该更新。
还是上面那个例子,如果你把 count 写进依赖:
useEffect(() => {
const id = setInterval(() => {
console.log('count:', count);
}, 2000);
return () => clearInterval(id);
}, [count]);这时候行为就变了:
每次 count 变化,effect 都会先清理旧定时器,再创建一个新定时器。
新定时器闭包住的是新的 count。
所以结论不是“依赖数组决定何时执行”,而更准确地说: > 依赖数组决定 React 什么时候应该丢弃旧闭包,换成一份新的闭包。
这也是为什么漏依赖会出 bug。不是因为 React 在惩罚你,而是因为你明明用了一个值,却没告诉 React 这份逻辑应该随着它一起更新。
三、闭包陷阱最常见的 4 种场景
1. 定时器 / 轮询
这个最典型,前面已经看过。 问题根源:定时器回调不会自动“连到最新 state”,它拿的是定义它时的闭包。
错误写法:
useEffect(() => {
const id = setInterval(() => {
fetchData(query);
}, 5000);
return () => clearInterval(id);
}, []);如果 query 会变化,这里大概率就是 bug。
常见解法有两类:
解法 A:老实写依赖
useEffect(() => {
const id = setInterval(() => {
fetchData(query);
}, 5000);
return () => clearInterval(id);
}, [query]);优点:
最直观。
没有隐藏状态。
缺点:
query 每变一次,定时器都会被重建一次。
如果你不希望外部订阅频繁重建,这个做法就不合适。
解法 B:用 ref 保存最新值
const queryRef = useRef(query);
useEffect(() => {
queryRef.current = query;
}, [query]);
useEffect(() => {
const id = setInterval(() => {
fetchData(queryRef.current);
}, 5000);
return () => clearInterval(id);
}, []);这个模式的本质是:
定时器只注册一次;
但它读取的数据来自一个始终更新的 ref,而不是旧闭包。
什么时候用 A,什么时候用 B?
如果允许重建,优先用依赖数组,简单直接。
如果不希望重建外部订阅,再考虑 ref 保存最新值。
2. 异步请求里的旧参数
再看一个更接近业务的例子:
function SearchBox() {
const [keyword, setKeyword] = useState('');
useEffect(() => {
async function search() {
const res = await fetch(`/api/search?q=${keyword}`);
const data = await res.json();
console.log(data);
}
search();
}, []);
}如果这里依赖数组是空的,那这个请求永远只会用初始 keyword。 这不是“偶发 bug”,而是确定性错误。
正确写法通常是:
useEffect(() => {
let ignore = false;
async function search() {
const res = await fetch(`/api/search?q=${keyword}`);
const data = await res.json();
if (!ignore) {
console.log(data);
}
}
search();
return () => {
ignore = true;
};
}, [keyword]);这里顺便处理了另一个问题:竞态。 React 官方文档明确提醒过,effect 里的异步请求除了依赖要正确,还要防止旧请求在新请求之后返回,导致结果倒灌。
3. 事件回调里用旧 state
一个非常隐蔽但常见的坑:
const handleAdd = useCallback(() => {
setCount(count + 1);
}, []);看起来像在“优化”函数引用,实际上逻辑已经坏了:
依赖数组是空的,所以这个 callback 永远是第一次渲染时那一版。
它里的
count永远是初始值。每次点按钮都在执行
setCount(0 + 1)。
这也是为什么很多文章强调:
useCallback不是用来“让逻辑正确”的,而只是让函数引用更稳定;如果逻辑本身依赖没写对,它只会把错误稳定下来。
这里正确的改法有两种。
改法 A:把依赖写全
const handleAdd = useCallback(() => {
setCount(count + 1);
}, [count]);改法 B:如果是基于旧 state 计算新 state,用函数式更新
const handleAdd = useCallback(() => {
setCount(prev => prev + 1);
}, []);第二种通常更好,因为它不再直接依赖 count,也就不容易产生旧值问题。
这条经验特别重要:
只要你的下一个 state 依赖上一个 state,优先用函数式更新,而不是闭包里直接读当前 state。
4. 给子组件传回调时“优化过头”
很多人学了 React.memo 和 useCallback 之后,容易进入一个阶段:
看到函数就包一层 useCallback,好像不包就不专业。
比如:
const handleSelect = useCallback((id: string) => {
onChange(id, filters);
}, []);这段代码的问题不是 useCallback 本身,而是它闭包住了旧的 filters 和 onChange。
如果这两个值会变,那这个 callback 就是错的。
正确写法应该是:
const handleSelect = useCallback((id: string) => {
onChange(id, filters);
}, [onChange, filters]);这时候又有人会说: “那不是每次还是会变吗?包 useCallback 有什么意义?”
答案是:如果依赖本来就总变,那 useCallback 可能本来就没意义。
这是 useCallback 最容易被误解的一点:
它不是“让函数不变”的魔法。
它只是“当依赖不变时,复用旧函数;依赖变了,就给你新函数”。
所以别把它当护身符。
四、useCallback 到底是干什么的?
先说最准确的一句话:
useCallback 用来缓存函数引用,不是用来修复闭包,也不是默认的性能优化按钮。
它主要有两个典型用途。
用途 1:配合 memoized 子组件,避免无意义重渲染
const Item = React.memo(function Item({
onSelect,
item,
}: {
onSelect: (id: string) => void;
item: Item;
}) {
return <button onClick={() => onSelect(item.id)}>{item.name}</button>;
});
function List({ items }: { items: Item[] }) {
const handleSelect = useCallback((id: string) => {
console.log(id);
}, []);
return items.map(item => (
<Item key={item.id} item={item} onSelect={handleSelect} />
));
}这里 useCallback 的意义是:
如果父组件每次 render 都创建新函数,
React.memo子组件可能仍然会因为 props 引用变了而重新渲染。把函数引用稳定下来后,memo 才更容易发挥作用。
用途 2:作为别的 Hook 的依赖,避免无意义重跑
比如:
const fetchUser = useCallback(async () => {
const res = await api.getUser(userId);
setUser(res);
}, [userId]);
useEffect(() => {
fetchUser();
}, [fetchUser]);这时 fetchUser 作为 effect 的依赖,useCallback 让它只在 userId 变时才变化。
但要注意一个更值得推荐的写法:
useEffect(() => {
async function fetchUser() {
const res = await api.getUser(userId);
setUser(res);
}
fetchUser();
}, [userId]);如果函数只在 effect 里用,把它放进 effect 通常更简单,也更不容易纠结依赖。
五、什么时候不该用 useCallback?
这是最实用的一节。
很多团队的问题不是“不会用 useCallback”,而是“用太多了”。
下面这些场景,通常都不值得包:
1. 函数没有传给 memo 子组件,也没有作为依赖
const handleClick = () => {
setOpen(true);
};这种函数完全没必要包 useCallback。
因为:
它不参与性能瓶颈。
它不作为依赖。
包了只会增加认知负担。
2. 包了之后依赖一大串,还是每次都变
const handleSave = useCallback(() => {
submit(form, user, settings, permissions, locale);
}, [form, user, settings, permissions, locale]);如果依赖本来每次都在变,这个 callback 基本等于没缓存。 这时候真正该做的,往往不是继续包,而是重新拆状态、瘦身依赖或重构组件边界。
3. 把 useCallback 当成“解决闭包”的默认手段
这是最大误区。
useCallback 不会自动拿到最新值;
它只是帮你缓存一份函数,而这份函数照样有闭包。
如果依赖写错,useCallback 只会把错误缓存得更久。
所以一个非常重要的判断是:
代码如果“不用 useCallback 就会错”,那通常不是说明你该加 useCallback,而是说明你本来的数据流就有问题。
六、遇到闭包问题时,优先按这个顺序排查
这里给你一套非常适合 code review 和日常排错的顺序。
第一步:先怀疑依赖数组是不是少写了
只要 effect、memo、callback 里用到了响应式值,就先检查依赖是不是完整。
不要第一反应就是:
“这是不是 React 的 bug?”
“是不是 useCallback 不稳定?”
“是不是异步有问题?”
大多数时候,先把依赖写对,问题就消失了。
第二步:如果只是基于旧 state 更新新 state,改成函数式更新
比如:
setTodos(prev => [...prev, newTodo]);
setCount(prev => prev + 1);这种写法可以显著减少你对闭包中当前 state 的依赖。
第三步:如果外部订阅不想重建,用 ref 读最新值
这是处理“定时器 / 监听器 / 第三方订阅”类闭包问题的通用招式。
但注意,ref 是工具,不是逃生通道。 如果只是普通 effect,本来就应该随着依赖更新,那还是优先写依赖。
第四步:只在真的有收益时再上 useCallback
判断标准可以很简单:
有没有配合
React.memo?有没有作为别的 Hook 依赖?
有没有实测性能问题?
如果都没有,那大概率不用。
七、一个完整重构例子:从“旧值地狱”到可维护写法
先看坏代码:
function SearchPanel({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
const handleSearch = useCallback(() => {
onSearch(query);
}, []);
useEffect(() => {
const id = setInterval(() => {
handleSearch();
}, 5000);
return () => clearInterval(id);
}, []);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}这里有两个 stale closure:
handleSearch永远拿的是初始 query。定时器 effect 永远拿的是第一次的 handleSearch。
一种直接但会频繁重建的修法:
function SearchPanel({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
const handleSearch = useCallback(() => {
onSearch(query);
}, [onSearch, query]);
useEffect(() => {
const id = setInterval(() => {
handleSearch();
}, 5000);
return () => clearInterval(id);
}, [handleSearch]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}这是正确的,但每次 query 变化都要重建 interval。
如果业务上希望“定时器只注册一次,但每次都用最新 query”,那就换成 ref 模式:
function SearchPanel({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
const queryRef = useRef(query);
const onSearchRef = useRef(onSearch);
useEffect(() => {
queryRef.current = query;
}, [query]);
useEffect(() => {
onSearchRef.current = onSearch;
}, [onSearch]);
useEffect(() => {
const id = setInterval(() => {
onSearchRef.current(queryRef.current);
}, 5000);
return () => clearInterval(id);
}, []);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}这类代码的关键,不是“高级”,而是你要先搞清楚目标:
你是想让副作用跟着依赖重建?
还是想让副作用只建一次,但读取最新值?
这两个目标不一样,代码就该不一样。
八、这一篇可以带走的 6 条结论
React 里的“旧值”问题,本质上几乎都是闭包问题,不是 React 神秘失灵。
依赖数组的作用,是告诉 React 什么时候该放弃旧闭包,创建新闭包。
只要下一次 state 依赖上一次 state,优先用函数式更新。
外部订阅、定时器这类“不想频繁重建”的场景,可以用 ref 存最新值。
useCallback只缓存函数引用,不会自动帮你拿最新值,也不会修复错误逻辑。只有当函数稳定引用真的有价值时,再上
useCallback;否则它只是给代码增加噪音。
在 Google 上继续关注
把 HeyBinyang 添加为 Google 首选来源
如果你愿意继续在 Google 里读到我的更新,可以把这个站点添加为 preferred source,之后更容易在相关内容场景里看到它。
SHARE
分享
分享这篇文章。