问题场景
写循环、回调、定时器时,经常遇到"变量值不对"的玄学:
js
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000); // 3 3 3 ???
}
或者:
js
function fetchData(id) {
const data = cache[id];
setTimeout(() => {
sendMetric(data); // 用到的是"快照"还是"最新的"?
}, 2000);
}
代码看起来完全合理,结果却全错------定时器/回调执行时的值,跟你写代码时脑子里想的完全不是一回事。这是前端最经典也最隐蔽的坑之一。
原因分析
根源是闭包(closure)捕获的是变量的"引用/作用域",而不是"值" 。setTimeout 的回调在未来某个时刻执行 ,那时它读取的,是闭包捕获的那个变量在当前时刻的值,而不是定义时的值。
具体分两种坑:
var的函数作用域陷阱 :var没有块级作用域,整个for循环共享同一个i。回调执行时,for早跑完了,i已经是最终值(3),于是全打印 3。- 延迟读取陷阱 :闭包捕获的是变量本身,如果你在一个会变化的地方用
let/对象,回调里读到的是执行那一刻 的值,不是调用setTimeout那一刻的值。
解决方案
坑 1:for + var → 用 let 修复(最常用)
js
// 错误
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000); // 3 3 3
}
// 正确:let 有块级作用域,每次循环独立绑定
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000); // 0 1 2
}
let 每次迭代创建一个新的绑定,回调捕获的是当次迭代的 i,问题消失。
坑 2:闭包捕获引用 → 用参数/立即执行捕获"值"
如果用的是 var 或需要显式捕获值,用 IIFE 或额外参数固化:
js
// IIFE 固化
for (var i = 0; i < 3; i++) {
(function (index) {
setTimeout(() => console.log(index), 1000); // 0 1 2
})(i);
}
// 或函数参数(也是固化)
[0, 1, 2].forEach((index) => {
setTimeout(() => console.log(index), 1000); // 0 1 2
});
坑 3:读取"最新"而不是"快照"(反向需求)
有时候你想 读到最新的值(故意为之),但闭包默认捕获的是声明时的绑定 。这时用 let 在外部声明即可:
js
let latest = { count: 0 };
setTimeout(() => {
// 这里读到的是执行时 latest 指向的对象
console.log(latest); // 若外部改了 latest,这里是新的
}, 1000);
latest = { count: 99 }; // 定时器回调读到的是这个
注意:这跟"固化"相反------取决于你想要哪个 。要稍后改的,用外层 let 引用;要固定的,参数固化。
坑 4:对象属性不是快照
js
const obj = { val: 1 };
setTimeout(() => console.log(obj.val), 1000);
obj.val = 99; // 输出 99,不是 1
闭包捕获的是 obj 引用,obj.val 永远是访问时的最新值。要固话就解构:
js
const obj = { val: 1 };
const snapshot = obj.val;
setTimeout(() => console.log(snapshot), 1000); // 1
obj.val = 99;
坑 5:React 里的过期闭包(useEffect/useCallback)
React 组件里最常见的坑:依赖没写全,回调里读到过期的 state:
jsx
function Counter() {
const [count, setCount] = useState(0);
// ❌ 依赖缺 count,回调捕获的是首次渲染的 count(0),永远是旧值
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
// ✅ 用函数式更新,不依赖外部值
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
}
关键 :setCount(count + 1) 里捕获了 count,但因为依赖数组 [],这个回调是第一次渲染的闭包 ,count 永远是 0。用函数式更新 c => c + 1 或把 count 加进依赖数组,才能读到最新的。
要点总结
- 闭包捕获变量作用域 ,不是值------回调执行时读到的是那一刻的值。
var无块级作用域 →for循环共享同一变量 → 用let让每次迭代独立绑定。- 要固化当前值:用 IIFE / 函数参数 / 提前解构,把值快照下来。
- 要读取最新值 :用外层
let引用,别提前固化。 - 对象属性永远是访问时的值,需固话就提前解构。
- React 里过期闭包 :
useEffect/useCallback依赖不全 → 回调捕获旧 state → 用函数式更新或补全依赖。 - 记一句话:"回调将来执行,读到的是当下的值"------所有闭包定时器坑都源于这一句。
一句话:闭包很强大,但它"绑定的是作用域、不是值"。写
setTimeout/回调时,先问自己:我想要的是固化的快照,还是最新的动态值? 想清楚就不踩坑。