閉包(Closure)是函式的組合,以及該宣告函式所包含的作用域環境(lexical environment)。

語法作用域(Lexical scoping)

思考這個例子:

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

init() 建立了局部變數 namedisplayName() 函式。displayName() 是個在 init() 內定義的內部函式,且只在該函式內做動。displayName() 自己並沒有局部變數,不過它可以訪問外面函式的變數、因而能取用在父函式宣告的變數 name

運行這個程式碼並注意 displayName() 裡面的 alert() 宣告能顯示位於上一層的 name 變數。 這是個語法作用域的例子:which describes how a parser resolves variable names when functions are nested. The word "lexical" refers to the fact that lexical scoping uses the location where a variable is declared within the source code to determine where that variable is available. Nested functions have access to variables declared in their outer scope。

閉包

再思考這個例子:

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

var myFunc = makeFunc();
myFunc();

若你執行這個程式碼,它會與前例 init() 有相同結果:字串 Mozilla 會以 JavaScript alert 提示顯示出來。但箇中不同、且頗具趣味處,乃內部函式 displayName() 竟在外部函式執行前,從其回傳。

乍看之下,這段程式碼看來不大直覺:在某些程式語言,函式內的局部變數,只會在函式的執行期間存在。當 makeFunc() 執行完,你可能會預期 name 變數再也無法使用。但這段能照舊運行的程式碼表明了,在 JavaScript 並不是這樣做。

The reason is that functions in JavaScript form closures. A closure is the combination of a function and the lexical environment within which that function was declared. This environment consists of any local variables that were in-scope at the time the closure was created. In this case, myFunc is a reference to the instance of the function displayName created when makeFunc is run. The instance of displayName maintains a reference to its lexical environment, within which the variable name exists. For this reason, when myFunc is invoked, the variable name remains available for use and "Mozilla" is passed to alert.

這裡有個更有趣的例子: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 都是閉包。他們共享函式的定義,卻保有不同的環境:在 add5 的作用域環境,x 是 5。而在 add10 的作用域環境, x 則是 10。

實用的閉包

閉包實用之處,在於能讓你把一些資料(透過作用域環境)與操控這些資料的函式相關聯。很明顯地,這與把一些資料(物件屬性)與一些方法的相關聯的物件導向程式設計(object-oriented programming)相似。

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

在 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 */

Notice how each of the two counters, counter1 and counter2, maintains its independence from the other. Each closure references a different version of the privateCounter variable through its own closure. Each time one of the counters is called, its lexical environment changes by changing the value of this variable; however changes to the variable value in one closure do not affect the value in the other closure.

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

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

在 ECMAScript 2015 導入 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 的作用域捕抓函式的環境。三個閉包都被建立起來,但他們共享同一個能改變數值變數(item.help)的作用域環境。The value of item.help is determined when the onfocus callbacks are executed. Because the loop has already run its course by that time, the item variable object (shared by all three closures) has been left pointing to the last entry in the helpText list.

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

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 event listener attachment with the current value of item (preserved until iteration).
  }
}

setupHelp();

如果你不想用更多閉包的話,你可以使用 ES2015 的 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,物件創立時也無須每次都定義方法。詳細資料請參見深入了解物件模型

文件標籤與貢獻者

 此頁面的貢獻者: iigmir, BigHeadDoggy, flyinglimao
 最近更新: iigmir,