JavaScript循环内的闭包 - 简单实用示例

3229

var funcs = [];
// let's create 3 functions
for (var i = 0; i < 3; i++) {
  // and store them in funcs
  funcs[i] = function() {
    // each should log its value.
    console.log("My value:", i);
  };
}
for (var j = 0; j < 3; j++) {
  // and now let's run each one to see
  funcs[j]();
}

它会输出以下内容:

我的值:3
我的值:3
我的值:3

但我想要输出:

我的值:0
我的值:1
我的值:2


当函数的延迟由事件侦听器引起时,就会出现同样的问题:

var buttons = document.getElementsByTagName("button");
// let's create 3 functions
for (var i = 0; i < buttons.length; i++) {
  // as event listeners
  buttons[i].addEventListener("click", function() {
    // each should log its value.
    console.log("My value:", i);
  });
}
<button>0</button>
<br />
<button>1</button>
<br />
<button>2</button>

...或异步代码,例如使用Promises:

// Some async wait function
const wait = (ms) => new Promise((resolve, reject) => setTimeout(resolve, ms));

for (var i = 0; i < 3; i++) {
  // Log `i` as soon as each promise resolves.
  wait(i * 100).then(() => console.log(i));
}

这也可以在 for infor of 循环中看出:

const arr = [1,2,3];
const fns = [];

for (var i in arr){
  fns.push(() => console.log("index:", i));
}

for (var v of arr){
  fns.push(() => console.log("value:", v));
}

for (const n of arr) {
  var obj = { number: n }; // or new MyLibObject({ ... })
  fns.push(() => console.log("n:", n, "|", "obj:", JSON.stringify(obj)));
}

for(var f of fns){
  f();
}

这个基本问题有什么解决方案?


59
在ES6中,一个简单的解决方案是使用let声明变量_i,它具有循环体作用域。 - Tomas Nikodym
4
JS函数在声明时“闭合”了它们可以访问的作用域,并保留对该作用域的访问权限,即使该作用域中的变量发生变化。上述数组中的每个函数都关闭了全局作用域(全局作用域只是因为它们恰好是在其中声明的作用域)。稍后,这些函数被调用以记录全局作用域中“i”的最新值。这就是JS :) 使用let代替var通过在每次循环运行时创建一个新的作用域,为每个函数创建单独的作用域来解决此问题。其他各种技术使用额外的函数完成相同的操作。 - Costa Michailidis
45个回答

2395

问题在于,每个匿名函数中的变量i都绑定到函数外的同一变量。

ES6解决方法:let

ECMAScript 6(ES6)引入了新的letconst关键字,其作用域与基于var的变量不同。例如,在使用基于let的索引的循环中,每次迭代循环将具有具有循环作用域的新变量i,因此您的代码将按预期工作。有很多资源可用,但我建议2ality的块范围文章作为一个很好的信息来源。

for (let i = 0; i < 3; i++) {
  funcs[i] = function() {
    console.log("My value: " + i);
  };
}

需要注意的是,IE9-IE11和Edge 14之前的版本支持let,但它们在上述示例中存在问题(它们不会每次创建一个新的i,因此所有上述函数将像使用var一样记录3)。Edge 14最终得到了正确解决方案。


ES5.1解决方案:forEach

随着Array.prototype.forEach函数(于2015年)的相对广泛使用,值得注意的是,在涉及主要迭代数组值的情况下,.forEach()提供了一种干净、自然的方法来获取每次迭代的独立闭包。也就是说,假设您有一些包含值(DOM引用、对象等)的数组,并且出现了针对每个元素设置特定回调的问题,您可以这样做:

var someArray = [ /* whatever */ ];
// ...
someArray.forEach(function(arrayElement) {
  // ... code code code for this one element
  someAsynchronousFunction(arrayElement, function() {
    arrayElement.doSomething();
  });
});
.forEach循环中使用的回调函数每次调用都会形成自己的闭包。传递给该处理程序的参数是该特定迭代步骤的数组元素。如果它在异步回调中使用,它不会与其他在迭代的其他步骤上建立的任何回调冲突。如果您正在使用jQuery,则$.each()函数可提供类似功能。

经典解法:闭包

您需要做的是将每个函数内的变量绑定到函数外部的单独且不变的值上:

var funcs = [];

function createfunc(i) {
  return function() {
    console.log("My value: " + i);
  };
}

for (var i = 0; i < 3; i++) {
  funcs[i] = createfunc(i);
}

for (var j = 0; j < 3; j++) {
  // and now let's run each one to see
  funcs[j]();
}

由于JavaScript没有块级作用域,只有函数作用域,通过将函数创建包装在一个新函数中,可以确保“i”的值保持为您预期的那样。


11
“function createfunc(i) { return function() { console.log("My value: " + i); }; }”仍然是闭包,因为它使用变量“i”。 - Incerteza
62
很遗憾,这个答案已经过时了,而且底部正确答案的被忽略了。现在使用 Function.bind() 明显更可取,请参见 https://dev59.com/v3RB5IYBdhLWcg3wAjNH#19323214。 - Wladimir Palant
97
@Wladimir:你认为.bind()是“正确的答案”并不准确。它们各有用处。使用.bind()时,你不能绑定参数而不绑定this值。此外,在调用之间无法更改i参数的副本,有时这是必要的。因此,它们是相当不同的结构,更别提.bind()实现一直以来都很慢了。当然,在简单的例子中,两者都可以工作,但闭包是一个重要的概念需要理解,这就是问题所在。 - cookie monster
11
请停止使用这些针对返回值的函数hack,改用[].forEach或[].map,因为它们可以避免重复使用相同的作用域变量。 - Christian Landgren
46
这些技巧只有在迭代数组时才有用。它们并不是“黑科技”,而是必备的知识。 - user1106925
显示剩余2条评论

422

尝试:

var funcs = [];
    
for (var i = 0; i < 3; i++) {
    funcs[i] = (function(index) {
        return function() {
            console.log("My value: " + index);
        };
    }(i));
}

for (var j = 0; j < 3; j++) {
    funcs[j]();
}

编辑(2014):

个人认为@Aust的有关使用.bind的更近期答案现在是做这种事情的最佳方式。 当你不需要或不想处理 bindthisArg 时,也可以使用 lo-dash/underscore 的 _.partial


6
}(i));的任何解释? - aswzen
4
我认为它将“i”作为参数“index”传递给函数。 - Jet Blue
1
它实际上正在创建本地变量索引。 - Abhishek Singh
3
立即调用函数表达式,也称为IIFE。 (i)是传递给匿名函数表达式的参数,该函数表达式会立即被调用,并且索引从i处设置。 - Eggs

391

还有一种方法尚未提及,那就是使用Function.prototype.bind

var funcs = {};
for (var i = 0; i < 3; i++) {
  funcs[i] = function(x) {
    console.log('My value: ' + x);
  }.bind(this, i);
}
for (var j = 0; j < 3; j++) {
  funcs[j]();
}

更新

正如@squint 和 @mekdev 所指出的,如果您在循环之外创建函数并在循环内绑定结果,则可以获得更好的性能。

function log(x) {
  console.log('My value: ' + x);
}

var funcs = [];

for (var i = 0; i < 3; i++) {
  funcs[i] = log.bind(this, i);
}

for (var j = 0; j < 3; j++) {
  funcs[j]();
}


这也是我最近在做的事情,我也喜欢 lo-dash/underscore 的 _.partial - Bjorn
20
在ECMAScript 6的特性下,“.bind()”方法将基本上过时。此外,每次迭代实际上会创建两个函数。首先是匿名函数,然后是由“ .bind()”生成的函数。更好的做法是在循环外部创建该函数,然后在内部使用“.bind()”。 - user1106925
这会触发JsHint - 不要在循环内部创建函数。我也曾经尝试过这种方式,但在运行代码质量工具之后,发现是不可行的。 - mekdev
5
你们俩都是正确的。我的初始示例是快速编写的,以演示 bind 的使用方式。根据你们的建议,我已经添加了另一个示例。 - Aust
5
我认为,与其浪费在两个O(n)循环上计算,不如只执行以下代码:for (var i = 0; i < 3; i++) { log.call(this, i); } - user2290820
1
.bind() 做了被接受的答案建议的事情,此外还会调整 this - niry

294
使用立即调用函数表达式(IIFE)是封装索引变量的最简单、最易读的方法:

for (var i = 0; i < 3; i++) {

    (function(index) {

        console.log('iterator: ' + index);
        //now you can also loop an ajax call here 
        //without losing track of the iterator value:   $.ajax({});
    
    })(i);

}

这将迭代器i传递到我们定义为index的匿名函数中。这创建了一个闭包,在IIFE内部的任何异步功能中,变量i都会被保存以备后用。


11
为了提高代码的可读性并避免混淆 i 是哪个,我建议将函数参数重命名为 index - Kyle Falconer
5
你如何使用这种技术来定义原问题中描述的数组funcs - Nico
@Nico,与原始问题中所示的方式相同,只是您将使用“index”而不是“i”。 - JLRishe
@Nico for (var i = 0; i < 3; i++) { (function(index) { funcs[index] = function () { console.log("My value: " + index); }; })(i); } - JLRishe
1
@Nico 在 OP 的特定情况下,他们只是在迭代数字,所以这不是使用 .forEach() 的好例子,但很多时候,当一个人开始使用数组时,forEach() 是一个不错的选择,例如:var nums [4, 6, 7]; var funcs = {}; nums.forEach(function (num, i) { funcs[i] = function () { console.log(num); }; }); - JLRishe
显示剩余6条评论

184

来晚了,但我今天在研究这个问题时注意到许多答案没有完全解释 Javascript 如何处理作用域,这实际上就是这个问题的关键。

所以像其他人提到的一样,问题在于内部函数引用了相同的i变量。那么为什么我们不在每次迭代时创建一个新的局部变量,并让内部函数引用它呢?

//overwrite console.log() so you can see the console output
console.log = function(msg) {document.body.innerHTML += '<p>' + msg + '</p>';};

var funcs = {};
for (var i = 0; i < 3; i++) {
    var ilocal = i; //create a new local variable
    funcs[i] = function() {
        console.log("My value: " + ilocal); //each should reference its own local variable
    };
}
for (var j = 0; j < 3; j++) {
    funcs[j]();
}

就像以前一样,每个内部函数输出最后分配给 i 的值,现在每个内部函数只是输出最后分配给 ilocal 的值。但是,每次迭代都应该有自己的 ilocal,不是吗?

事实证明,这就是问题所在。每次迭代都共享同一作用域,因此第一个迭代后的每个迭代只是覆盖了 ilocal。来自 MDN 的解释如下:

重要说明: JavaScript 没有块级作用域(block scope)。块内声明变量,在 ES6 之前只有全局作用域和函数作用域两种,ES6 中引入了块级作用域,也就是 {} 包围的区域。变量在块级作用域中被定义后,仅在该区域内有效。但是,在 JavaScript 中,块语句不会创建新的作用域。尽管“独立”的块是有效的语法,你不应该在 JavaScript 中使用它们,因为它们不会像 C 或 Java 中的那些块一样执行。

再强调一遍:

JavaScript 没有块级作用域。块内声明变量在函数或脚本中的作用域范围内。

我们可以通过在每次迭代之前检查 ilocal 是否已声明来证明这一点:

//overwrite console.log() so you can see the console output
console.log = function(msg) {document.body.innerHTML += '<p>' + msg + '</p>';};

var funcs = {};
for (var i = 0; i < 3; i++) {
  console.log(ilocal);
  var ilocal = i;
}

这正是为什么这个 bug 如此棘手的原因。即使你重新声明一个变量,Javascript 不会抛出错误,而 JSLint 甚至不会警告。这也是为什么最好的解决方法是利用闭包,它本质上是 JavaScript 中的一种思想,即内部函数可以访问外部变量,因为内部作用域“封闭”了外部作用域。

Closures

这也意味着内部函数“持有”外部变量并保持它们存活,即使外部函数返回。为了利用这一点,我们创建并调用一个包装函数纯粹是为了创建一个新的作用域,在新的作用域中声明 ilocal,并返回一个使用 ilocal 的内部函数(下面有更多解释):

//overwrite console.log() so you can see the console output
console.log = function(msg) {document.body.innerHTML += '<p>' + msg + '</p>';};

var funcs = {};
for (var i = 0; i < 3; i++) {
    funcs[i] = (function() { //create a new scope using a wrapper function
        var ilocal = i; //capture i into a local var
        return function() { //return the inner function
            console.log("My value: " + ilocal);
        };
    })(); //remember to run the wrapper function
}
for (var j = 0; j < 3; j++) {
    funcs[j]();
}

在包装函数内创建内部函数会给予其一个私有环境,只有它自己可以访问。这个环境被称为“闭包”。因此,每次我们调用包装函数时,我们都会创建一个具有独立环境的新内部函数,确保ilocal变量不会相互冲突和覆盖。进行一些微小的优化后,最终答案与很多其他SO用户给出的答案相同:


//overwrite console.log() so you can see the console output
console.log = function(msg) {document.body.innerHTML += '<p>' + msg + '</p>';};

var funcs = {};
for (var i = 0; i < 3; i++) {
    funcs[i] = wrapper(i);
}
for (var j = 0; j < 3; j++) {
    funcs[j]();
}
//creates a separate environment for the inner function
function wrapper(ilocal) {
    return function() { //return the inner function
        console.log("My value: " + ilocal);
    };
}

更新

随着ES6现在已成为主流,我们现在可以使用新的let关键字来创建块级作用域变量:

//overwrite console.log() so you can see the console output
console.log = function(msg) {document.body.innerHTML += '<p>' + msg + '</p>';};

var funcs = {};
for (let i = 0; i < 3; i++) { // use "let" to declare "i"
    funcs[i] = function() {
        console.log("My value: " + i); //each should reference its own local variable
    };
}
for (var j = 0; j < 3; j++) { // we can use "var" here without issue
    funcs[j]();
}

看,现在变得多么容易了!有关更多信息,请参见此答案,我的信息基于此。


8
现在JavaScript中有一个叫做“块作用域”的概念,可以使用letconst关键字实现。如果这个答案能够加入这方面的内容,我认为它会更具全球性的实用性。 - user4639281
@TinyGiant 当然,我添加了一些关于 let 的信息,并链接了一个更完整的解释。 - woojoo666
@woojoo666,你的回答是否也适用于在循环中调用两个交替的URL,例如:i=0; while(i < 100) { setTimeout(function(){ window.open("https://www.bbc.com","_self") }, 3000); setTimeout(function(){ window.open("https://www.cnn.com","_self") }, 3000); i++ }?(可以将window.open()替换为getelementbyid......) - nutty about natty
@nuttyaboutnatty 很抱歉回复晚了。你提供的示例代码似乎还没有生效。在你的超时函数中没有使用 i,因此你不需要一个闭包。 - woojoo666
抱歉,我的意思是“你的示例代码似乎已经可以工作了”。 - woojoo666

170

随着ES6的广泛支持,对于这个问题的最佳答案已经发生了变化。ES6为这种情况提供了letconst关键字。我们可以使用let来设置循环作用域变量,而不是破坏闭包。

var funcs = [];

for (let i = 0; i < 3; i++) {          
    funcs[i] = function() {            
      console.log("My value: " + i); 
    };
}

val将指向循环的特定回合中的对象,并且不需要额外的闭包符号即可返回正确的值。这显然极大地简化了这个问题。

constlet类似,但有一个额外的限制:变量名在初始赋值后不能被重新绑定到新的引用。

如果针对最新版本的浏览器进行开发,则现在支持浏览器。目前最新的Firefox、Safari、Edge和Chrome都支持const/let。它也支持Node,在利用Babel等构建工具时可以在任何地方使用。你可以在这里看到一个工作示例: http://jsfiddle.net/ben336/rbU4t/2/

文档在这里:

请注意,IE9-IE11和Edge 14之前支持let,但会出错(它们不会每次创建一个新的i,因此上面的所有函数都会像使用var时一样记录3)。Edge 14最终得到了正确的结果。


不幸的是,“let”仍然没有得到充分支持,特别是在移动设备上。https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let - MattC
3
截至2016年6月,除了iOS Safari、Opera Mini和Safari 9之外的所有主要浏览器版本都支持let。常青浏览器支持它。Babel将正确地转译它,以保持预期行为,而无需开启高兼容模式。 - Dan
@DanPantry 是时候更新一下了 :) 更新后更好地反映了当前情况,包括添加 const 提及、文档链接和更好的兼容性信息。 - Ben McCormick
难道这不是我们使用Babel来转译代码的原因吗?这样那些不支持ES6/7的浏览器也能理解我们在做什么了。 - pixel 67

98

换句话说,i 在你的函数中是在执行函数时绑定的,而不是创建函数时绑定的。

当你创建闭包时,i 是指向外部作用域定义的变量的引用,而不是创建闭包时的副本。它将在执行时评估。

大多数其他答案都提供了通过创建另一个变量来解决问题的方式,以避免值的更改。

只是为了说明清楚,我想加上一些解释。对于解决方案,个人而言,我会选择 Harto 的方法,因为它是这里答案中最清晰易懂的方法。所有发布的代码都可以工作,但我倾向于使用闭包工厂,而不是写一堆注释来解释为什么要声明一个新变量(Freddy 和 1800 年代)或者使用奇怪的嵌入式闭包语法(apphacker)。


80
理解 JavaScript 中变量作用域的关键在于它是基于函数的。这与 C# 不同,后者具有块级作用域,只需将变量复制到 for 循环内部即可。

将代码包装在一个返回函数的函数中,就像 apphacker 的答案一样,这样变量就具有了函数作用域。

此外,还可以使用 let 关键字代替 var,从而利用块级作用域规则。在这种情况下,在 for 循环内定义变量就可以做到这一点。不过,由于兼容性问题,let 关键字并不是一个实际的解决方案。

var funcs = {};

for (var i = 0; i < 3; i++) {
  let index = i; //add this
  funcs[i] = function() {
    console.log("My value: " + index); //change to the copy
  };
}

for (var j = 0; j < 3; j++) {
  funcs[j]();
}


@nickf 用哪个浏览器?就像我说的,它有兼容性问题,我的意思是严重的兼容性问题,比如我不认为 IE 支持 let。 - eglasius
1
@nickf 是的,请查看此参考链接:https://developer.mozilla.org/En/New_in_JavaScript_1.7 ... 检查 let 定义部分,其中有一个在循环内的 onclick 示例。 - eglasius
2
@nickf 嗯,实际上你必须明确指定版本:<script type="application/javascript;version=1.7"/> ... 我实际上还没有在任何地方使用它,因为IE的限制,这不切实际 :( - eglasius
你可以在这里查看不同版本的浏览器支持情况:http://es.wikipedia.org/wiki/Javascript - eglasius

66

这是一种类似于Bjorn的(apphacker)技术变体,允许您在函数内部分配变量值,而不是将其作为参数传递,有时可能更清晰:

var funcs = [];
for (var i = 0; i < 3; i++) {
    funcs[i] = (function() {
        var index = i;
        return function() {
            console.log("My value: " + index);
        }
    })();
}

请注意,无论使用何种技术,index变量都会成为一种静态变量,绑定到内部函数的返回副本上。也就是说,对其值的更改在调用之间被保留。这非常方便。


谢谢,你的解决方案有效。但我想问一下为什么这个能行,但是交换 var 行和 return 行就不行了呢?谢谢! - midnite
如果你交换了 varreturn,那么在返回内部函数之前变量将不会被赋值。 - Boann

64

这里描述了在JavaScript中使用闭包时常见的错误。

函数定义了一个新的环境

考虑以下代码:

function makeCounter()
{
  var obj = {counter: 0};
  return {
    inc: function(){obj.counter ++;},
    get: function(){return obj.counter;}
  };
}

counter1 = makeCounter();
counter2 = makeCounter();

counter1.inc();

alert(counter1.get()); // returns 1
alert(counter2.get()); // returns 0

每次调用makeCounter时,{counter: 0}都会创建一个新对象。此外,还会创建一个新的obj副本来引用新对象。因此,counter1counter2是彼此独立的。

循环中的闭包

在循环中使用闭包是棘手的。

考虑:

var counters = [];

function makeCounters(num)
{
  for (var i = 0; i < num; i++)
  {
    var obj = {counter: 0};
    counters[i] = {
      inc: function(){obj.counter++;},
      get: function(){return obj.counter;}
    }; 
  }
}

makeCounters(2);

counters[0].inc();

alert(counters[0].get()); // returns 1
alert(counters[1].get()); // returns 1

请注意,counters[0]counters[1]并不是独立的。实际上,它们都在同一个obj上操作!
这是因为循环的所有迭代中只有一个obj的副本,可能是出于性能原因。 即使{counter: 0}在每次迭代中创建一个新对象,但同一个obj的副本将只会更新为最新对象的引用。
解决方案是使用另一个辅助函数:
function makeHelper(obj)
{
  return {
    inc: function(){obj.counter++;},
    get: function(){return obj.counter;}
  }; 
}

function makeCounters(num)
{
  for (var i = 0; i < num; i++)
  {
    var obj = {counter: 0};
    counters[i] = makeHelper(obj);
  }
}

这是有效的,因为函数作用域中的局部变量以及函数参数变量在进入时都会被分配新的副本。


小澄清:在闭包循环的第一个示例中,counters[0]和counters[1]不是因为性能原因而独立的。原因是var obj = {counter: 0};在任何代码执行之前就被评估了,如MDN var所述: 无论在哪里声明,var声明都会在任何代码执行之前处理。 - Charidimos

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接