🕰️ 闭包 + setTimeout 的 5 个经典陷阱:为什么定时器看到的永远不是最新的值?

问题场景

写循环、回调、定时器时,经常遇到"变量值不对"的玄学:

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 的回调在未来某个时刻执行 ,那时它读取的,是闭包捕获的那个变量在当前时刻的值,而不是定义时的值。

具体分两种坑:

  1. var 的函数作用域陷阱var 没有块级作用域,整个 for 循环共享同一个 i。回调执行时,for 早跑完了,i 已经是最终值(3),于是全打印 3。
  2. 延迟读取陷阱 :闭包捕获的是变量本身,如果你在一个会变化的地方用 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/回调时,先问自己:我想要的是固化的快照,还是最新的动态值? 想清楚就不踩坑。

相关推荐
lilian2332 小时前
Harmony os 技术实战|拼豆制图27:用单字符编码承载 50 张 70×70 图纸
前端·数据库·华为·harmonyos
CarIise2 小时前
CSS选择器与样式关联
前端·css·tensorflow
里欧跑得慢3 小时前
CSS 模块化架构的演进:BEM、CSS Modules 到 CSS-in-JS 的反思
前端·css·flutter·web·css-in-js
IT_陈寒3 小时前
Vue的computed属性把我坑惨了,原来我一直用错姿势
前端·人工智能·后端
小灰灰搞电子4 小时前
Rust+Slint 实现温度计源码分享
前端·rust·slint
计算机魔术师4 小时前
面壁智能 OpenBMB 推出 MathForm,面向 Lean 4 数学自动形式化的开源框架、数据集与模型
前端
NeilCarmack5 小时前
Deepseek-harness增加桌面版端序列:第 2 讲 · spawn Electron:当前进程如何“交棒“
前端·javascript·electron
上海魁鲸科技有限公司5 小时前
APS高级排产系统到底有什么用?一文讲清功能、选型与落地建议
前端·microsoft·excel
陈随易5 小时前
Bun v1.4 更新总结:把浏览器、图片、定时任务和工程工具都装进一个运行时
前端·后端·程序员