为什么你的JavaScript代码像坏掉的传话筒?
想象一下:你写了一个简单的循环,给按钮添加事件处理程序,然后突然——砰!——所有按钮都显示同一个数字。熟悉吗?如果你曾经遇到过JavaScript循环中的闭包问题,你就知道这种认知失调的时刻。你确信代码是正确的,但浏览器却固执地说:“不,朋友,你搞错了什么。”
在Stack Overflow上,这个问题获得了数十万次浏览。这并不奇怪:在闭包中捕获循环变量是新手甚至经验丰富的开发者最容易掉入的陷阱之一。让我们来弄清楚为什么会发生这种情况,以及更重要的是,如何从性能角度修复它。
经典问题:var与异步
看看这段看似无害的代码:
for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(i); // 会输出什么? }, 100);}如果你回答“0, 1, 2, 3, 4”——恭喜你,你是个乐观主义者。现实是残酷的:控制台会显示5, 5, 5, 5, 5。为什么?因为var具有函数作用域,而不是块级作用域。当setTimeout执行时,循环已经结束,i等于5。所有五个闭包都引用了同一个变量。
这是JavaScript循环中闭包问题的经典例子。解决方案显而易见:需要为每次迭代创建一个新的作用域。
解决方案 #1:IIFE——老派的拐杖
在ES6出现之前,开发者使用立即调用函数表达式(IIFE):
for (var i = 0; i < 5; i++) { (function(j) { setTimeout(function() { console.log(j); }, 100); })(i);}这里的j是调用IIFE时i的副本。每个闭包捕获自己的值。有效吗?是的。美观吗?不太美观。从闭包优化的角度来看,这会在每次迭代中创建一个额外的函数,如果循环很大,可能会影响内存。
解决方案 #2:let——来自ES6的救星
随着let的出现,问题得到了优雅的解决:
for (let i = 0; i < 5; i++) { setTimeout(function() { console.log(i); // 0, 1, 2, 3, 4 }, 100);}let为每次循环迭代创建一个新的绑定。这是ECMAScript规范中内置的行为。每个闭包都获得自己的i副本。性能如何?非常好——引擎会在编译层面进行优化。
但有一个细微之处:如果你在循环内部但在for块外部使用let,行为可能会有所不同。始终将let放在循环头内部。
解决方案 #3:bind——函数式方法
bind方法允许固定参数:
for (var i = 0; i < 5; i++) { setTimeout(function(j) { console.log(j); }.bind(null, i), 100);}这里的bind创建了一个带有预设参数的新函数。这有效,但每次迭代都会创建一个额外的函数。在性能方面,由于内部检查,bind比IIFE稍慢。
解决方案 #4:forEach和其他数组方法
有时避免问题的最佳方法是不要使用for:
[0, 1, 2, 3, 4].forEach(function(i) { setTimeout(function() { console.log(i); }, 100);});forEach通过回调为每次迭代创建一个新的作用域。这不仅解决了