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个回答

53
最简单的解决方案是使用以下内容替换原文中的代码:

使用:

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

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

循环中创建的匿名函数共享同一个闭包,在该闭包中,i 的值是相同的,因此会触发三次“2”的提醒。使用以下方法可以避免共享闭包:

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

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

这个想法是,使用一个IIFE(立即调用的函数表达式)来封装整个for循环的主体,并将new_i作为参数传递并捕获它作为i。由于匿名函数会立即执行,因此每个在匿名函数内定义的函数的i值都是不同的。

这个解决方案似乎适用于任何存在该问题的原始代码,因为它对原始代码的更改要求最小。事实上,这是有意设计的,根本不应该成为问题!


2
曾经在一本书中读到过类似的内容。我也更喜欢这种方式,因为你不必(太多地)修改现有代码,并且一旦学会了自调用函数模式,为什么要这样做就变得显而易见:为了将该变量限制在新创建的作用域中。 - DanMan
1
@DanMan 谢谢。自调用匿名函数是处理 JavaScript 缺乏块级变量作用域的很好的方式。 - Kemal Dağ
3
自调用或自触发并不是这种技术的合适术语,更准确的术语是IIFE(即时调用函数表达式)。参考:http://benalman.com/news/2010/11/immediately-invoked-function-expression/。 - jherax

35

这里有一个简单的解决方案,使用 forEach(适用于IE9及以上版本):

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

打印:

My value: 0
My value: 1
My value: 2

34

试试这个更简短的方法

  • 无需使用数组

  • 无需额外的循环


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

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

http://jsfiddle.net/7P6EN/


1
你的解决方案似乎输出正确,但不必要地使用了函数,为什么不直接使用console.log输出呢?原始问题是关于创建具有相同闭包的匿名函数的。问题在于,由于它们具有单个闭包,因此每个函数的i值都相同。希望你明白了。 - Kemal Dağ

31

该代码的主要问题在于OP演示的代码中,直到第二个循环才读取 i 。 为了说明这一点,请想象在代码内部看到一个错误

funcs[i] = function() {            // and store them in funcs
    throw new Error("test");
    console.log("My value: " + i); // each should log its value.
};
实际上,直到执行 funcs[someIndex]()时才会出现错误。按照这个逻辑,可以看出i的值也直到这个时候才被收集。一旦原始循环结束, i++ i带到3的值,导致条件 i < 3 失败并且循环结束。此时,i3,因此每次使用 funcs[someIndex]()时评估i都是3。

要解决这个问题,必须在遇到i时对其进行评估。请注意,已经以funcs[i]的形式(其中有3个唯一的索引)发生了这种情况。有几种捕获此值的方法,其中一种是将其作为参数传递给函数,这在此处已经以几种方式显示。

另一个选项是构造一个函数对象,它将能够关闭该变量。可以通过以下方式实现:

jsFiddle演示

funcs[i] = new function() {   
    var closedVariable = i;
    return function(){
        console.log("My value: " + closedVariable); 
    };
};

25

JavaScript函数在声明时“闭合”了它们可以访问的作用域,并即使该作用域中的变量发生变化,仍保留对该作用域的访问权限。

var funcs = []

for (var i = 0; i < 3; i += 1) {
  funcs[i] = function () {
    console.log(i)
  }
}

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

上面数组中的每个函数都在全局作用域中被闭合(全局,仅因它们声明的作用域是全局)。

稍后,调用这些函数时会记录全局作用域中 i 最新的值。这就是闭包的神奇所在,也是让人沮丧的地方。

“JavaScript 函数闭合它们所声明的作用域,并且即使该作用域内的变量值发生了变化,也仍然可以访问该作用域。”

使用 let 替代 var 可以解决这个问题,因为每次 for 循环运行时都会创建一个新的作用域,从而为每个函数创建一个分离的作用域。各种其他技术也可以通过额外的函数来实现这一点。

var funcs = []

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

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

(let 使变量块级作用域。块由花括号表示,但在for循环的情况下,初始化变量i被认为是在花括号中声明的。)


1
直到我阅读了这个答案,我才理解这个概念。它涉及到一个非常重要的点-i的值被设置为全局范围。当for循环运行结束时,全局变量i的值现在是3。因此,每当该函数在数组中调用(例如使用funcs[j]),该函数中的i引用的是全局i变量(即3)。 - Modermo

16

在阅读了各种解决方案之后,我想要补充的是,这些解决方案有效的原因在于依赖作用域链的概念。这是JavaScript在执行过程中解析变量的方式。

  • 每个函数定义形成一个作用域,包括所有被vararguments声明的本地变量。
  • 如果我们在一个(外部)函数内定义了另一个内部函数,就会形成一个链,在执行期间将被使用。
  • 当一个函数被执行时,运行时通过搜索作用域链来评估变量。如果在链的某个点上找到了一个变量,它将停止搜索并使用它;否则,它将继续搜索直到达到属于window的全局作用域。

在初始代码中:

funcs = {};
for (var i = 0; i < 3; i++) {         
  funcs[i] = function inner() {        // function inner's scope contains nothing
    console.log("My value: " + i);    
  };
}
console.log(window.i)                  // test value 'i', print 3

在执行funcs时,作用域链为function inner -> global。由于变量ifunction inner中找不到(既没有使用var声明也没有作为参数传递),它会继续查找,直到最终在全局作用域中找到了window.i的值。

通过将其包装在外部函数中,可以显式地定义一个帮助函数,如harto所做的那样,或者像Bjorn所做的那样使用匿名函数:

funcs = {};
function outer(i) {              // function outer's scope contains 'i'
  return function inner() {      // function inner, closure created
   console.log("My value: " + i);
  };
}
for (var i = 0; i < 3; i++) {
  funcs[i] = outer(i);
}
console.log(window.i)          // print 3 still

当执行funcs函数时,现在作用域链将变为function inner -> function outer。这时,i可以在外部函数的作用域中找到,在for循环中执行3次,每次都正确地绑定了i的值。此时它不会使用内部执行时window.i的值。

更多细节可以在此处找到。其中包括创建循环中闭包的常见错误,以及我们为什么需要闭包和性能考虑。


我们很少在实际中编写这个代码示例,但我认为它是一个很好的例子,可以理解其基本原理。一旦我们有了范围和它们如何链接在一起的想法,就更清楚地看到为什么其他“现代”方法,如Array.prototype.forEach(function callback(el) {})自然而然地工作:传递的回调自然形成包装作用域,并在forEach的每次迭代中正确绑定el。因此,定义在回调中的每个内部函数都将能够使用正确的el值。 - wpding

15

通过ES6的新特性,块级作用域得到了管理:

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

原文中的代码使用var被替换为let


const 提供相同的结果,并且应当在变量的值不会改变时使用。然而,在 for 循环初始化器内部使用 const 在 Firefox 中实现不正确,且尚未修复。它被声明在块外部,而不是块内部,这导致对变量的重新声明,从而导致错误。在初始化器内部使用 let 在 Firefox 中实现正确,所以不必担心。 - user4639281

12

我们将逐个检查使用 varlet 的实际情况。

案例1:使用 var

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

按下F12打开你的Chrome控制台窗口,然后刷新页面。展开数组内的每个第三个函数,你会看到一个名为[[Scopes]]的属性。展开它,你会看到一个称为"Global"的数组对象,再展开它,你会找到一个名为'i'的属性,它的值为3。

enter image description here

enter image description here

结论:

  1. 当你使用'var'在函数外部声明变量时,它将成为全局变量(你可以在控制台窗口中输入iwindow.i来检查它,它会返回3)。
  2. 你声明的匿名函数不会在不调用和检查函数内部的值的情况下执行。
  3. 当你调用函数时,console.log("My value: " + i)将从其Global对象中获取值并显示结果。

CASE2:使用let

现在用'let'替换'var'

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

重复上述步骤,进入作用域。现在您将看到两个对象"Block""Global"。现在展开Block对象,您会看到 'i' 在那里被定义,奇怪的是,对于每个函数,i的值都不同(0、1、2)。

enter image description here

结论:

当您使用'let'关键字声明变量时,即使在函数外部但在循环内部,该变量也不会成为全局变量,而是成为仅适用于同一函数的块级变量。这就是我们在调用函数时为什么会得到每个函数i不同的值的原因。

有关闭包如何工作的更多详细信息,请查看精彩视频教程https://youtu.be/71AtaJpJHw0


11

我很惊讶目前还没有人建议使用forEach函数来更好地避免(重新)使用本地变量。实际上,出于这个原因,我不再使用for(var i ...)了。

[0,2,3].forEach(function(i){ console.log('My value:', i); });
// My value: 0
// My value: 2
// My value: 3

// 使用 forEach 替代 map 进行编辑。


3
如果实际上并没有要进行映射的话,使用.forEach()是更好的选择。达里尔在你发布这篇文章7个月前就建议过了,因此没有什么可惊讶的。 - JLRishe
这个问题不是关于循环遍历数组的。 - jherax
他想创建一个函数数组,这个例子展示了如何做到这一点而不涉及全局变量。 - Christian Landgren

10

你原始的示例无法工作的原因是,在循环中创建的所有闭包引用了同一个框架。实际上,一个对象有3个方法,只有一个 i 变量。它们都打印出相同的值。


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