閉包的運用

閉包的運用

閉包(Closure)經常會被認為是 JavaScript 的高級機能,但了解閉包是精通語言的必要之事。

思考以下的函數︰

function init() {
  var name = "Mozilla";
  function displayName() {
    alert(name);
  }
  displayName();
}

init() 函數建立了稱為 name 的局域變數,然後定義了稱為 displayName() 的函數。displayName() 是內部的函數 - 他是在 init() 內部定義的,而且只在函數本體內部才可使用。displayName() 沒有他自己的局域變數,但會重複使用在外部函數裡所宣告的 name 變數。

本例只會做一點事 - 試試執行代碼看會發生什麼。這是詞彙作用域的範例︰在 JavaScript 中,變數的作用域是由他自己在原始碼中的位置所定義的,且內部的函數能夠存取宣告於外部作用域的變數。

現在思考下例︰

function makeFunc() {
  var name = "Mozilla";
  function displayName() {
    alert(name);
  }
  return displayName;
}

var myFunc = makeFunc();
myFunc();

如果你執行這個代碼,將會發生和前一個 init() 例子完全相同的效果︰字串 "Mozilla" 將會被顯示在 JavaScript 的警告方框中。其中的不同點 - 以及有趣的一點 - 是內部的 displayName() 函數會在執行之前從外部的函數所返回。

代碼的運作看起來也許很不直覺。通常說,在函數內部的局域變數只存在於函數執行的期間。一旦 makeFunc() 執行完畢,預期不再需要 name 變數是很合理的。由於代碼仍舊以預期般的運作,很明顯情況並不如此。

對於這個難題的解答是 myFunc 已經變成閉包了。閉包是一種特殊的物件,其中結合了兩樣東西︰函數,和函數所建立的環境。環境由任意的局域變數所組成,這些變數是由在閉包建立的時間點上存在於作用域裡的所有變數。既然如此,myFunc 就是結合了 displayName 函數和閉包建立之後就存在的 "Mozilla" 字串這兩者的閉包。

這裡還有更為有趣的範例 - makeAdder 函數︰

function makeAdder(x) {
  return function(y) {
    return x + y;
  };
}

var add5 = makeAdder(5);
var add10 = makeAdder(10);

print(add5(2));  // 7
print(add10(2)); // 12

在這個範例中,我們已經定義了函數 makeAdder(x),可接受單一參數 x,並返回新的函數。返回的函數會接受單一參數 y,並返回 xy 的合。

就本質而言,makeAdder 是函數的製造機 - 他會建立可以把指定的值和他們的參數相加的函數。在上例中,我們使用了我們的函數製造機來建立兩個新的函數 - 一個給他自己的參數加上 5,另一個則加上 10。

add5add10 兩個都是閉包。他們共享相同的函數本體的定義,但保存了不同的環境變數。在 add5 的環境中,x 是 5。至於互有關連的 add10x 是 10。

實用的閉包

該是拋開理論的時候了 - 但是閉包真的有用嗎?讓我們思考閉包潛在的用處。閉包讓你把一些資料(環境)和可操作資料的函數聯繫在一起。這一點明顯和物件導向程式設式並行不悖,物件可讓我們把一些資料(物件的屬性)和一個以上的方法聯繫在一起。

因此,如果通常你會在某個地方使用附有單一方法的物件,你可以在這些地方使用閉包。

視情況你可能會想這樣做,這在 Web 上尤其常見。我們寫在 Web 上的 JavaScript 代碼多半是以事件為基礎 - 我們定義了一些行為,然後把這些行為和由使用者所觸發的事件(如 click 或 keypress)連繫在一起。我們的代碼通常被連繫為 Callback︰在回應事件時,所執行的單一函數。

這裡有個實際的例子︰假如我們希望在頁面上加入可以調整頁面文字的按鈕。以像素為單位,指定 body 元素的 font-size 是一個方法,然後以 em 為單位,設定在頁面上(如頁眉)的其他元素的大小︰

body {
  font-family: Helvetica, Aria, sans-serif;
  font-size: 12px;
}

h1 {
  font-size: 1.5em;
}
h2 {
  font-size: 1.2em;
}

我們的互動式文字大小按鈕可以改變 body 元素的 font-size 屬性,拜相對單位之賜,接著對其他的元素做調整。

JavaScript 代碼︰

function makeSizer(size) {
  return function() {
    document.body.style.fontSize = size + 'px';
  };
}

var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);

現在 size12size14size16 這些函數可分別調整 body 文字的大小為 12、14 和 16 像素。我們可以把代碼和按鈕(本例中使用的是連結)連繫在一起,如下︰

function setupButtons() {
  document.getElementById('size-12').onclick = size12;
  document.getElementById('size-14').onclick = size14;
  document.getElementById('size-16').onclick = size16;
}
<a href="#" id="size-12">12</a>
<a href="#" id="size-14">14</a>
<a href="#" id="size-16">16</a>

 

使用閉包模擬私有的方法

像 Java 這類語言可以把方法宣告為私有的,意思是這些方法只能被同一類別的其他方法所呼叫。

JavaScript 並不提供做這些事的原生方式,但可以使用閉包來模擬私有方法。私有方法不只是對限制代碼的存取這方面有用︰同時也是管理你的全域命名空間的強大方式,把非必要的方法堆在公開的界面裡。

這裡是如何使用閉包來定義可以存取私有函數和變數的公開函數︰

var Counter = (function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  }   
})();

alert(Counter.value()); /* 顯示 0 */
Counter.increment();
Counter.increment();
alert(Counter.value()); /* 顯示 2 */
Counter.decrement();
alert(Counter.value()); /* 顯示 1 */

在此完成了很多事。在上一個範例中,每一個閉包都有他自己的環境;此處我們建立了由三個函數所共享的單一環境Counter.incrementCounter.decrementCounter.value

共享的環境是建立在無名函數的本體內,無名函數一經定義就會開始執行。環境內含兩個私有項︰稱作 privateCounter 的變數,以及稱作 changeBy 的函數。這兩個私有項都不能在無名函數外部被直接存取。相對的,必須由三個公開的函數來存取這些私有項,這三個函數是從無名函數的封裝器所返回的。

這三個公開的函數共享閉包的同一個環境。感謝 JavaScript 的辭彙作用域,這三個函數都能存取 privateCounter 變數和 changeBy 函數。

按照這個方式來運用閉包,可以得到通常是附加在物件導向程式設計裡的資料隱藏和封裝的好處。

在循環中建立閉包︰常見的錯誤

在 JavaScript 1.7 引入 let 關鍵字以前,閉包常見的問題出現在當閉包是在循環內部建立的時候。思考以下的例子︰

<p id="help">這裡會顯示有用的提示</p>
<p>E-mail: <input type="text" id="email" name="email"></p>
<p>姓名: <input type="text" id="name" name="name"></p>
<p>年齡: <input type="text" id="age" name="age"></p>
function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': '你的 e-mail 位址'},
      {'id': 'name', 'help': '你的完整姓名'},
      {'id': 'age', 'help': '你的年齡(你必須大於 16 歲)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = function() {
      showHelp(item.help);
    }
  }
}

helpText 陣列定義了三個有用的提示,每一個都和文件中的輸入欄位的 ID 連繫在一起。循環會在這些定義裡巡回一圈,給每一個顯示相關連的說明的方法使用 onfocus 事件。

如果你試著執行這個代碼,你會發現他並不如預期般的運作。不管你把焦點放在哪一個欄位上,都會顯示關於你的年齡的訊息。

這其中的原因是代入給 onfocus 的函數是閉包;這些閉包是由函數的定義和從 setupHelp 函數的作用域所捕捉到的環境所組成的。這三個閉包已經建立了,但每一個都共享同一個環境。每次執行 onfocus 的 Callback 的時候,循環執行的是他自己的閉包,以及指向 helpText 列表中的最後一項的變數 item(由三個閉包所共享)。

本例的解決方法是使用更多的閉包︰特別是使用稍早已描述過的函數製造機︰

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function makeHelpCallback(help) {
  return function() {
    showHelp(help);
  };
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': '你的 e-mail 位址'},
      {'id': 'name', 'help': '你的完整姓名'},
      {'id': 'age', 'help': '你的年齡(你必須大於 16 歲)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
  }
}

這次就如預期般運作。而不是所有的 Callback 都共享單一的環境,makeHelpCallback 給每一個 help 建立新的環境,此處的 help 參照了相對應的 helpText 陣列的字串。

如果你使用 JavaScript 1.7 以上的版本,你可以使用 let 關鍵字建立具有區塊層級作用域的變數來解決這個問題︰

  for (var i = 0; i < helpText.length; i++) {
    let item = helpText[i];
    document.getElementById(item.id).onfocus = function() {
      showHelp(item.help);
    }
  }

let 關鍵字使 item 變數改用具有區塊層級的作用域來建立,導致 for 循環每一次反復都能建立新的參考。意思是每一個閉包都會捕捉到個別的變數,解決因為共享同一環境所引起的問題。

效能的考量

如果並沒有特定的任務需要用到閉包,且閉包對 Script 的效能會有負面的影響,因此在其他函數的內部裡建立不必要的函數是很不智的。

例如,當建立新的物件或類別時,通常應該要把方法和物件的原型連繫在一起,而不是在物件的建構子中定義。這其中的理由是,每當呼叫建構子的時候,就要把方法代入(也就是每一個物件正在建立的時候)。

思考以下不切實際的例子︰

function MyObject(name, message) {
  this.name = String(name);
  this.message = String(message);
  this.getName = function() {
    return this.name;
  };

  this.getMessage = function() {
    return this.message;
  };
}

上面的代碼並未從閉包的行為中取得好處,應該改用重整過的形式︰

function MyObject(name, message) {
  this.name = String(name);
  this.message = String(message);
}
MyObject.prototype = {
  getName: function() {
    return this.name;
  },
  getMessage: function() {
    return this.message;
  }
};

或者是︰

function MyObject(name, message) {
  this.name = String(name);
  this.message = String(message);
}
MyObject.prototype.getName = function() {
  return this.name;
};
MyObject.prototype.getMessage = function() {
  return this.message;
};

在上面這兩個範例中,繼承的原型可以被所有的物件所共享,而且在每一次建立物件時不再需要方法的定義。參閱 Core_JavaScript_1.5_教學#物件模型的細節 以取得更多細節。

文件標籤與貢獻者

 此頁面的貢獻者: teoli, happysadman
 最近更新: teoli,