彻底搞懂JavaScript闭包的原理与实际应用
又踩坑了,循环绑定事件拿不到正确的索引值
这破事卡了我快两个小时,最后发现还是闭包的老问题。简单说就是我在一个 for 循环里给一堆按钮绑定 click 事件,每个按钮应该弹出对应的索引 i,结果不管点哪个都是最后一个值——典型的异步回调拿不到当时循环变量的场景。
代码长这样:
const buttons = document.querySelectorAll('.btn');
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
alert('我是第 ' + i + ' 个按钮');
});
}
你猜怎么着?点任何一个都弹“我是第 5 个按钮”(假设有5个)。这里我踩过好几次坑了,但每次换个场景还是容易懵一下,尤其是赶需求的时候根本不想动脑。
试了三种方法,前两种纯属浪费时间
第一反应是把 var 换成 let,毕竟 ES6 的块级作用域不是说能解决这个问题吗?改完变成:
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
alert('我是第 ' + i + ' 个按钮');
});
}
嘿,还真行了!点哪个就弹哪个。原理其实也简单:用 let 声明的变量在每次循环时都会创建一个新的词法环境,所以每次绑定的事件函数都能捕获到当前的 i 值,本质是形成了多个不同的闭包作用域。
但问题是……项目里有些老页面还在用 IE9,不支持 let。虽然大部分用户已经切到现代浏览器了,但老板不让砍兼容性,所以我得找别的路子。
第二招我用了 bind:
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function(index) {
alert('我是第 ' + index + ' 个按钮');
}.bind(null, i));
}
这个也能跑通。bind 会预设参数,相当于把当时的 i 固定传进去了。不过后来发现 bind 在某些低版本安卓微信里有点怪问题,而且监听器移除起来麻烦(得保存原函数引用),所以没敢上线。
折腾了半天,最后还是回归最稳的方案:立即执行函数(IIFE)。
核心解法就这几行,记住就行
用一个自执行函数包裹事件绑定,把当前的 i 当作参数传进去,形成闭包来保留值:
for (var i = 0; i < buttons.length; i++) {
(function(index) {
buttons[i].addEventListener('click', function() {
alert('我是第 ' + index + ' 个按钮');
});
})(i);
}
这里的关键是,外层函数执行时,参数 index 就被赋值为当前的 i,而内部的事件回调函数作为内层函数,可以访问到这个 index —— 这就是闭包。每轮循环都生成了一个新的函数作用域,所以每个按钮记住的是自己的那份数据。
这段代码兼容性贼好,IE6 都能跑。虽然看起来多了一层括号嵌套有点丑,但在这种小功能里完全无感。上线后测试了几轮没问题,除了一个小细节:如果循环里还操作了其他变量,比如 data 数组,还得注意别在外面改引用。
拓展场景:异步请求里的闭包也容易翻车
类似的坑我还遇到过一次,在循环发 API 请求的时候。伪代码如下:
const userIds = [101, 102, 103];
for (var j = 0; j < userIds.length; j++) {
fetch(https://jztheme.com/api/user/${userIds[j]})
.then(function(response) {
console.log('请求完成,对应 ID 是:' + userIds[j]);
});
}
你以为每个 then 打印的是对应的 ID?错!等接口回来的时候 j 已经变成 3 了,所以全打印 undefined。解决方案一样,用 IIFE 包一层:
for (var j = 0; j < userIds.length; j++) {
(function(id) {
fetch(https://jztheme.com/api/user/${id})
.then(function(response) {
console.log('请求完成,对应 ID 是:' + id);
});
})(userIds[j]);
}
或者更现代一点,直接用数组的 forEach:
userIds.forEach(function(id) {
fetch(https://jztheme.com/api/user/${id})
.then(function(response) {
console.log('请求完成,对应 ID 是:' + id);
});
});
forEach 的回调每次执行都在独立的作用域里,天然避开了 var 全局污染的问题。当然,要是非得用 for…in 或者 for…of,记得还是优先上 let 或 IIFE。
顺带聊聊闭包到底是个啥
很多人讲闭包都说“内层函数访问外层变量”,太抽象。我理解的闭包其实是:当一个函数被从它原本的作用域带走执行时,它依然能记住原来那些变量的值。
比如上面那个 IIFE,里面的 addEventListener 回调本来是在自执行函数里定义的,后来 DOM 事件触发时才被执行,已经脱离了原来的执行上下文,但它还能拿到 index,这就是闭包的能力。
好处是能封装私有变量,坏处是搞不好就内存泄漏。比如你在闭包里引用了很大的 DOM 节点或者数组,又没及时清理引用,GC 就不敢回收,页面跑着跑着就卡了。
但这都不是大问题,现在浏览器优化都挺强的,真出事一般也是你自己逻辑写崩了。关键是理解清楚什么时候会形成闭包,特别是在循环和异步中。
改完之后还有个小毛病,但不影响使用
用 IIFE 方案上线后,QA 提了个 bug:快速连续点击同一个按钮,偶尔会弹两次一样的提示。查了下发现是因为网络波动导致事件被重复绑定?不对,我加了判断只绑一次啊。
后来才发现是 build 流程出了问题,某个脚本被注入了两遍,导致整个初始化逻辑跑了两次,于是每个按钮都被绑了两个相同的 listener。跟闭包没关系,纯属部署脚本写的烂。修复后就没这问题了。
所以说有时候你以为是语言机制的坑,其实是工程流程的锅。但排查时还是得一步步来,先怀疑自己,再怀疑环境。
总结一下,别再被 this 类似问题干趴
遇到循环绑定拿不到正确变量值的情况,优先考虑三点:
- 能不能用 let?能就直接上,最省事
- 要不要兼容老浏览器?要的话上 IIFE,经典可靠
- 能不能换 forEach/map?很多时候换个语法结构比硬改作用域清爽得多
闭包本身不可怕,可怕的是不知道它啥时候悄悄形成了。尤其是 JavaScript 这种到处都是回调的语言,一不小心就会被坑一把。建议平时写代码时多留个心眼:这个函数会不会被延迟执行?它用的变量是不是会被后续逻辑改掉?
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案欢迎评论区交流,比如用 Proxy 或 WeakMap 玩花活的也可以说说看,我挺感兴趣。

暂无评论