【简译】JavaScript闭包致使的闭合变量问题以及解决方法

本文是翻译此文php

预先阅读此文:闭合循环变量时被认为有害的(closing over the loop variable considered harmful)闭包

JavaScript也有一样的问题。考虑:ide

function hookupevents() {
 for (var i = 0; i < 4; i++) {
  document.getElementById("myButton" + i)
   .addEventListener("click",
         function() { alert(i); });
 }
}

当你在一个循环中涉及event handler时,你就会遇到这样的代码,这是最多见的问题。所以,我用这个问题做为例子。不管你点击哪个button,他们都显示4,而不是相应的button 号码。在预先阅读连接中给出了缘由:你闭合的是循环变量,所以,在函数真正执行时,变量i的值是4,由于循环在此已经结束了。麻烦的是修复这个问题。在C#中,你能够复制这个值给一个在这个做用域中的局部变量并捕获这个局部变量,可是在JavaScript中行不通:函数

function hookupevents() {
 for (var i = 0; i < 4; i++) {
  var j = i;//添加一个变量
  document.getElementById("myButton" + i)
   .addEventListener("click",
         function() { alert(j); });
 }
}

如今,点击按钮将显示3而不是4。缘由是JavaScript变量的做用域是函数做用域,而不是块做用域。即便你再一个块中定义了var j,这个变量的做用域也贯穿整个函数。换句话说,上面这个代码相似于下面:oop

function hookupevents() {
 var j;
 for (var i = 0; i < 4; i++) {
  j = i;
  document.getElementById("myButton" + i)
   .addEventListener("click",
         function() { alert(j); });
 }
}

下面这个函数强调了“变量提高(variable declaration hoisting)”这个行为:spa

function strange() {
 k = 42;
 for (i = 0; i < 4; i++) {
  var k;
  alert(k);
 }
}

这个函数显示42四次,由于变量K在整个函数中始终指向同一个变量K,即便他已经被声明过。没错,JavaScript容许你在声明一个变量前就使用它。JavaScript的变量做用域是函数,所以,若是你想要在一个新的做用域中建立一个变量,你就要把它加到一个新的函数中,由于函数定义了做用域。翻译

function hookupevents() {
 for (var i = 0; i < 4; i++) {
  var handlerCreator = function(index) {
   var localIndex = index;
   return function() { alert(localIndex); };
  };
  var handler = handlerCreator(i);
  document.getElementById("myButton" + i)
   .addEventListener("click", handler);
 }
}

如今,事情开始变得奇怪了。咱们要把一个变量放到它本身的函数中,所以咱们定义了一个帮助函数 handlerCreator ,它能够建立一个事件处理函数。所以咱们如今有了一个函数,咱们能够建立一个新的局部变量,这个局部变量与在父函数(parent function)中的变量是不一样的。咱们把这个局部变量称做localIndex。handlerCreator函数把参数保存在localIndex中,而后建立并返回了一个真正的事件处理函数,这个函数使用localIndex而不是变量 i 所以它使用的是捕获值而不是原始变量。如今每一个handler都获得一个localIndex的独立副本,你能够看到,每次显示的都是指望获得的值。我用上面那种长方式写代码是为了解释性目的。在实际中,代码能够精简。做为例子,index参数能够用来代替localIndex,应为参数能够被看作方便的已经初始化了的局部变量。code

function hookupevents() {
 for (var i = 0; i < 4; i++) {
  var handlerCreator = function(index) {
   return function() { alert(index); };
  };
  var handler = handlerCreator(i);
  document.getElementById("myButton" + i)
   .addEventListener("click", handler);
 }
}

而后handlerCreator能够改写成内联形式的(即写成当即执行函数)对象

function hookupevents() {
 for (var i = 0; i < 4; i++) {
  var handler = (function(index) {
   return function() { alert(index); })(i);
  document.getElementById("myButton" + i)
   .addEventListener("click", handler);
 }
}

而后是handler自己也能够写成内联形式blog

function hookupevents() {
 for (var i = 0; i < 4; i++) {
  document.getElementById("myButton" + i)
   .addEventListener("click",
       (function(index) {
         return function() { alert(index); })(i));
 }
}

(function(x){...})(y)这种模式被具备误导性的称做自调用函数(self-invoking function),说它是误导性的是由于这个函数不会调用自己;外围的代码调用它。一个更好的名字多是当即执行函数(immedately-invoked function)(貌似国内都是叫当即执行函数,并无见到自调用函数这一说法)由于这个函数一旦定义就被当即执行了。下一步就是去简单的改变帮助函数的index变量的名字为 这样外层变量和内层变量之间的联系就能够变得更加显然(对于初学者也更加容易迷惑):

function hookupevents() {
 for (var i = 0; i < 4; i++) {
  document.getElementById("myButton" + i)
   .addEventListener("click",
       (function(i) {
         return function() { alert(i); })(i));
 }
}

形如(function(X){...})(X)这样的模式是一种习惯写法,意思是:在封闭的代码块中,按值的方式捕获x。由于函数能够拥有多个参数,因此你能够扩展为(function(x,y,z){...})(x,y,z)用来按值的方式捕获多个变量。把整个循环体放到这个模式中也是很常见的,由于你一般屡次引用循环变量,因此你能够只捕获一次而后重用这个捕获变量。

function hookupevents() {
 for (var i = 0; i < 4; i++) {
  (function(i) {
   document.getElementById("myButton" + i)
    .addEventListener("click", function() { alert(i); });
  })(i);
 }
}

也许在JavaScript中修复这个问题十分繁琐也是一件好事。对于C#,这个问题更容易解决,但也是很微妙的。JavaScript版本仍是比较明显的。

练习题 : 这个模式不起做用了!

var o = { a: 1, b: 2 };
document.getElementById("myButton")
 .addEventListener("click",
   (function(o) { alert(o.a); })(o));
o.a = 42;

这个代码显示的是42 而不是 1.尽管我按值的方式捕获了o。请解释缘由。

更多阅读:C#和ECMAScript 使用了两种方式解决这个问题(这里指的应该是语言层面上经过修改语义和添加语法糖等方式,而不是上面提到的方法)。在C#5中,foreach循环中的循环变量如今被认为是在循环中的做用域了。ECMAScript提出了一个新的关键字let。

全文翻译完。

后记:

在这篇文章中提到的预先阅读中,是C#(C# 5 以前)中foreach循环中的闭包出现了问题。代码以下:

var values = new List<int>() { 100, 110, 120 };
var funcs = new List<Func<int>>();
foreach(var v in values)
  funcs.Add( ()=>v );
foreach(var f in funcs)
  Console.WriteLine(f());

这里显示的是三个120 而不是 100 110 120。做者解释的缘由是()=>v意味着“返回当前变量v的值”而不是“返回委托被建立时的值v”。闭包关闭的是变量,而不是变量的值。解决的方法是加一个局部变量:

foreach(var v in values)
{
  var v2 = v;
  funcs.Add( ()=>v2 );
}

每一次从新开始一个循环咱们都从新定义了一个v2,每次闭包闭合的都是一个新的只被复制了当前变量v的当前值的v2。至于问什么foreach会出现这种问题,缘由在于foreach只是下面代码的语法糖:

{
    IEnumerator<int> e = ((IEnumerable<int>)values).GetEnumerator();
    try
    {
      int m; // OUTSIDE THE ACTUAL LOOP
      while(e.MoveNext())
      {
        m = (int)(int)e.Current;
        funcs.Add(()=>m);
      }
    }
    finally
    {
      if (e != null) ((IDisposable)e).Dispose();
    }
  }

因此拥有块级做用域的C#在上面代码的做用下闭合了的是循环结束后最终的m值。若是把它改为下面的形式:

   try
    {
      while(e.MoveNext())
      {
        int m; // INSIDE
        m = (int)(int)e.Current;
        funcs.Add(()=>m);
      }

代码就能够正确运行了。

剩下的就是做者阐述了修改这个问题的好处与坏处。也仍是值的一看的。

而后关于上面的练习题,做者提供的模式只是按值的方式捕获了对象o的引用,因此在最后一行更改了o.a后,全部的都更改了。

相关文章
相关标签/搜索