给五个按钮绑点击事件,结果点哪个都弹出"第 5 个":我在 JavaScript 里被一个 for 循环里的 var 闭包,坑到怀疑人生的那个下午
需求小得不能再小:页面上动态生成五个按钮,标号 0 到 4,点击第几个,就弹一个提示"你点了第几个按钮"。我三下五除二写完,自测——点第 0 个,弹出"第 5 个";点第 2 个,还是"第 5 个";不管我点哪个,弹出来的永远是雷打不动的"第 5 个"。我愣住了:我明明在循环里,把每个按钮对应的编号都绑上去了啊,怎么所有按钮,都"异口同声"地说自己是第 5 个?
那个下午,我对着这五个"集体失忆、都以为自己是老五"的按钮,从困惑到抓狂,改了无数遍代码,加了无数行打印,愣是没想通。直到我把那个循环变量的作用域,真正想明白,才恍然大悟——原来我栽进了 JavaScript 里一个最经典、最古老、坑过几乎每一个 JS 初学者的坑:用 var 声明的循环变量,和闭包(closure)撞在一起时,会产生的那个"所有闭包共享同一个变量"的陷阱。这个坑虽老,但那天它给我上的这一课,我至今记忆犹新。
故障现场:五个都说自己是"第 5 个"的按钮
我那段出问题的代码,简化之后,就是教科书般的一段:
// 给 5 个按钮绑定点击事件(有致命缺陷的版本)
for (var i = 0; i < 5; i++) {
var btn = document.createElement("button");
btn.textContent = "按钮 " + i;
btn.onclick = function () {
alert("你点了第 " + i + " 个按钮"); // ← 问题就藏在这个 i 里
};
document.body.appendChild(btn);
}
// 现象: 点任何一个按钮, 弹出的都是 "你点了第 5 个按钮"
// 我期望: 点第 0 个弹 "第 0 个", 点第 2 个弹 "第 2 个" ...
这段代码看起来天经地义:循环五次,每次创建一个按钮,给它绑一个点击事件,事件里用 i 来报出"我是第几个"。可运行起来,所有按钮的点击事件,弹出的全是"第 5 个"。我最初的猜测是:"是不是我每次循环,都把 i 的值'拍快照'存进事件里了,但存错了?" 于是我加了打印,想看看绑定的瞬间 i 是多少:
for (var i = 0; i < 5; i++) {
var btn = document.createElement("button");
btn.onclick = function () {
alert("你点了第 " + i + " 个按钮");
};
console.log("绑定时, i =", i); // 绑定的瞬间打印 i
document.body.appendChild(btn);
}
// 控制台输出: 绑定时 i=0, 绑定时 i=1, ... 绑定时 i=4 ← 绑定时明明是对的!
// 可点击时, i 却变成了 5 ??
这下我更懵了:绑定事件的那一刻,打印出来的 i 明明是 0、1、2、3、4,一个不差;可等我点击按钮、事件真正执行的时候,i 却统一变成了 5。这中间到底发生了什么?是谁,在绑定之后、点击之前,把所有事件里的 i 都偷偷改成了 5?这个"绑定时正确、执行时却全变样"的诡异现象,像一团迷雾,把我困在了原地。
第一件事:搞懂 var 的"函数作用域"和闭包"捕获变量"
我意识到,要破这个谜,我必须搞懂两个被我一直模模糊糊带过去的概念:一是 var 声明的变量,它的作用域到底是什么;二是闭包,到底"捕获"的是什么。当我把这两件事真正想透,迷雾瞬间散开了。
// 真相1: var 是"函数作用域", 不是"块作用域"!
// 这意味着, for 循环里的 var i, 并不是"每次循环一个新的 i",
// 而是"整个循环, 从头到尾, 都只有同一个 i"!
for (var i = 0; i < 5; i++) {
// 这里的 i, 5 次循环用的是同一个变量, 不是 5 个独立的 i
}
// 循环结束后, i 依然存在(函数作用域), 且它的值是 5 (退出循环时 i 刚好等于5)
// 真相2: 闭包捕获的是"变量本身"(的引用), 不是"绑定时变量的值"!
// 那 5 个 onclick 函数, 都是闭包, 它们捕获的都是"那同一个 i 变量",
// 而不是各自"绑定时 i 的那个值"。
// 所以: 当你点击时, 5 个函数去读那"同一个 i", 而此时循环早已结束、
// i 早已是 5 —— 于是所有函数, 读到的都是 5 !
真相大白!原来,问题出在两个我没理解透的关键点叠加上。第一,var 是"函数作用域"而非"块作用域"——这意味着 for 循环里的 var i,在整个循环过程中,自始至终都是"同一个变量",而不是我以为的"每次循环都有一个新的、独立的 i"。第二,闭包捕获的,是"变量本身(的引用)",而不是"创建闭包那一刻该变量的值"。这两点一叠加,谜底就清晰了:那五个 onclick 函数,捕获的全都是"那同一个 i 变量";绑定时它们没读 i(所以绑定时打印是对的),等到点击、函数真正执行去读 i 时,循环早就跑完了,那"唯一的 i"早已停在了退出循环时的值——5。于是五个函数,读到的都是同一个 5。我那五个按钮,不是"都以为自己是老五",而是它们根本"共用着同一个号码牌",而这个号码牌,最后定格在了 5。
第二件事:最优雅的正解——把 var 换成 let
搞懂了根因——"五个闭包共享同一个 var i"——解法的方向就明确了:我得让每次循环,都有一个"独立的、专属于这次循环"的变量,让五个闭包各自捕获各自的那一个,而不是共享同一个。而在现代 JavaScript 里,实现这一点最优雅、最简单的方式,只需要改一个词:把 var 换成 let。
// 正解: 把 var i 改成 let i —— 一字之差, 彻底解决!
for (let i = 0; i < 5; i++) { // ← var 改成 let
var btn = document.createElement("button");
btn.textContent = "按钮 " + i;
btn.onclick = function () {
alert("你点了第 " + i + " 个按钮");
};
document.body.appendChild(btn);
}
// 现在: 点第 0 个弹 "第 0 个", 点第 2 个弹 "第 2 个" ... 完美!
// 为什么 let 就好了? 因为 let 是"块作用域", 而且对于 for 循环,
// JavaScript 有个特殊规定: 每一次循环迭代, 都会创建一个"全新的、
// 独立的" i ! 所以这 5 次循环, 其实是 5 个各自独立的 i (值分别是0~4),
// 5 个闭包, 就各自捕获了各自那个独立的 i —— 互不干扰!
这一字之差,为什么有如此神奇的效果?关键就在 let 和 var 在作用域上的根本区别:var 是"函数作用域"——整个循环只有一个 i;而 let 是"块作用域",并且对于 for 循环,JavaScript 规范有一条特殊而贴心的规定:每一次循环迭代,都会为 let 声明的循环变量,创建一个全新的、独立的绑定。也就是说,用 let 时,这五次循环里的 i,是五个彼此独立的变量(它们的值恰好是 0、1、2、3、4),于是五个闭包,就分别捕获了属于自己那次迭代的、独立的 i——它们再也不共享同一个变量,自然也就各报各的号、互不干扰了。可以说,let 的出现,正是为了从语言层面,优雅地解决 var 留下的这一类"循环闭包共享变量"的经典坑——这也是为什么现代 JavaScript 强烈建议用 let/const 取代 var。
下面这张图,把 var 的"共享一个变量"和 let 的"每次迭代独立变量",直观地对比出来:
左边红色那条,是 var 的坑:一个变量被五个闭包共享,最后都读到循环结束时的 5。右边绿色那条,是 let 的解:每次迭代一个独立变量,五个闭包各捕获各的值。两条路的分岔点,只在 var 和 let 这一个词上。
第三件事:let 之前,前辈们是怎么填这个坑的?
虽然 let 是如今最优雅的解法,但我很好奇:在 let 还没出现的年代(ES6 之前),前辈们面对这个坑,是怎么解决的?了解这些"老办法",不仅是温故知新,更能让我对"如何创建一个独立作用域"这件事,理解得更深。我把几种经典的老解法也都摸了一遍:
// 老解法1: 用 IIFE(立即执行函数表达式)制造一个独立作用域
for (var i = 0; i < 5; i++) {
(function (j) { // 立即执行, 把当前 i 的值作为参数 j 传进去
btn.onclick = function () {
alert("你点了第 " + j + " 个按钮"); // 闭包捕获的是参数 j, 每次独立
};
})(i); // ← 把当前 i 的值, 当场"快照"传进去
}
// 老解法2: 利用函数参数天然是"独立副本"的特性(同理)
function bind(btn, j) {
btn.onclick = function () { alert("你点了第 " + j + " 个按钮"); };
}
for (var i = 0; i < 5; i++) { bind(btn, i); } // 每次调用, j 是独立的
// 老解法3 (ES5): forEach 的回调天然每次一个独立作用域
[0, 1, 2, 3, 4].forEach(function (i) {
btn.onclick = function () { alert("你点了第 " + i + " 个按钮"); };
});
这几种老办法,虽然写法各异,但它们的内核,和 let 的解法是完全一致的——都是想方设法地,为每一次循环,创造一个"独立的作用域",让每个闭包,都能捕获到一个属于它自己的、独立的变量,而不是共享那个唯一的 var i。老解法1(IIFE):用一个立即执行的函数,把当前 i 的值作为参数 j 传进去——函数参数天然是"调用时的独立副本",于是每个闭包捕获的 j 都是独立的。老解法2:把绑定逻辑抽成一个函数,利用"函数参数是独立副本"这个同样的原理。老解法3(forEach):forEach 的回调函数,每次迭代都是一次独立的函数调用,天然就有独立的作用域和参数。看懂了这三种老办法都在"制造独立作用域",你就会明白 let 的伟大之处:它把前辈们需要用 IIFE 等技巧才能费力实现的"每次迭代独立变量",直接做成了语言内置的、自动的行为——你只管写 let,语言帮你把"独立作用域"安排得明明白白。
第四件事:这个坑的"亲戚"——异步循环里同样会咬你
填平了按钮事件这个坑,我立刻警觉:这个"var 循环变量被共享"的问题,本质上发生在任何"循环里创建了延迟执行的函数(闭包)"的场景。而现代前端里,这种场景最常见的,就是异步——setTimeout、Promise、各种回调。我一验证,果然,它们全都是同一个坑的"亲戚":
// 亲戚坑1: setTimeout 经典面试题 —— 用 var, 全部打印 5
for (var i = 0; i < 5; i++) {
setTimeout(function () { console.log(i); }, 100);
}
// 输出: 5 5 5 5 5 (同样: 5个回调共享同一个 i, 100ms后i已是5)
// 改成 let, 立刻正常:
for (let i = 0; i < 5; i++) {
setTimeout(function () { console.log(i); }, 100);
}
// 输出: 0 1 2 3 4 ✓
// 亲戚坑2: 循环里发异步请求, 想记住"这是第几个请求"
for (var i = 0; i < urls.length; i++) {
fetch(urls[i]).then(function (res) {
console.log("第", i, "个请求回来了"); // ✗ 全是 urls.length, 不是各自的序号
});
}
// 正解: 用 let, 或干脆用 for...of / forEach / map 配合 async
// 亲戚坑3: var 在循环外"泄露"—— 循环结束后 i 还活着
for (var i = 0; i < 5; i++) { /* ... */ }
console.log(i); // 5 (var 泄露到了循环外! let 则会在这里报 i is not defined)
这几个"亲戚坑",根源和最初的按钮坑完全一致——都是"循环里创建的延迟函数(闭包),共享了那个唯一的 var 循环变量",等到延迟函数真正执行时,循环早已结束,变量早已停在了终值。亲戚坑1(setTimeout):这道经典面试题,本质和按钮事件一模一样,setTimeout 的回调是延迟 100ms 执行的闭包,那时 i 早是 5。亲戚坑2(异步请求):循环里发请求,想在回调里用 i 记录"这是第几个",同样会因共享而全部错成终值。亲戚坑3(var 泄露):这是 var 函数作用域的另一个副作用——循环结束后 i 居然还活着、还能被访问到(let 则会规规矩矩地在循环外就"消失")。把 var 和 let 在这些维度上的区别,整理成一张表:
| 维度 | var | let |
|---|---|---|
| 作用域 | 函数作用域 | 块作用域({} 内) |
| for 循环每次迭代 | 共享同一个变量 | 每次迭代独立的新变量 |
| 循环+闭包/异步 | 踩坑(都读到终值) | 正确(各读各的) |
| 循环结束后变量 | 泄露, 仍可访问 | 已销毁, 访问报错 |
| 变量提升(hoisting) | 提升且初始化为 undefined | 提升但有"暂时性死区" |
| 能否重复声明 | 能(易出错) | 不能(同作用域报错) |
第五件事:理解了闭包,才算真正理解了 JavaScript 的一半
这次踩坑,看似是栽在一个 var 上,但它真正逼着我搞懂的,是 JavaScript 里那个既核心又微妙的概念——闭包。我趁机把闭包这件事,从"坑"的角度,彻底捋了一遍,也理解了它"捕获变量而非值"这个特性,既是坑的来源,也是它强大威力的来源:
// 闭包"捕获变量"的特性, 是坑, 但也是强大的能力来源:
// 正面应用1: 用闭包做"私有变量"(模块模式)
function createCounter() {
let count = 0; // 这个 count 被闭包"保护"起来, 外部无法直接访问
return {
inc: function () { return ++count; }, // 闭包捕获 count, 持续访问它
get: function () { return count; },
};
}
const c = createCounter();
c.inc(); c.inc();
console.log(c.get()); // 2 (count 被安全地封装在闭包里)
// 正面应用2: 用闭包做"函数工厂"
function multiplier(factor) {
return function (x) { return x * factor; }; // 闭包记住了 factor
}
const double = multiplier(2);
console.log(double(5)); // 10 (闭包"记住"了它被创建时的 factor=2)
当我从"坑"和"应用"两面都看过闭包之后,我对它的理解,从"一个会坑我的诡异机制",升级成了"一个我能驾驭的强大工具"。闭包的核心,就是"一个函数,记住并能持续访问它被创建时所在的那个作用域里的变量"——这个'记住作用域'的能力,正是 JavaScript 里无数强大模式(私有变量封装、函数工厂、柯里化、回调与事件、模块化……)的基石。而我那个 var 循环坑,不过是这个强大能力的一个"阴暗面":当被记住的"变量",是一个被多个闭包共享的、还在变化的变量时,就会出问题。所以,理解闭包坑的过程,恰恰是真正理解闭包的过程——你只有搞懂了它"捕获的是变量、是活的引用,而不是某一刻的值快照",才能既避开它的坑,又用好它的强大。把闭包相关的"坑"与"用",对照整理:
| 闭包的特性 | 当它是"坑" | 当它是"能力" |
|---|---|---|
| 捕获变量引用(非值) | 循环里共享变量, 都读到终值 | 能持续追踪、读写外层状态 |
| 记住外层作用域 | 意外延长变量生命周期(内存) | 实现私有变量、数据封装 |
| 延迟执行时再读变量 | 异步回调读到的是"未来"的值 | 函数工厂、柯里化、惰性求值 |
| 每次调用产生新闭包 | 循环里大量闭包占内存 | 每个回调持有独立的私有状态 |
这张表让我看清:闭包的"坑"和"能力",其实是同一枚硬币的两面——都源于"函数能记住并持续访问它创建时的作用域变量"这一个核心特性。用得不明就里,它就是坑(var 循环全读终值);用得清醒明白,它就是 JavaScript 最锋利的工具之一(私有封装、函数工厂)。区别只在于:你是否真正理解了它捕获的是"活的变量引用",并据此清醒地预判它在每个场景下的行为。这次踩坑最大的收获,不是记住了"循环里要用 let"这条规则,而是借此真正吃透了闭包——而吃透闭包,几乎可以说,是真正理解 JavaScript 这门语言的一半。
一张"循环里创建函数,会不会踩闭包坑"的决策图
把这次踩坑沉淀成一张图。每当你在循环里创建会"延迟执行"的函数时,照着它判断:
这张图的核心判断只有一步:循环里创建的延迟函数,有没有用到循环变量?用到了,循环变量是不是 var?是 var,就铁定踩坑,改 let 或制造独立作用域。而最省心的现代实践是:彻底告别 var,一律用 let/const,遍历优先用 for...of/forEach/map。把这套判断变成习惯,这个老坑就再也碰不到你。
我立下的几条作用域与闭包使用规矩
这次"五个按钮都报第 5 个"的事故后,我给自己立了几条规矩:
- 彻底告别 var:新代码一律用
let/const,默认用const、需要重新赋值才用let,var直接从我的字典里划掉。 - 循环创建函数必查变量捕获:循环里凡是创建事件处理器/
setTimeout/异步回调,就停下来想一秒"这个闭包捕获的变量,是不是每次迭代独立的"。 - 遍历优先用 for...of / forEach / map:它们的每次迭代天然是独立作用域,既避坑又更声明式、更易读。
- 清醒认识"闭包捕获的是变量引用":牢记闭包捕获的是"活的变量"而非"当时的值",据此预判延迟执行时它会读到什么。
- 需要"值快照"就显式复制:确实想让闭包记住"此刻的值",就用
let/参数/解构等方式,显式地为它制造一份独立副本。 - 用闭包做封装要有意识:用闭包实现私有变量、函数工厂时,清楚自己在利用它"记住作用域"的能力,并留意它对变量生命周期(内存)的影响。
- 面试题当自查题:把"for+var+setTimeout 输出什么"这类经典题,当成检验自己作用域/闭包理解是否扎实的试金石。
这几条里,第一条"告别 var"是最一劳永逸的。而贯穿所有规矩的那条主线,是对"作用域"和"变量生命周期"的清醒认知。我这次栽跟头,根子上是我对"我写下的这个变量,它的作用域有多大、它在循环的每次迭代里是同一个还是不同的、它在延迟函数执行时会是什么值"这一连串问题,缺乏清晰的把握——我只是凭着"循环里 i 应该每次不一样"的模糊直觉在写,而这个直觉,恰恰和 var 的真实行为相反。真正搞清楚每一个变量的'作用域边界'和'生命周期',是写出行为可预测的代码的根基;而闭包,正是把'作用域'这件事的重要性,放大到极致的那个机制。
写在最后:经典的坑之所以经典,是因为它直指语言的内核
这次被一个 for 循环里的 var 坑到的经历,让我对那些"老掉牙的经典坑",生出了一份新的尊重。一个坑之所以能成为"经典"、能坑过一代又一代的程序员,往往不是因为它有多刁钻、多罕见,恰恰相反,是因为它直指一门语言最核心、最本质的某个机制——你之所以会踩它,正是因为你对那个核心机制的理解还不够透;而你一旦真正填平了它,你对这门语言的理解,也就跟着上了一个台阶。这个 var 闭包坑,表面上是个"小陷阱",可它背后牵连的,是 JavaScript 最核心的几个概念:作用域(函数作用域 vs 块作用域)、闭包(捕获变量引用)、变量生命周期、异步执行时机——这些,几乎就是 JavaScript 这门语言的"地基"。我栽进这个坑,本质上是我对这片地基,还踩得不够实。
想通这一点,我对"踩经典坑"这件事,有了一种近乎感激的心态。那些经典的坑,与其说是我们前进路上的'绊脚石',不如说是这门语言为我们精心设置的'必修课'——它们用一次具体的、让你印象深刻的'疼',逼着你去把某个你一直含糊带过的核心概念,真正地搞懂、吃透。我若不是被这五个按钮坑得抓耳挠腮,我可能至今都只是"知道"闭包这个词,却从未真正理解它"捕获变量引用"的精髓;是这个坑,逼我把作用域、闭包、异步时机这些 JavaScript 的核心,一次性地、扎扎实实地补了一遍课。从这个意义上说,踩中一个经典坑,然后把它彻底搞懂,是一个程序员理解一门语言的过程里,最高效、也最深刻的学习方式之一。
所以,如果你也在学习 JavaScript、或任何一门语言,我想把这次踩坑最想说的话送给你:别轻视、更别回避那些'经典的坑',反而要主动地去拥抱它们、彻底地搞懂它们。当你遇到一个"很多人都踩过"的坑时,别只满足于从网上抄一个"改成 let 就好了"的结论草草了事——要顺着这个坑,一路深挖下去,去搞懂它背后那个核心机制到底是怎么运作的。因为每一个经典的坑背后,都藏着一节这门语言最重要的必修课;你把这节课真正上完了,你对这门语言的掌握,就比那些只会'绕开坑'的人,深了整整一层。那五个都报"第 5 个"的按钮,最终教给我的,远不止"循环里要用 let"——它借由一个具体的疼,把闭包与作用域这门 JavaScript 的核心课,深深地刻进了我的理解里。而这,正是一个经典的坑,所能给予一个程序员的、最宝贵的馈赠。
—— 别看了 · 2026