在使用 React Hooks
的过程中 ,如果你发现总是获取不到最新的 state
值,那么你可能遇到了 "过时的闭包" ,英文叫法是 "stale closure"。
如果你在搜索引擎中查找如下关键词,会发现很多相关讨论和解决方案:
- 中文:react 过时的闭包
- 英文:react stale closure
我自己刚开始使用 react
时,也曾对这个问题产生困惑,完全不知道该如何查找资料或解决。希望这篇文章能帮你从实战到原理彻底理解它。
过时的闭包一些常见的场景
即使我用 React
开发多年,但有时还是会被过时的闭包问题难到。
我整理了3个开发过程中实际遇到的案例:
- 延迟执行(
setTimeout
、Promise
等) DOM
事件监听- 防抖函数
每个例子都附带可运行的在线示例,建议动手运行看看效果
延时执行
在线示例:codesandbox.io/p/sandbox/s...
jsx
import { useEffect, useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
const onClick = () => {
setCount(count + 1);
};
useEffect(() => {
// Promise, setInterval 类似
setTimeout(() => {
console.log("count", count); // ❌ stale closure
}, 4000);
}, []);
return <button onClick={onClick}>count: {count}</button>;
}
这里 count
是一个过时的变量, 结合 useRef
来解决这个问题:
jsx
// 设置值的时候
const [count, setCount] = useState(0);
const countRef = useRef(count)
countRef.current = count
// 取值时
setTimeout(() => {
// ✅ 通过引用获取最新值
console.log("count", countRef.current);
},4000)
DOM
事件监听
在线示例:codesandbox.io/p/devbox/li...
jsx
import { useEffect, useState } from "react";
function App() {
const [count,setCount] = useState(0)
const onClickDocument = () => {
console.log("click", count);
setCount(count + 1)
};
useEffect(() => {
// ❌ stale function onClickDocument
document.addEventListener("click", onClickDocument);
return () => {
document.removeEventListener("click", onClickDocument);
};
}, []);
return (
<div className="App">
App
<h3>{count}</h3>
</div>
);
}
export default App;
useEffect
中点击事件调用的 onClickDocument
,就是一个过时的函数,是组件在第一次渲染时创建的。
同样还是结合 useRef
来解决这个问题,只不过这次传给它的参数是一个函数:
jsx
const onClickDocument = () => {
console.log("click", count);
setCount(count + 1);
};
const onClickDocumentRef = useRef(onClickDocument);
onClickDocumentRef.current = onClickDocument;
// 取值时
useEffect(() => {
const handler = () => {
onClickDocumentRef.current();
};
document.addEventListener("click", handler);
return () => {
document.removeEventListener("click", handler);
};
}, []);
防抖函数
在线示例:codesandbox.io/p/sandbox/n...
jsx
import { useState } from "react";
import "./styles.css";
import { debounce } from "./debounce";
export default function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const onClick1 = debounce(() => {
console.log("click1", count1, count2);
setCount1(count1 + 1);
}, 4000);
const onClick2 = () => {
setCount2(count2 + 1);
};
return (
<div className="App">
<h2>count1: {count1}</h2>
<h2>count2: {count2}</h2>
<button onClick={onClick1}>click1</button>
<button onClick={onClick2}>click2 </button>
</div>
);
}
这个例子使用 debounce
函数,实现多次点击 onClick1
,只有最后一次的点击在 4s 后生效。
只点击 click1
的话,你会发现最终的效果就是我们想要的,但其实它只是恰好满足了当前的需求。
现在进行如下操作:
- 点击
click1
- 点击
click2
- 再次点击
click1
关键点在于:第二步 点击 click2
时会由于调用 setCount2
重新执行 App
函数, debounce
函数也会重新执行创建一个全新 的 onClick1
。
此时再点击 click1
执行的是点击 click2
创建的 onClick1
,最终的效果就是 onClick1
会被执行2次。(ps: onClick1
中获取到的 count1
, count2
也可能会是旧值,推荐感兴趣的小伙伴可以在在线编辑器中查看和研究)
那么要如何处理这个问题呢?还是结合 useRef
:
jsx
const onClick1 = () => {
console.log("click1", count1, count2);
setCount1(count1 + 1);
}
const onClick1Ref = useRef(onClick1)
const memoizedOnClick1 = useMemo(() => {
return debounce(() => onClick1Ref.current(), 4000)
},[])
通过 useMemo
传入 []
依赖,来确保 debounce
只执行1次,这样就能保证每次执行的memoizedOnClick1
都是同一个函数。
自定义 hooks
经过上面的几个示例,可以发现解决过时的闭包问题的核心思路是使用 useRef
jsx
const [count,setCount] = useState(0)
const countRef = useRef(count)
countRef.current = count
// 通过 countRef.current 获取最新值
而且不只是变量可以使用 useRef
来处理,函数同样也可以:
jsx
const fn = () => {
console.log('fn')
}
const fnRef = useRef(fn)
fnRef.current = fn
// 通过fnRef.current() 来执行最新的函数
但如果每个组件中都重复写这些逻辑,代码会变得冗余且难以维护。更推荐的方法是抽象出可复用的自定义 hook
,或直接使用成熟社区方案:
Hook 库名称 | 说明 |
---|---|
ahooks |
由阿里巴巴团队维护的高质量 React Hooks 库 |
react-use |
国外非常活跃的通用 React Hooks 工具集 |
我们以 ahooks
为例,看看它是如何解决这些问题的
useLatest
获取变量最新值 github.com/alibaba/hoo...useMemoizedFn
返回缓存函数 github.com/alibaba/hoo...useEventListener
事件监听封装 github.com/alibaba/hoo...useDebounceFn
防抖函数封装 github.com/alibaba/hoo...
可以看到,这些 hooks
的源码都离不开 useRef
的身影:


补充一下:
你也可以通过传入回调形式的
setState
来获取最新的state
:
jsxsetState((count) => count + 1)
但需要注意,只有函数参数
count
是最新的,如果在函数中读取外部变量,可能还是获取到旧值
过时的闭包是如何产生的
过时的闭包是由于 js
语言特性而引发的,大多数情况下我们并不会遇到。但是随着 react hooks
的推出和广泛使用,使它频繁的出现在我们的视线中。
现在我们脱离 React
,看一下原生 js
中的闭包:
jsx
let innerFn;
const outerFn = (value) => {
if (!innerFn) {
innerFn = () => {
console.log(value);
};
}
return innerFn;
};
const firstFn = outerFn("first call");
const secondFn = outerFn("second call");
const thirdFn = outerFn("third call");
firstFn();
secondFn();
thirdFn();
这里的 innerFn
其实和 useEffect
在 deps
为 []
的情况下类似,尽管 outerFn
调用了多次,但是调用的 innerFn
都是在第一次执行 outerFn
时创建的。
为了更好的理解这个问题 ,github.com/swyxio 用原生 js
(仅几十行代码)实现了一个极简版 React
codesandbox.io/p/sandbox/j...

可以看到 useEffect
的 deps
发生改变时才会重新执行 cb
。如果 deps
没有发生改变,那么在函数组件重新 render
时, cb
就会成为过时的函数。
将代码再简化下:
jsx
let deps = null;
const useEffect = (fn, _deps) => {
const isSameDeps = deps ? deps.every((dep, i) => dep === _deps[i]) : false;
if (!isSameDeps) {
fn();
}
deps = _deps;
};
const App = (message) => {
useEffect(() => {
setTimeout(() => {
console.log("message", message);
}, 2000);
}, []);
};
App("render1");
App("render2");
App("render3");
可以看到即使最新执行 App
时传入了 render3
,但是打印的结果还是 render1
结语
在这篇文章中,我用由易到难的三个例子向大家演示了 React
中过时的闭包问题,然后结合 ahooks
介绍如何解决这个问题。
最终我们脱离 React
,从原生 js
的角度来看过时的闭包到底是如何产生的。
希望经过这一系列的探索,能帮助你更好的理解 React