深入淺出FE(四)閉包

目錄

 

1.定義

1.1 爲什麼要用閉包?

1.2 閉包

2.原理

2.1 函數

2.2 詞法環境

2.3 引用

2.4 閉包

3.用法

3.1 封裝私有變量

3.2 延長變量生命週期

4.拓展

4.1 閉包的缺陷

4.2 Currying

5.參考資料


1.定義

1.1 爲什麼要用閉包?

因爲局部變量無法共享和長久的保存,而全局變量可能造成變量污染,所以我們希望有一種機制既可以長久的保存變量又不會造成全局污染。

1.2 閉包

什麼是閉包,不同的人會有不同的理解,不同的書中答案都不盡相同...

《JavaScript高級程序設計》這樣描述:

閉包是指有權訪問另一個函數作用域中的變量的函數;

《JavaScript權威指南》這樣描述:

從技術的角度講,所有的JavaScript函數都是閉包:它們都是對象,它們都關聯到作用域鏈。

《你不知道的JavaScript》定義:

當函數可以記住並訪問所在的詞法作用域時,就產生了閉包,即使函數是在當前詞法作用域之外執行。

MDN定義:

函數與對其狀態即詞法環境lexical environment)的引用共同構成閉包closure)。也就是說,閉包可以從內部函數訪問外部函數作用域。

winter

在JavaScript中,我們稱函數對象爲閉包。根據ECMA-262規範,JavaScript的函數包含一個[[scope]]屬性。[[scope]]指向scope chain(ECMA-262v3)或者Lexical Environment(ECMA-262v5)。這對應於閉包的環境部分,[[scope]]中可訪問的屬性列表即是標識符列表,對象本身的引用則對應於環境。控制部分即是函數對象本身了。

我比較贊同MDN上的定義,相比其他都是用法的定義,MDN的定義突出了運行期的概念,比較準確(個人認爲)。

2.原理

在JavaScript,函數在每次創建時生成閉包,即閉包是函數的運行期實例

2.1 函數

既然閉包是函數運行期的實例,那我們先來用靜態的視角來看函數,它就是一個函數對象(函數的實例)。如果不考慮它作爲對象的那些特性,那麼函數也無非就是“用三個語義組件構成的實體”。這三個語義組件是指:

  • 參數:函數總是有參數的,即使它的形式參數表爲空;
  • 執行體:函數總是有它的執行過程,即使是空的函數體或空語句;
  • 結果:函數總是有它的執行的結果,即使是 undefined。

我們先來看一個最簡單的函數的例子:

x => x

在閉包創建時,參數 x 將作爲閉包(作用域 / 環境)中的名字被初始化——這個過程中“參數 x”只作爲名字或標識符,並且“將會在”閉包中登記一個名爲“x”的變量;按照約定,它的值是 undefined。並且,還需要強調的是,這個過程是引擎爲閉包初始化的,發生於用戶代碼得到這個閉包之前。

每個函數可以對應多個閉包(每次都會生成新的參數),一個閉包也可以對應多個函數(遞歸)。

2.2 詞法環境

所謂詞法環境,就是一個能夠表示標識符在源代碼(詞法)中的位置的環境。由於源代碼分塊,所以詞法環境就可以用“鏈式訪問”來映射“塊之間的層級關係”。但是“var 變量”突破了這個設計限制。

var x = 1;
if (true) {
  var x = 2;

  with (new Object) {
    var x = 3;
  }
}

這個示例中的“1、2、3”所在的“var 變量”x,都突破了它們所在的詞法作用域(或對應的詞法環境),而指向全局的x。於是,自 ECMAScript 5 開始約定,ECMAScript 的執行上下文將有兩個環境,一個稱爲詞法環境,另一個就稱爲變量環境(Variable Environment);所有傳統風格的“var 聲明和函數聲明”將通過“變量環境”來管理。

在早期的 JavaScript 中,作用域與執行環境是一對一的,所以也就常常混用,而到了 ECMAScript 5 之後,有一些作用域並沒有對應用執行環境,所有就分開了。在 ECMAScript 5 之後,ECMAScript 規範中就很少使用“作用域(Scope)”這個名詞,轉而使用“環境”這個概念來替代它。

2.3 引用

引用這個詞很重要,細分可以分爲“引用(規範引用,即爲表達式的值)”和javascript引用(對象函數類型),但是明顯的是概念中的引用指的是規範引用。

表達式的值,在 ECMAScript 的規範中,稱爲“引用”。在 JavaScript 的內部,所謂“引用”是可以轉換爲“值”,以便參與值運算的。因爲表達式的本質是求值運算,所以引用是不能直接作爲最終求值的操作數的。這依賴於一個非常核心的、稱爲“GetValue()”的內部操作。所謂內部操作,也稱爲內部抽象操作(internal abstract operations),是 ECMAScript 描述一個符合規範的引擎在具體實現時應當處理的那些行爲。

2.4 閉包

如果你清楚了函數和詞法環境的概念,那我們再繼續看閉包概念。

一個典型的閉包案例:

function A(){
    let a = 1;
    return function(){
        console.log(a)
    }
}
A();

返回1,如果知道函數的機制,我們知道函數A彈出調用棧後,A中的a變量應該也釋放了,但是爲什麼return出的函數還能繼續引用到函數A中的變量呢?因爲函數A中的變量a是存儲在堆中的,現在的JS引擎還能通過逃逸分析辨別出哪些變量需要存儲在堆上,哪些需要存儲在棧上。

再來看MDN上的一個例子,這個例子說明了閉包是函數運行期的實例,同一個函數,閉包不一樣:

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。

3.用法

3.1 封裝私有變量

function closureDemo(){
    let a = 1;

    let getA = () => {
      return a;
    }

    let setA = (b) => {
      a = b;
    }

    return {
        getA: getA,
        setA: setA
    }
}
let c = closureDemo();
c.setA(5);
console.log(c.getA());

上面是一個稍微複雜點的例子,封裝了一個私有變量a,我們可以通過c這個閉包獲取函數closureDemo的引用及其詞法環境,然後調用這個閉包的內部函數,操作私有變量。

3.2 延長變量生命週期

曾探的書《JavaScript設計模式與開發實踐》中舉了這樣一個例子:一般用於錯誤信息或者用戶信息上報的函數假設爲report函數:

function report(url){
    var img = new image();
    img.src = url;
}

但是通過查詢後段後發現一些低版本瀏覽器的實現存在bug,在這些瀏覽器下使用report函數進行數據上報會丟失30%的數據,也就是說,report函數並不是每一次都成功發起http請求,丟失數據的原因是因爲img是report函數中的局部變量,當report函數調用結束後,img變量由於是存在棧上,所以調用結束後就銷燬,而此時或許還來得及發出HTTP請求,所以請求會丟失。

我們把img變量用閉包封裝起來,便能解決請求丟失問題。

var report = (function(url){
  var imgs = [];
  return function(url){
    var img = new image();
    imgs.push(img)
    img.src = url;
  }
});

4.拓展

4.1 閉包的缺陷

  • 閉包的缺點就是常駐內存會增大內存使用量,並且使用不當很容易造成內存泄露。如果將來需要回收這些閉包變量,我們需要將他們手動設爲null。還有使用閉包比較容易形成循環引用,如果閉包的作用域鏈中保存着一些DOM節點,這時候就有可能造成內存泄漏。但這並不是閉包造成的問題,也不是js的問題。這是因爲IE<8(詳見參考資料4)中BOM和DOM中的對象是使用C++以COM對象的方式實現的,而COM對象的垃圾回收機制採用的是引用計數策略。在基於引用計數的垃圾回收機制中,如果兩個對象之間形成了循環引用,那麼這兩個對象都無法回收,但循環引用造成的內存泄漏並不是閉包造成的(在現代瀏覽器中不會出現)。要解決循環引用帶來的內存泄漏問題,只需要將循環引用的變量設置爲null,譯者着切斷變量與它之前引用的值之間的連接,當垃圾回收器下次運行時,會回收他們佔用的內存。

  • 如果不是因爲某些特殊任務而需要閉包,在沒有必要的情況下,在其它函數中創建函數是不明智的,因爲閉包對腳本性能具有負面影響,包括處理速度和內存消耗。

4.2 Currying

Currying,中文譯爲柯里化,在計算機科學中,柯里化(Currying)是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,並且返回接受餘下的參數且返回結果的新函數的技術。這個技術由 Christopher Strachey 以邏輯學家 Haskell Curry 命名的,儘管它是 Moses Schnfinkel 和 Gottlob Frege 發明的。

按照Stoyan Stefanov(《JavaScript Pattern》作者)的說法,所謂“柯里化”就是使函數理解並處理部分應用。Currying又稱部分求值。一個currying函數首先接受一些參數,接受這些參數之後,函數不會立即求值,而是繼續返回另外一個函數,剛纔傳入的參數在函數形成的閉包中被保存起來。待到函數真正需要求值的時候,之前傳入的所有參數都會被一次性用於求值。簡單說就是接受參數直到需要結果的時候才返回結果。

那麼柯里化有什麼用呢?柯里化有3個常見作用:1. 參數複用2. 提前返回;3. 延遲計算/運行

因爲我是做金融相關的,所以我舉一個金融例子,假如我需要統計我最近的基金收益,需要將每一天的收益加起來,最後一起計算,可能有人會說,爲什麼不直接看最後多少錢,減去當時的錢就是收益,沒必要計算,但是假設你的錢每天都有進出,所以這也算是一種比較好的理財計算方式。

那麼接下來,可以寫一下這個代碼(參考自《JavaScript設計模式與開發實踐》)

var currying = function(fn){
  var args = [];
  return function () {
    if(arguments.length === 0){
      return fn.apply(this, args);
    }else{
      [].push.apply(args, arguments);
      return arguments.callee;
    }
  }
}

var cost = (function(){
  var Income = 0;
  return function(){
    for(var i = 0; i < arguments.length; i++){
      Income += arguments[i];
    }
    return Income;
  }
})();

var cost = currying(cost);
cost(100);
cost(150);
cost(-100);
cost(130);
console.log('cost()=' + cost());

//精簡版本

//ES5
function curry(fn, arr = []) {
  return fn.length === arr.length
    ? fn.apply(null, arr)
    : function(...args) {
        return curry(fn, arr.concat(args));
      };
}

//ES6
const curry = (fn, arr = []) =>
  fn.length === arr.length
    ? fn(...arr)
    : (...args) => curry(fn, [...arr, ...args]);

5.參考資料

1. JavaScript Closures for Beginners

2. winter 閉包概念考證

3. 曾探《JavaScript設計模式與開發實踐》

4. IE<8循環引用導致的內存泄露

5. 周愛民 極客時間《JavaScript核心原理解析》課程

6. MDN 閉包

7. JS中的柯里化(currying)

8. 百度百科:柯里化

9. 函數 爲什麼要Currying化,currying化有什麼優點?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章