聽說你還不理解JavaScript閉包

窗裡窗外發表於2018-03-14

閉包(Closure)

閉包是一個函式和詞法環境的組合,函式宣告在這個詞法環境中

詞法作用域

看下面一個例子

function init() {
  var name = `Mozilla`; // name是區域性變數
  function displayName() { // displayName()是內部函式,一個閉包
    alert(name); // 使用外部函式宣告的變數
  }
  displayName();
}
init();

  init()建立了一個區域性變數name和一個函式displayName()。函式displayName()是一個已經定義在init()內部的函式,並且只能在函式init()裡面才能訪問得到。函式displayName()沒有自己的區域性變數,但由於內部函式可以訪問外部函式變數,displayName()可以訪問到宣告在外部函式init()的變數name,如果區域性變數還存在的話,displayName()也可以訪問他們。

閉包

看下面一個例子

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

var myFunc = makeFunc();
myFunc();

  執行這段程式碼你會發現和之前init()的方法是一樣的效果,但不同之處是,displayName()在執行之前,這個內部方法是從外部方法返回來的。
  首先,程式碼還是會正確執行,在一些程式語言當中,一個函式內的區域性變數只存在於該函式的執行期間,隨後會被銷燬,一旦makeFunc()函式執行完畢的話,變數名就不能夠被獲取,但是,由於程式碼仍然正常執行,這顯然在JS裡是不會這樣的。這是因為函式在JS裡是以閉包的形式出現的,閉包是一個函式和詞法作環境的組合,詞法環境是函式被宣告的那個作用域,這個執行環境包括了建立閉包時同一建立的任意變數,即建立的這個函式和這些變數處於同一個作用域當中。在這個例子當中,myFunc()是displayName()的函式例項,makeFunc建立的時候,displayName隨之也建立了。displayName的例項可以獲得詞法作用域的引用,在這個詞法作用域當中,存在變數name,對於這一點,當myFunc呼叫的話,變數name,仍然可以被呼叫,因此,變數`Mozilla`傳遞給了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

  在這個例子當中,我們定義了一個函式makeAdder(x),傳遞一個引數x,並且返回一個函式,這個返回函式接收一個引數y,並返回x和y的和。
  實際上,makeAdder是一個工廠模式 – 它建立了一個函式,這個函式可以計算特定值的和。在上面這個例子當中,我們使用工廠模式來建立新的函式 – 一個與5進行加法運算,一個與10進行加法運算。add5和add10都是閉包,他們共享相同的函式定義,但卻儲存著不同的詞法環境,在add5的詞法環境當中,x為5;在add10的詞法環境當中,x變成了10。

閉包的實踐

  閉包是很有用的,因為他讓你把一些資料(詞法環境)和一些能夠獲取這些資料的函式聯絡起來,這有點和麵向物件程式設計類似,在物件導向程式設計當中,物件讓我們可以把一些資料(物件的屬性)和一個或多個方法聯絡起來
  因此,你能夠像物件的方法一樣隨時使用閉包。實際上,大多數的前端JS程式碼都是事件驅動性的 – 我們定義一些事件,當這個事件被使用者所觸發的時候(例如使用者的點選事件和鍵盤事件),我們的事件通常會帶上一個回撥:即事件觸發所執行的函式。例如,假設我們希望在頁面上新增一些按鈕,這些按鈕能夠調整文字的大小,實現這個功能的方式是確定body的字型大小,然後再設定頁面上其他元素(例如標題)的字型大小,我們使用em作為單位。

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

h1 {
  font-size: 1.5em;
}

h2 {
  font-size: 1.2em;
}

  我們設定的調節字型大小的按鈕能夠改變body的font-size,並且這個調節能夠通過相對字型單位,反應到其他元素上,

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

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

  size12,size14,size16是三個分別把字型大小調整為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語言能夠宣告私有方法,意味著只能夠在相同的類裡面被呼叫,JS無法做到這一點,但卻可以通過閉包來封裝私有方法。私有方法不限制程式碼:他們提供了管理名稱空間的一種強有力方式。
  下面程式碼闡述了怎樣使用閉包來定義公有函式,公有函式能夠訪問私有方法和屬性。

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

  在先前的例子當中,每個閉包具有他們自己的詞法環境,在這個例子中,我們建立了一個單獨的詞法環境,這個詞法環境被3個函式所共享,這三個函式是counter.increment, counter.decrement和counter.value
  共享的詞法環境是由匿名函式建立的,一定義就可以被執行,詞法環境包含兩項:變數privateCounter和函式changeBy,這些私有方法和屬性不能夠被外面訪問到,然而,他們能夠被返回的公共函式訪問到。這三個公有函式就是閉包,共享相同的環境,JS的詞法作用域的好處就是他們可以互相訪問變數privateCounter和changeBy函式

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

  兩個計數器counter1和counter2分別是互相獨立的,每個閉包具有不同版本的privateCounter,每次計數器被呼叫,詞法環境會改變變數的值,但是一個閉包裡變數值的改變並不影響另一個閉包裡的變數。

迴圈中建立閉包:常見錯誤

下面一個例子

<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 陣列定義了三個有用的hint,每個分別與輸入框的id相對應,每個方法與onfocus事件繫結起來。當你執行這段程式碼的時候,不會像預期的那樣工作,不管你聚焦在哪個輸入框,始終顯示你的age資訊。
  原因在於,分配給onfocus事件的函式是閉包,他們由函式定義構成,從setupHelp函式的函式作用域獲取。三個閉包由迴圈所建立,每個閉包具有同一個詞法環境,環境中包含一個變數item.help,當onfocus的回撥執行時,item.help的值也隨之確定,迴圈已經執行完畢,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 event listener attachment with the current value of item (preserved until iteration).
  }
}

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,所以,每個閉包繫結了塊級作用域,也就意味著不需要額外的閉包

效能考慮

  如果閉包在實際案例中是不被允許的,在一個函式中就不一定再建立一個函式,因為這會影響指令碼的效能,例如處理的速度和記憶體的消耗。例如,當建立一個物件,物件的方法應該跟物件的原型聯絡起來而不是在物件的構造器裡定義,這是因為無論什麼時候構造器被呼叫,方法都會被重新分配

下面一個例子

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;
  }
};

  然而,我們不建議重新定義原型,下面的例子中,給原型分別定義方法而不是重新定義整個原型,這樣會改變constructor的指向。

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;
};

  在前面兩個例子中,繼承原型可以被所有物件所共享並且在每個物件建立的同時都不必定義方法。

參考

相關文章