「閉包」攻略

weixin_34327223發表於2018-08-06

$ 閉包是什麼?

閉包是函式和宣告該函式的詞法環境的組合,是指有權訪問另一個函式作用域中變數的函式。可能聽了會蒙圈,繼續如下解釋:

  JS的閉包其實是對JS函式作用域的一種利用。在函式作用域中定義的變數,由於只存在函式自身的記憶體棧中,同樣,在瀏覽器垃圾回收機制作用下,這部分變數不能被函式外界的所訪問引用,而在同一個作用域中定義的函式卻可以訪問相同作用域下定義的變數。

  因此,建立閉包的關鍵之處就在於,我們可以在函式作用域內部定義一個可以訪問該作用域下的所有變數的子函式,最終將這個子函式return出來。利用子函式對這些函式內部變數的呼叫,以達到外界訪問函式內部變數的效果。而這個子函式和其所在的詞法環境環境,就叫做閉包

$ 閉包有什麼特性?

  其實上方也提及到了,主要有以下三個特性:

  1. 在函式內再巢狀函式,這也是函式最直接的展示
  2. 內部函式可以引用外層函式作用域內的引數和變數
  3. 被引用的引數和變數不會被垃圾回收機制回收

$ 為什麼要有閉包?

  要解釋這個問題時,你可能會把什麼是閉包中的內容贅述一遍,答得不算錯,但並不準確,缺少的是對閉包形成機制部分內容的解釋,為理解需要先擴充一下知識:

  JS引擎在工作時有兩個階段:語法檢查階段執行階段;而執行階段又分為預解析階段和執行階段。當語法檢查階段執行錯誤時,瀏覽器會放棄執行階段直接報錯,也就是我們在coding的時候常見的一些報紅。

在預解析階段,先建立執行上下文,執行上下文包括變數物件,作用域鏈 和 this 值。來理解一下這三個物件。

  1. 變數物件Variable object
      JS中,使用var宣告變數,使用function宣告函式、及當前函式的形參(形參是不需要宣告但要依附於函式存在的特殊變數)

  2. 作用域鏈
      當前變數物件 + 所有父級作用域[[scope]]。它其實就是一個變數物件的鏈,Active Object即AO,函式建立後就有靜態的[[scope]]屬性(可以列印在控制檯中檢視當前狀態AO中有哪些屬性),直到該函式中被銷燬。在部分文章中也提及叫記憶體棧。理解不一,意思相同。

  3. this
      進入執行上下文後,將不再改變。

建立執行上下文後,會對變數物件AO的屬性進行首次填充。所謂的屬性,就是var, function 及函式形參名。而他們的值,變數的值為undefined, 函式的值為函式定義,形參的值為實參,還沒有傳入實參的則為undefined

  與解析階段完成之後,進入執行程式碼階段。此時,執行上下文有個Scope屬性(區別於函式的[[scope]]屬性)。

Scope = 當前AO.concat([[scope]])

  JS解析器逐行讀取並執行程式碼,變數物件中的屬性值可能因賦值語句而改變。當我們查詢外部作用域的變數時,其實就是沿著作用域鏈,依次在這些變數物件裡遍歷標誌符,直到最後的全域性變數物件。

  好了,我們再回到閉包問題,先看看閉包的程式碼表現:

function outer() {
    var  a = 5;
    return function inner () {
        return a;
    }
}

var getInnerData = outer();
console.log(getInnerData);  // function inner() {return a;}
var innerData = getInnerData(); 
console.log(innerData);  // 5

  以上程式碼中,getInnerData函式就是一個閉包。函式執行時,其上下文有個Scope屬性,該屬性作為一個作用域鏈包含有該函式被定義時所有外層的變數物件的引用,所以定義了閉包的函式雖然銷燬了,但是其變數物件依然被繫結在函式 inner 上,保留在記憶體中。

  事實上,只要程式碼保持對getInnerDate 函式的引用,函式自身的[[scope]]屬性就繫結著閉包的活動物件。

8646214-16b23fb7147e86a0.png

  但要留意的是,基於js的垃圾回收機制,outer 的變數物件裡,只有仍被引用的變數會繼續儲存在記憶體中:
8646214-6b013cb8701c9588.png

8646214-8f7a01e0b939d178.png


第二日補充】以上內容獲取並不能滿足你對閉包完全瞭解的需求,接著往下看:

$ 實用的閉包

  再來回顧一下閉包的程式碼表現

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

var myFunc = makeFunc();
var myName = myFunc();
console.log(myName );  // Mozilla

  通過執行外部函式myFunc()呼叫到了makeFunc()的函式內部變數name,這就是閉包中所形容的:有權訪問另一個函式作用域中變數的函式。

  吃瓜群眾的我們會認為,函式中的區域性變數僅在函式的執行期間可用。一旦 makeFunc() 執行完畢,我們會認為 name 變數將不能被訪問。然而,這裡的name為什麼還能訪問呢?

  原因是,JavaScript中的函式會形成閉包。 閉包是由函式以及建立該函式的詞法環境組合而成。這個環境包含了這個閉包建立時所能訪問的所有區域性變數。在我們的例子中,myFunc 是執行 makeFunc 時建立的 displayName 函式例項的引用,而 displayName例項仍可訪問其詞法作用域中的變數,即可以訪問到name 。由此,當 myFunc 被呼叫時,name 仍可被訪問,其值 Mozilla 就被傳遞到console中。

  還有個更有意思的例子:

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 求和。

add5add10 都是閉包。它們共享相同的函式定義,但是儲存了不同的詞法環境。在 add5 的環境中,x5。而在 add10 中,x 則為 10

  以上只是閉包的基本應用與程式碼表現,那閉包到底實用在哪兒呢?

閉包允許將函式與其所操作的資料(環境)關聯起來。這顯然類似於物件導向程式設計,在物件導向程式設計中,物件允許我們將某些資料(物件的屬性)與一個或多個方法相關聯

  因此,通常你使用只有一個方法的物件的地方,都可以使用閉包。

  假設我們有這麼一個需求,在介面上我們有三個按鈕來調整頁面字型大小,通常我們會這麼做

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

h1 {
  font-size: 1.5em;
}

h2 {
  font-size: 1.2em;
}

  設定一個根元素的字型大小,其他部分則根據根元素的字型大小來計算出相對值。通過DOM的繫結來實現對根元素大小的調整。

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

var size12 = makeSize(12);
var size14 = makeSize(14);
var size18 = makeSize(18);

  此時定義好了三個調整字型大小的閉包,我們將其繫結到頁面的點選事件上,這樣就實現了我們的需求。

document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-18').onclick = size18;

$ 用閉包模擬私有方法

  在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;
        }
    }
})();

console.log(Counter.value());   // 0
Counter.increment();
Counter.increment();
console.log{Counter.value()};  // 2
Counter.decrement();
console.log(Counter.value());  // 1

  仔細觀察,在之前的示例中,每個閉包都有它自己的詞法環境;而這次我們只建立了一個詞法環境,為三個函式所共享:Counter.incrementCounter.decrementCounter.value

  該共享環境建立於一個立即執行的匿名函式體內。這個環境中包含兩個私有項:名為 privateCounter 的變數和名為 changeBy 的函式。這兩項都無法在這個匿名函式外部直接訪問。必須通過匿名函式返回的三個公共函式訪問。

這三個公共函式是共享同一個環境的閉包。多虧 JavaScript 的詞法作用域,它們都可以訪問 privateCounter 變數和 changeBy 函式。

你應該注意到我們定義了一個匿名函式,用於建立一個計數器。我們立即執行了這個匿名函式,並將他的值賦給了變數counter。我們可以把這個函式儲存在另外一個變數makeCounter中,並用他來建立多個計數器。

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();
console.log(Counter1.value()); /* logs 0 */
Counter1.increment();
Counter1.increment();
console.log(Counter1.value()); /* logs 2 */
Counter1.decrement();
console.log(Counter1.value()); /* logs 1 */
console.log(Counter2.value()); /* logs 0 */

  請注意兩個計數器 counter1counter2 是如何維護它們各自的獨立性的。每個閉包都是引用自己詞法作用域內的變數 privateCounter

  也就是說, 每次呼叫其中一個計數器時,通過改變這個變數的值,會改變這個閉包的詞法環境。然而在一個閉包內對變數的修改,不會影響到另外一個閉包中的變數。

以這種方式使用閉包,提供了許多與物件導向程式設計相關的好處 —— 特別是資料隱藏和封裝。

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

  在ECMAScript 2015(ES6) 引入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的定義及for迴圈遍歷來實現當點選某個控制元件時顯示提示資訊

  執行這段程式碼後,發現它沒有達到想要的效果。無論焦點在哪個input上,顯示的都是關於年齡(最後一項)的資訊。

  原因是,賦值給onfocus的是閉包,這些閉包是由他們的函式和定義在setupHelp作用域中捕獲的環境所組成的,這三個閉包在迴圈中被建立,但他們共享了同一個詞法作用域,在這個作用域中存在一個變數item,當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();

  另一種辦法是使用匿名閉包

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();

  為了避免過多的使用閉包,也可以使用let來解決這個問題

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

  這個例子使用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;
  };
}

  這個例子中,並沒有使用到閉包,劣勢是外部函式無法訪問到MyObject內部的方法getNamegetMessage。為此我們可以改成如下形式:

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

  該例中我們將函式定義在了原型中,但並不建議重新定義原型,繼續修改如下:

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

  在前面的兩個示例中,繼承的原型可以為所有物件共享,不必在每一次建立物件時定義方法

相關文章