【簡譯】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後,全部的都更改了。

相關文章
相關標籤/搜索