MDN’s new design is in Beta! A sneak peek: https://blog.mozilla.org/opendesign/mdns-new-design-beta/

閉包(Closure)是指能使用獨立(自由)變數的函式(在使用運用,但在封閉的作用域中定義)。換言之,這些函式能「記得」被建立時的環境。

語法作用域(Lexical scoping)

思考這個例子:

function init() {
  var name = "Mozilla"; // name 是個由 init 建立的局部變數
  function displayName() { // displayName() 是內部函式,一個閉包
    alert(name); // 使用了父函式宣告的變數
  }
  displayName();
}
init();

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 alert 提示顯示出來。但箇中不同、且頗具趣味處,乃內部函式 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);

console.log(add5(2));  // 7
console.log(add10(2)); // 12

在此,我們定義一個帶有 x 參數並回傳新函式的函式 makeAdder(x)。該新函式又帶有 y 參數並回傳了 xy 的總和。

本質上 makeAdder 乃為函式工廠:它是個建立給定值、並與其參數求和之函式。上例中我們的函式工廠建立了兩個新函式:一個給參數加 5,令一個則是 10。

add5add10 都是閉包。他們共享函式的定義,卻保有不同的環境:在 add5x 是 5。而在 add10x 則是 10。

實用的閉包

這就是閉包的原理了──但它真有什麼用嗎?讓我們想想它們的實際意義吧。閉包讓你把一些資料(環境)與操控他們的函式相關聯。很明顯地,這與把一些資料(物件屬性)與一些方法的相關聯的物件導向程式設計(object oriented programming)相似。

因此,在使用只含一個方法的物件之處,通常也可以使用閉包。

在 Web 中,試圖做這種事的情況還蠻普遍的。我們寫的大多數 web JavaScript 程式碼屬於 event-based 的:我們定義了一些行為,接著把它與用戶觸發事件(例如點擊或按鍵)連結起來。程式碼通常會以 callback 的形式連結:也就是一個處理事件回應的函式。

這裡有個實際的例子:假設我們想在網頁上,加個能調整文字大小的按鈕。其中一個方法是用像素指定 body 元素的 font-size,接著透過相對的 em 單位,設置其他頁面的其他因素(如 headers)個大小:

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

h1 {
  font-size: 1.5em;
}

h2 {
  font-size: 1.2em;
}

我們的互動式文字大小按鈕,可以改變 body 元素的 font-size 屬性(property)並藉由相對單位令頁面其他元素接受相應調整。

以下是 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 現在是能調整字體大小到分別為 12、14、與 16 像素的函式。而我們能如下例一般,把他們附加到按鈕上(本例為連結):

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 之類的程式語言,提供了私有方法宣告的能力,意味著它們只能被同一個 class 的其他方法呼叫。

JavaScript 並沒有的提供原生的方法完成這種事,不果它藉由閉包來模擬私有方法。私有方法不只能限制程式碼的通行,還提供全域命名空間,避免非必要的方法弄亂公開介面之強大管理方式。

以下展示如何定義一些能訪問私有函式與變數、並使用別稱 module pattern 閉包的公有函式:

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

console.log(counter.value()); // logs 0
counter.increment();
counter.increment();
console.log(counter.value()); // logs 2
counter.decrement();
console.log(counter.value()); // logs 1

這裡有很多東西。在前面的其他例子,每個閉包都有各自的環境;而在此例中,我們建立一個,被三個函式共享的環境:counter.incrementcounter.decrementcounter.value

該共享環境由匿名函式的 body 建立,之後立刻執行。該環境還包括兩個私有項(private item):變數 privateCounter 與函式 changeBy。這些私有項,都不會在匿名函式外直接訪問。相反地,它們要透過由匿名包裝器(anonymous wrapper)回傳的公有函式訪問。

這三個公有函式,皆為共享同一個環境的閉包。由於 JavaScript 的語法作用域,它們都能訪問 privateCounter 變數與 changeBy 函式。

你應該也發現到我們定義了建立 counter 的匿名函式、而我們接著呼叫它,並給counter 變數指派了回傳值。我們也能在分離的變數 makeCounter 儲存函式並用其建立數個 counter。

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

var counter1 = makeCounter();
var counter2 = makeCounter();
alert(counter1.value()); /* Alerts 0 */
counter1.increment();
counter1.increment();
alert(counter1.value()); /* Alerts 2 */
counter1.decrement();
alert(counter1.value()); /* Alerts 1 */
alert(counter2.value()); /* Alerts 0 */

請注意兩個 counter 如何維持各自的獨立。每當呼叫 makeCounter() 函式的時候,他們的環境都不相同。每次的閉包變數 privateCounter 會包含不同的實例。

使用這種方法的閉包,提供了一些與物件導向程式設計的益處,尤其是資料隱藏與封裝

在迴圈建立閉包:一個常見錯誤

在 ECMAScript6 導入 let 前,迴圈內建立的閉包,常會發生問題。請思考以下的範例:

<p id="help">Helpful notes will appear here</p>
<p>E-mail: <input type="text" id="email" name="email"></p>
<p>Name: <input type="text" id="name" name="name"></p>
<p>Age: <input type="text" id="age" name="age"></p>
function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

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

setupHelp(); 

helpText 陣列定義了三個有用的提示,每個提示都和文件內的輸入字段 ID 相關連。迴圈透過這三個定義,hooking up an onfocus event to each one that shows the associated help method.

若試著運行這段程式碼,你會發現它不若預期般運行:無論聚焦哪一段,訊息都是在顯示你的年齡。

之所以如此,是因為指派到 onfocus 的函式為閉包,他們組成函式的定義、並從 setupHelp 的作用域捕抓函式的環境。三個閉包都被建立起來,但他們共享同一個環境。當 onfocus 回調並執行的時候,迴圈早已執行了所有過程,由三個閉包共享的 item 變數也因而指向 helpText 的最後一項。

其中一個解法是使用更多閉包,尤其要使用前述的函式工廠:

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

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

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

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

setupHelp(); 

這次就如同預期般的運作了。與所有回調共享單一環境相比,makeHelpCallback 給每個回調建立新的環境,該環境的 help 參照到 helpText 陣列的對應字串。

還有另一個解決方法:

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

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

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

setupHelp();

如果你不想用更多閉包的話,你可以使用 ES6 的 let 關鍵字:

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

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

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

setupHelp();

在這裡,我們用了 let 而不是 var,所以每個閉包都會與每個 block scoped 變數綁定,因而能在不用更多閉包的情況下完美運行。

性能考量

如果指定的任務無須使用閉包的話,在其他函式內建立不必要的函式並不明智,因為從速度和記憶體角度而言,它都會影響腳本性能。

例如說,當我們建立了新的 object/class 時候,方法通常要和物件的 prototype 關聯,而不是定義到物件的建構子(constructor)──這是因為每當建構子被呼叫的時候,方法都會重新分配(也就是說,它每次都在建立物件)。

思考一下這個不切實際的例子:

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

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

上面的程式碼並未利用閉包的益處,所以,可以改成這個常規的形式:

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

但我們不建議重新定義 prototype,因此這個附加到現有 prototype 的下例更佳:

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

以上的程式碼,可以寫得如同下例般簡潔:

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

在前例中,所有物件可共享繼承的 prototype,物件創立時也無須每次都定義方法。詳細資料請參見深入了解物件模型

文件標籤與貢獻者

 此頁面的貢獻者: pa-da, iigmir
 最近更新: pa-da,