代码改变世界

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

2014-06-22 15:07  muzinian  阅读(557)  评论(0编辑  收藏  举报

本文是翻译此文

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

JavaScript也有同样的问题。考虑:

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,这个变量的作用域也贯穿整个函数。换句话说,上面这个代码类似于下面:

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)”这个行为:

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,应为参数可以被看做方便的已经初始化了的局部变量。

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本身也可以写成内联形式

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后,所有的都更改了。