天天看點

深入了解JavaScript系列:閉包(Closures)介紹 概論 ECMAScript閉包的實作 閉包用法實戰 總結 其它參考 同步與推薦

介紹

本章我們将介紹在JavaScript裡大家經常來讨論的話題 —— 閉包(closure)。閉包其實大家都已經談爛了。盡管如此,這裡還是要試着從理論角度來讨論下閉包,看看ECMAScript中的閉包内部究竟是如何工作的。

正如在前面的文章中提到的,這些文章都是系列文章,互相之間都是有關聯的。是以,為了更好的了解本文要介紹的内容,建議先去閱讀第14章作用域鍊和第12章變量對象。

英文原文:http://dmitrysoshnikov.com/ecmascript/chapter-6-closures/      

概論

在直接讨論ECMAScript閉包之前,還是有必要來看一下函數式程式設計中一些基本定義。

衆所周知,在函數式語言中(ECMAScript也支援這種風格),函數即是資料。就比方說,函數可以指派給變量,可以當參數傳遞給其他函數,還可以從函數裡傳回等等。這類函數有特殊的名字和結構。

定義

A functional argument (“Funarg”) — is an argument which value is a function.
函數式參數(“Funarg”) —— 是指值為函數的參數。      

例子:

function exampleFunc(funArg) {
  funArg();
}

exampleFunc(function () {
  alert('funArg');
});      

上述例子中funarg的實際參數其實是傳遞給exampleFunc的匿名函數。

反過來,接受函數式參數的函數稱為高階函數(high-order function 簡稱:HOF)。還可以稱作:函數式函數或者偏數理或操作符。上述例子中,exampleFunc 就是這樣的函數。

此前提到的,函數不僅可以作為參數,還可以作為傳回值。這類以函數為傳回值的函數稱為帶函數值的函數(functions with functional value or function valued functions)。

(function functionValued() {
  return function () {
    alert('returned function is called');
  };
})()();      

可以以正常資料形式存在的函數(比方說:當參數傳遞,接受函數式參數或者以函數值傳回)都稱作 第一類函數(一般說第一類對象)。在ECMAScript中,所有的函數都是第一類對象。

函數可以作為正常資料存在(例如:當參數傳遞,接受函數式參數或者以函數值傳回)都稱作第一類函數(一般說第一類對象)。

在ECMAScript中,所有的函數都是第一類對象。

接受自己作為參數的函數,稱為自應用函數(auto-applicative function 或者 self-applicative function):

(function selfApplicative(funArg) {

  if (funArg && funArg === selfApplicative) {
    alert('self-applicative');
    return;
  }

  selfApplicative(selfApplicative);

})();      

以自己為傳回值的函數稱為自複制函數(auto-replicative function 或者 self-replicative function)。通常,“自複制”這個詞用在文學作品中:

(function selfReplicative() {
  return selfReplicative;
})();      

自複制函數的其中一個比較有意思的模式是讓僅接受集合的一個項作為參數來接受進而代替接受集合本身。

// 接受集合的函數
function registerModes(modes) {
  modes.forEach(registerMode, modes);
}

// 用法
registerModes(['roster', 'accounts', 'groups']);

// 自複制函數的聲明
function modes(mode) {
  registerMode(mode); // 注冊一個mode
  return modes; // 傳回函數自身
}

// 用法,modes鍊式調用
modes('roster')('accounts')('groups')

//有點類似:jQueryObject.addClass("a").toggle().removClass("b")
      

但直接傳集合用起來相對來說,比較有效并且直覺。

在函數式參數中定義的變量,在“funarg”激活時就能夠通路了(因為存儲上下文資料的變量對象每次在進入上下文的時候就建立出來了):

function testFn(funArg) {
  // funarg激活時, 局部變量localVar可以通路了
  funArg(10); // 20
  funArg(20); // 30

}

testFn(function (arg) {
  var localVar = 10;
  alert(arg + localVar);
});      

然而,我們從第14章知道,在ECMAScript中,函數是可以封裝在父函數中的,并可以使用父函數上下文的變量。這個特性會引發funarg問題。

Funarg問題

在面向堆棧的程式設計語言中,函數的局部變量都是儲存在棧上的,每當函數激活的時候,這些變量和函數參數都會壓入到該堆棧上。

當函數傳回的時候,這些參數又會從棧中移除。這種模型對将函數作為函數式值使用的時候有很大的限制(比方說,作為傳回值從父函數中傳回)。絕大部分情況下,問題會出現在當函數有自由變量的時候。

自由變量是指在函數中使用的,但既不是函數參數也不是函數的局部變量的變量

例子:

function testFn() {

  var localVar = 10;

  function innerFn(innerParam) {
    alert(innerParam + localVar);
  }

  return innerFn;
}

var someFn = testFn();
someFn(20); // 30
      

上述例子中,對于innerFn函數來說,localVar就屬于自由變量。

對于采用面向棧模型來存儲局部變量的系統而言,就意味着當testFn函數調用結束後,其局部變量都會從堆棧中移除。這樣一來,當從外部對innerFn進行函數調用的時候,就會發生錯誤(因為localVar變量已經不存在了)。

而且,上述例子在面向棧實作模型中,要想将innerFn以傳回值傳回根本是不可能的。因為它也是testFn函數的局部變量,也會随着testFn的傳回而移除。

還有一個問題是當系統采用動态作用域,函數作為函數參數使用的時候有關。

看如下例子(僞代碼):

var z = 10;

function foo() {
  alert(z);
}

foo(); // 10 – 使用靜态和動态作用域的時候
(function () {

  var z = 20;
  foo(); // 10 – 使用靜态作用域, 20 – 使用動态作用域
})();

// 将foo作為參數的時候是一樣的(function (funArg) {

  var z = 30;
  funArg(); // 10 – 靜态作用域, 30 – 動态作用域
})(foo);      

我們看到,采用動态作用域,變量(辨別符)的系統是通過變量動态棧來管理的。是以,自由變量是在目前活躍的動态鍊中查詢的,而不是在函數建立的時候儲存起來的靜态作用域鍊中查詢的。

這樣就會産生沖突。比方說,即使Z仍然存在(與之前從棧中移除變量的例子相反),還是會有這樣一個問題: 在不同的函數調用中,Z的值到底取哪個呢(從哪個上下文,哪個作用域中查詢)?

上述描述的就是兩類funarg問題 —— 取決于是否将函數以傳回值傳回(第一類問題)以及是否将函數當函數參數使用(第二類問題)。

為了解決上述問題,就引入了 閉包的概念。

閉包

閉包是代碼塊和建立該代碼塊的上下文中資料的結合。      

讓我們來看下面這個例子(僞代碼):

var x = 20;

function foo() {
  alert(x); // 自由變量"x" == 20
}

// 為foo閉包
fooClosure = {
  call: foo // 引用到function
  lexicalEnvironment: {x: 20} // 搜尋上下文的上下文
};      

上述例子中,“fooClosure”部分是僞代碼。對應的,在ECMAScript中,“foo”函數已經有了一個内部屬性——建立該函數上下文的作用域鍊。

“lexical”通常是省略的。上述例子中是為了強調在閉包建立的同時,上下文的資料就會儲存起來。當下次調用該函數的時候,自由變量就可以在儲存的(閉包)上下文中找到了,正如上述代碼所示,變量“z”的值總是10。

定義中我們使用的比較廣義的詞 —— “代碼塊”,然而,通常(在ECMAScript中)會使用我們經常用到的函數。當然了,并不是所有對閉包的實作都會将閉包和函數綁在一起,比方說,在 Ruby語言中,閉包就有可能是: 一個過程對象(procedure object), 一個lambda表達式或者是代碼塊。

對于要實作将局部變量在上下文銷毀後仍然儲存下來,基于棧的實作顯然是不适用的(因為與基于棧的結構相沖突)。是以在這種情況下,上層作用域的閉包 資料是通過 動态配置設定記憶體的方式來實作的(基于“堆”的實作),配合使用垃圾回收器(garbage collector簡稱GC)和 引用計數(reference counting)。這種實作方式比基于棧的實作性能要低,然而,任何一種實作總是可以優化的: 可以分析函數是否使用了自由變量,函數式參數或者函數式值,然後根據情況來決定 —— 是将資料存放在堆棧中還是堆中。

ECMAScript閉包的實作

讨論完理論部分,接下來讓我們來介紹下ECMAScript中閉包究竟是如何實作的。這裡還是有必要再次強調下:ECMAScript隻使用靜态(詞法)作用域(而諸如Perl這樣的語言,既可以使用靜态作用域也可以使用動态作用域進行變量聲明)。

var x = 10;

function foo() {
  alert(x);
}

(function (funArg) {

  var x = 20;

  // 變量"x"在(lexical)上下文中靜态儲存的,在該函數建立的時候就儲存了
  funArg(); // 10, 而不是20

})(foo);      

技術上說,建立該函數的父級上下文的資料是儲存在函數的内部屬性 [[Scope]]中的。如果你還不了解什麼是[[Scope]],建議你先閱讀第14章, 該章節對[[Scope]]作了非常詳細的介紹。如果你對[[Scope]]和作用域鍊的知識完全了解了的話,那對閉包也就完全了解了。

根據函數建立的算法,我們看到 在ECMAScript中,所有的函數都是閉包,因為它們都是在建立的時候就儲存了上層上下文的作用域鍊(除開異常的情況) (不管這個函數後續是否會激活 —— [[Scope]]在函數建立的時候就有了):

var x = 10;

function foo() {
  alert(x);
}

// foo是閉包
foo: <FunctionObject> = {
  [[Call]]: <code block of foo>,
  [[Scope]]: [
    global: {
      x: 10
    }
  ],
  ... // 其它屬性
};      

如我們所說,為了優化目的,當一個函數沒有使用自由變量的話,實作可能不儲存在副作用域鍊裡。不過,在ECMA-262-3規範裡任何都沒說。是以,正常來說,所有的參數都是在建立階段儲存在[[Scope]]屬性裡的。

有些實作中,允許對閉包作用域直接進行通路。比如Rhino,針對函數的[[Scope]]屬性,對應有一個非标準的 __parent__屬性,在第12章中作過介紹:

var global = this;
var x = 10;

var foo = (function () {

  var y = 20;

  return function () {
    alert(y);
  };

})();

foo(); // 20
alert(foo.__parent__.y); // 20

foo.__parent__.y = 30;
foo(); // 30

// 可以通過作用域鍊移動到頂部
alert(foo.__parent__.__parent__ === global); // true
alert(foo.__parent__.__parent__.x); // 10

      

所有對象都引用一個[[Scope]]

這裡還要注意的是:在ECMAScript中,同一個父上下文中建立的閉包是共用一個[[Scope]]屬性的。也就是說,某個閉包對其中[[Scope]]的變量做修改會影響到其他閉包對其變量的讀取:

這就是說:所有的内部函數都共享同一個父作用域

var firstClosure;
var secondClosure;

function foo() {

  var x = 1;

  firstClosure = function () { return ++x; };
  secondClosure = function () { return --x; };

  x = 2; // 影響 AO["x"], 在2個閉包公有的[[Scope]]中

  alert(firstClosure()); // 3, 通過第一個閉包的[[Scope]]
}

foo();

alert(firstClosure()); // 4
alert(secondClosure()); // 3      

關于這個功能有一個非常普遍的錯誤認識,開發人員在循環語句裡建立函數(内部進行計數)的時候經常得不到預期的結果,而期望是每個函數都有自己的值。

var data = [];

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

data[0](); // 3, 而不是0
data[1](); // 3, 而不是1
data[2](); // 3, 而不是2
      

上述例子就證明了 —— 同一個上下文中建立的閉包是共用一個[[Scope]]屬性的。是以上層上下文中的變量“k”是可以很容易就被改變的。

activeContext.Scope = [
  ... // 其它變量對象
  {data: [...], k: 3} // 活動對象
];

data[0].[[Scope]] === Scope;
data[1].[[Scope]] === Scope;
data[2].[[Scope]] === Scope;      

這樣一來,在函數激活的時候,最終使用到的k就已經變成了3了。如下所示,建立一個閉包就可以解決這個問題了:

var data = [];

for (var k = 0; k < 3; k++) {
  data[k] = (function _helper(x) {
    return function () {
      alert(x);
    };
  })(k); // 傳入"k"值
}

// 現在結果是正确的了
data[0](); // 0
data[1](); // 1
data[2](); // 2      

讓我們來看看上述代碼都發生了什麼?函數“_helper”建立出來之後,通過傳入參數“k”激活。其傳回值也是個函數,該函數儲存在對應的數組元 素中。這種技術産生了如下效果: 在函數激活時,每次“_helper”都會建立一個新的變量對象,其中含有參數“x”,“x”的值就是傳遞進來的“k”的值。這樣一來,傳回的函數的 [[Scope]]就成了如下所示:

data[0].[[Scope]] === [
  ... // 其它變量對象
  父級上下文中的活動對象AO: {data: [...], k: 3},
  _helper上下文中的活動對象AO: {x: 0}
];

data[1].[[Scope]] === [
  ... // 其它變量對象
  父級上下文中的活動對象AO: {data: [...], k: 3},
  _helper上下文中的活動對象AO: {x: 1}
];

data[2].[[Scope]] === [
  ... // 其它變量對象
  父級上下文中的活動對象AO: {data: [...], k: 3},
  _helper上下文中的活動對象AO: {x: 2}
];      

我們看到,這時函數的[[Scope]]屬性就有了真正想要的值了,為了達到這樣的目的,我們不得不在[[Scope]]中建立額外的變量對象。要注意的是,在傳回的函數中,如果要擷取“k”的值,那麼該值還是會是3。

順便提下,大量介紹JavaScript的文章都認為隻有額外建立的函數才是閉包,這種說法是錯誤的。實踐得出,這種方式是最有效的,然而,從理論角度來說,在ECMAScript中所有的函數都是閉包。

然而,上述提到的方法并不是唯一的方法。通過其他方式也可以獲得正确的“k”的值,如下所示:

var data = [];

for (var k = 0; k < 3; k++) {
  (data[k] = function () {
    alert(arguments.callee.x);
  }).x = k; // 将k作為函數的一個屬性
}

// 結果也是對的
data[0](); // 0
data[1](); // 1
data[2](); // 2      

Funarg和return

另外一個特性是從閉包中傳回。在ECMAScript中,閉包中的傳回語句會将控制流傳回給調用上下文(調用者)。而在其他語言中,比 如,Ruby,有很多中形式的閉包,相應的處理閉包傳回也都不同,下面幾種方式都是可能的:可能直接傳回給調用者,或者在某些情況下——直接從上下文退 出。

ECMAScript标準的退出行為如下:

function getElement() {

  [1, 2, 3].forEach(function (element) {

    if (element % 2 == 0) {
      // 傳回給函數"forEach"函數
      // 而不是傳回給getElement函數
      alert('found: ' + element); // found: 2
      return element;
    }

  });

  return null;
}      

然而,在ECMAScript中通過try catch可以實作如下效果:

var $break = {};

function getElement() {

  try {

    [1, 2, 3].forEach(function (element) {

      if (element % 2 == 0) {
        // // 從getElement中"傳回"
        alert('found: ' + element); // found: 2
        $break.data = element;
        throw $break;
      }

    });

  } catch (e) {
    if (e == $break) {
      return $break.data;
    }
  }

  return null;
}

alert(getElement()); // 2
      

理論版本

這裡說明一下,開發人員經常錯誤将閉包簡化了解成從父上下文中傳回内部函數,甚至了解成隻有匿名函數才能是閉包。

再說一下,因為作用域鍊,使得所有的函數都是閉包(與函數類型無關: 匿名函數,FE,NFE,FD都是閉包)。      

這裡隻有一類函數除外,那就是通過Function構造器建立的函數,因為其[[Scope]]隻包含全局對象。

為了更好的澄清該問題,我們對ECMAScript中的閉包給出2個正确的版本定義:

ECMAScript中,閉包指的是:

  1. 從理論角度:所有的函數。因為它們都在建立的時候就将上層上下文的資料儲存起來了。哪怕是簡單的全局變量也是如此,因為函數中通路全局變量就相當于是在通路自由變量,這個時候使用最外層的作用域。
  2. 從實踐角度:以下函數才算是閉包:
    1. 即使建立它的上下文已經銷毀,它仍然存在(比如,内部函數從父函數中傳回)
    2. 在代碼中引用了自由變量

閉包用法實戰

實際使用的時候,閉包可以建立出非常優雅的設計,允許對funarg上定義的多種計算方式進行定制。如下就是數組排序的例子,它接受一個排序條件函數作為參數:

[1, 2, 3].sort(function (a, b) {
  ... // 排序條件
});      

同樣的例子還有,數組的map方法是根據函數中定義的條件将原數組映射到一個新的數組中:

[1, 2, 3].map(function (element) {
  return element * 2;
}); // [2, 4, 6]      

使用函數式參數,可以很友善的實作一個搜尋方法,并且可以支援無限制的搜尋條件:

someCollection.find(function (element) {
  return element.someProperty == 'searchCondition';
});      

還有應用函數,比如常見的forEach方法,将函數應用到每個數組元素:

[1, 2, 3].forEach(function (element) {
  if (element % 2 != 0) {
    alert(element);
  }
}); // 1, 3      

順便提下,函數對象的 apply 和 call方法,在函數式程式設計中也可以用作應用函數。 apply和call已經在讨論“this”的時候介紹過了;這裡,我們将它們看作是應用函數 —— 應用到參數中的函數(在apply中是參數清單,在call中是獨立的參數):

(function () {
  alert([].join.call(arguments, ';')); // 1;2;3
}).apply(this, [1, 2, 3]);      

閉包還有另外一個非常重要的應用 —— 延遲調用:

var a = 10;
setTimeout(function () {
  alert(a); // 10, after one second
}, 1000);      

還有回調函數

//...
var x = 10;
// only for example
xmlHttpRequestObject.onreadystatechange = function () {
  // 當資料就緒的時候,才會調用;
  // 這裡,不論是在哪個上下文中建立
  // 此時變量“x”的值已經存在了
  alert(x); // 10
};
//...
      

還可以建立封裝的作用域來隐藏輔助對象:

var foo = {};

// 初始化
(function (object) {

  var x = 10;

  object.getX = function _getX() {
    return x;
  };

})(foo);

alert(foo.getX()); // 獲得閉包 "x" – 10      

總結

本文介紹了更多關于ECMAScript-262-3的理論知識,而我認為,這些基礎的理論有助于了解ECMAScript中閉包的概念。如果有任何問題,我回在評論裡回複大家。

其它參考

  • Javascript Closures (by Richard Cornford)
  • Funarg problem
  • Closures

同步與推薦

本文已同步至目錄索引:深入了解JavaScript系列

深入了解JavaScript系列文章,包括了原創,翻譯,轉載等各類型的文章,如果對你有用,請推薦支援一把,給大叔寫作的動力。

原文:http://www.cnblogs.com/TomXu/archive/2012/01/31/2330252.html

繼續閱讀