JavaScript基礎系列---閉包及其應用

閉包(closure)是JavaScript中一個「神祕」的概念,許多人都對它難以理解,我也一直處於似懂非懂的狀態,前幾天深刻了解了一下執行環境以及做用域鏈,可戳查看詳情,而閉包與做用域及做用域鏈的關係密不可分,因此就再深刻去理解了一番。前端

詞法做用域Lexical Scope

首先咱們來理解一下做用域的概念:編程

一般來講,一段程序代碼中所用到的標識符並不老是有效/可用的,而限定這個標識符的可用性的代碼範圍就是這個標識符的做用域

做用域有詞法做用域與動態做用域之分,詞法做用域也可稱爲靜態做用域,這樣與動態做用域看起來更對應。segmentfault

  • 詞法做用域在詞法分析階段就肯定了做用域,以後不會再改變;也就是說詞法做用域是由你把代碼寫在哪裏來決定的,與以後的運行狀況無關
  • 動態做用域在運行時根據程序的流程信息來動態肯定做用域;也就是說動態做用域與運行狀況有關
  • 大部分編程語言都是基於詞法做用域,其中包括JavaScript

下面咱們使用代碼來講明二者的區別(此處僅僅使用JavaScript來講明兩種狀況,實際上JavaScript只基於詞法做用域)數組

var cc = 6;

function foo() {
  console.log(cc); // 會輸出6仍是66?
}

function bar() {
  var cc = 66;
  foo();
}

bar();
  • 若是是詞法做用域:會輸出6,詞法做用域在寫代碼時就靜態肯定了,也就是定義foo函數的時候就肯定了,foo函數的內部要訪問變量cc,因爲foo的內部做用域中沒有cc變量,因此會根據做用域鏈訪問到全局中的cc變量;這與在何處調用foo函數無關。
  • 若是是動態做用域:會輸出66,動態做用域要根據代碼的運行狀況來肯定,它關心foo函數在何處被調用,而不關心它定義在哪裏;foo函數的內部要訪問變量cc,而foo的內部做用域中沒有cc變量時,會順着調用棧在調用 foo() 的地方查找變量cc,此處是在bar函數中調用的,因此引擎會在bar的內部做用域中查找cc變量,這個cc變量的值爲66

詞法做用域鏈Lexical Scope Chain

var cc = 1;

function foo() {
  var dd = 2;
  console.log(cc);//1
  console.log(dd);//2
}

foo();
console.log(dd); //ReferenceError: dd is not defined

上面這一段代碼中,有全局變量cc以及局部變量dd,在foo函數內部能夠直接訪問全局變量cc,而在foo函數外部沒法讀取foo函數內的局部變量dd
這種結果的產生源於JavaScript的做用域鏈,也正是由於這個做用域鏈纔有了生成閉包的可能。
做用域鏈這一部分在另外一篇文章中有詳細介紹,可戳JavaScript基礎系列---執行環境與做用域鏈,看完能夠幫助更好的理解下文瀏覽器

什麼是閉包?

關於閉包沒有一個官方的定義,不一樣的書籍解讀可能有些不一樣閉包

在《JavaScript權威指南》中:編程語言

是指函數變量能夠被隱藏於做用域鏈以內,所以看起來是函數將變量「包裹」了起來

在《JavaScript高級程序設計》中:函數

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

在《你不知道的JavaScript--上卷》中:性能

當函數能夠記住並訪問所在的詞法做用域時,就產生了閉包,即便函數是在當前詞法做用
域以外執行

在維基百科的定義:this

在計算機科學中,閉包(Closure),又稱詞法閉包(Lexical Closure)或函數閉包(function closures),是引用了自由變量的函數。這個被引用的自由變量將和這個函數一同存在,即便已經離開了創造它的環境也不例外。因此,有另外一種說法認爲閉包是由函數和與其相關的引用環境組合而成的實體。閉包在運行時能夠有多個實例,不一樣的引用環境和相同的函數組合能夠產生不一樣的實例。

其中自由變量指:

在函數中使用的,但既不是函數參數也不是函數的局部變量的變量

一開始我也一直糾結於閉包的定義,想確切的知道閉包是什麼,可是因爲沒有官方的定義,難以肯定。因此本文中將以維基百科中的定義爲準即:

在計算機科學中,閉包(Closure),又稱詞法閉包(Lexical Closure)或函數閉包(function closures),是引用了自由變量的函數。這個被引用的自由變量將和這個函數一同存在,即便已經離開了創造它的環境也不例外。

閉包的建立

根據閉包的定義咱們能夠看出,閉包的產生條件是函數以及該函數引用了自由變量,兩者缺一不可。

這個被引用的自由變量將和這個函數一同存在,即便已經離開了創造它的環境也不例外這一描述是閉包的特性,使用閉包後能觀察到的一種現象,而不是閉包產生的條件。因此以前看到有些人說,須要將一個函數的內部函數返回才能算閉包的言論我以爲應該是不正確的,這應該是在使用閉包。

常說的閉包會致使性能問題,也是由於這個被引用的自由變量將和這個函數一同存在,即便已經離開了創造它的環境也不例外這一閉包特性,按理來講,在函數 執行後,函數的整個內部做用域一般都會被銷燬,由於咱們知道引擎有垃
圾回收器用來釋放再也不使用的內存空間,可是閉包能夠阻止這件事的發生,從而可能致使內存中保存大量的變量,從而消耗大量內存產生網頁性能問題。(注意是能夠,可能而非必定)

下面咱們直接來看幾個栗子:
1.若是考慮全局對象,那麼引用了全局變量的函數能夠看作建立了閉包,由於全局變量相對於該函數來講是自由變量

var a = 1;
function fa() {
    console.log(a);
}
fa();

此處,函數fa引用了自由變量afa建立了閉包

2.更常見的是在一個函數內部建立另外一個函數

function outer(){
    var b = 2;
    function inner(){
        console.log(b);
    }
    inner();
}
outer();

此處,函數inner引用了自由變量binner建立了閉包。
根據JavaScript基礎系列---執行環境與做用域鏈中的描述咱們能夠知道,調用outer()後,會進入Function Execution Context outer的建立階段:

  • 建立做用域鏈,outer函數的[[Scopes]]屬性被加入其中
  • 建立outer函數的活動對象AO(做爲該Function Execution Context的變量對象VO),並將建立的這個活動對象AO加到做用域鏈的最前端
  • 肯定this的值

此時Function Execution Context outer可表示爲:

outerEC = {
    scopeChain: {
        pointer to outerEC.VO,
        outer.[[Scopes]]
    },
    VO: {
        arguments: {
            length: 0
        },
        b: 2,
        inner: pointer to function inner(),
    },
    this: { ... }
}

接着進入Function Execution Context outer的執行階段:

  • 當遇到inner函數定義語句,進入inner函數的定義階段,inner[[Scopes]]屬性被肯定

    inner.[[Scopes]] = {
        pointer to outerEC.VO,
        pointer to globalEC.VO
    }
  • 遇到inner()調用語句,進入inner函數調用階段,此時進入Function Execution Context inner的建立階段:

    • 建立做用域鏈,inner函數的[[Scopes]]屬性被加入其中
    • 建立inner函數的活動對象AO(做爲該Function Execution Context的變量對象VO),並將建立的這個活動對象AO加到做用域鏈的最前端
    • 肯定this的值
  • 此時Function Execution Context inner可表示爲:

    innerEC = {
        scopeChain: {
            pointer to innerEC.VO,
            inner.[[Scopes]]
        },
        VO: {
            arguments: {
                length: 0
            },
        },
        this: { ... }
    }
  • 接着進入Function Execution Context inner的執行階段:遇到打印語句console.log(b);,經過inner.[[Scopes]]訪問到變量b=2
  • 至此,函數inner執行完畢,Function Execution Context inner的做用域鏈及變量對象被銷燬
  • 而後函數outer也執行完畢,Function Execution Context outer的做用域鏈及變量對象被銷燬。

這種狀況下,函數執行完畢後該銷燬的都被銷燬了,沒有佔用內存,因此這種狀況下閉包是不會對性能有佔用內存方面的影響的。

3.最常被討論的閉包

栗子1

function fa(){
    var n = 666;
    function fb(){
        console.log(n);
    }
    return fb;
}
var getN = fa();
getN();

此處,函數fb引用了自由變量nfb建立了閉包,而且fb被傳遞到了創造它的環境之外(所在的詞法做用域之外)。

這段代碼的執行狀況與上面相似,鑑於篇幅就不一一展開詳細描述了,你們能夠本身推一遍;如今主要描述一下不一樣之處,在fa函數的最後,fa函數將它的內部函數fb返回了,按理說返回以後fa函數就執行完畢了,其做用域鏈和活動對象應該被銷燬,可是閉包fb阻止了這件事的發生:

  • 函數fb定義以後其[[Scopes]]屬性被肯定,這個屬性至此以後一直保持不變,直至函數fb被銷燬,能夠表示爲

    fb.[[Scopes]] = {
        pointer to fa.VO,
        pointer to globalEC.VO
    }
  • 函數fa執行完畢後,將其返回值--fb函數賦給了全局變量getN,這樣一來因爲getN是全局變量,而全局變量是在Global Execution Context中的,須要等到應用程序退出後 —— 如關閉網頁或瀏覽器 —— 纔會被銷燬,那麼也就意味着fb函數也要到這時纔會被銷燬
  • fb函數的[[Scopes]]屬性中引用了fa函數的變量(活動)對象,意味着fa函數的變量(活動)對象可能隨時還須要用到,這樣一來fa函數執行完畢以後,只有Function Execution Context fa的做用域鏈會被銷燬,而變量(活動)對象仍然會在內存中
  • 這樣遇到getN()語句時,實際上就是調用fb函數,因而順着fb的做用域鏈找到變量n並打印出來

這裏咱們分析一下,變量n是閉包fb引用的自由變量,創造這個n這個自由變量的是函數fa,此時fa執行完畢以後,自由變量n仍然能夠訪問到(仍然存在),而且在fa函數外也能訪問到(離開fa以後)。這一點也就正對應於這個被引用的自由變量將和這個函數一同存在,即便已經離開了創造它的環境也不例外

除了將內部函數return這種方式以外,還有其餘方式可使用閉包,這些方式的共同之處是:將內部函數傳遞到創造它的環境之外(所在的詞法做用域之外),以後不管在何處執行這個函數就都會使用閉包。

  • 栗子2

    function foo() {
        var a = 2;
        function baz() {
            console.log( a ); // 2
        }
        bar( baz );
    }
    function bar(fn) {
        fn();
    }
    foo();

    這個栗子中,是經過函數傳參來將內部函數baz傳遞到它所在的詞法做用域之外的

  • 栗子3

    var fn;
    function foo() {
        var a = 2;
        function baz() {
            console.log( a );
        }
        fn = baz; // 將baz 賦給全局變量
    }
    foo();
    fn(); // 2

    這個栗子中,是經過賦值給全局變量fn來將內部函數baz傳遞到它所在的詞法做用域之外的。

在栗子1和栗子3這種狀況下呢,閉包使得它本身的變量對象以及包含它的函數的變量對象都存在於內存中,若是濫用就頗有可能致使性能問題。因此在不須要閉包後,最好主動解除對閉包的引用,告訴垃圾回收機制將其清除,好比在上面這些例子中進行getN = null;fn = null的操做。

4.常常用但可能並無意識到它就是閉包的閉包

  • 栗子1

    function wait(msg) {
        setTimeout( function timer() {
            console.log( msg );
        }, 1000 );
    }
    wait( "Hello, closure!" );

    上面的代碼其實能夠理解爲下面這樣:

    function wait(msg) {
        function timer(){
            console.log( msg );
        }
        setTimeout( timer, 1000 );
    }
    wait( "Hello, closure!" );

    內部函數timer引用了自由變量msgtimer建立了閉包,而後將timer傳遞給setTimeout(..),也就是將內部函數timer傳遞到了所在的詞法做用域之外。

    wait(..) 執行1000 毫秒後,wait的變量對象並不會消失,timer函數能夠訪問變量msg,只有當setTimeout(..)執行完畢後,wait的變量對象纔會被銷燬。

  • 栗子2

    function bindName(name, selector) {
        $( selector ).click( function showName() {
            console.log( "This name is: " + name );
        } );
    }
    bindName( "Closure", "#closure" );

    上面的代碼其實能夠理解爲下面這樣:

    function bindName(name, selector) {
        function showName(){
            console.log( "This name is: " + name );
        }
        $( selector ).click( showName );
    }
    bindName( "Closure", "#closure" );

    內部函數showName引用了自由變量nameshowName建立了閉包,而後將showName傳遞給click事件做爲回調函數,也就是將內部函數showName傳遞到了所在的詞法做用域之外。
    bindName(..)執行以後,bindName的變量對象並不會消失,每當這個click事件觸發的時候showName函數能夠訪問變量name

5.同一個調用函數建立的閉包共享引用的自由變量

function change() {
    var num = 10;
        return{
        up:function() {
            num++;
            console.log(num);
        },
        down:function(){
            num--;
            console.log(num);
        }
    }
}
var opt = change();
opt.up();//11
opt.up();//12
opt.down();//11
opt.down();//10

opt.upopt.down共享變量num的引用,它們操做的是同一個變量num,由於調用一次change只會建立並進入一個Function Execution Context change,經過閉包留在內存中的變量對象只有一個。

6.不一樣調用函數建立的閉包互不影響

function change() {
   var num = 10;
       return{
       up:function() {
           num++;
           console.log(num);
       },
       down:function(){
           num--;
           console.log(num);
       }
   }
}
var opt1 = change();
var opt2 = change();
opt1.up();//11
opt1.up();//12
opt2.down();//9
opt2.down();//8

change函數被調用了兩次,分別賦值給opt1opt2,此時opt1.up,opt2.up以及opt1.down,opt2.down是互不影響的,由於每調用一次就會建立並進入一個新的Function Execution Context change,也就會有新的變量對象,因此不一樣調用函數經過閉包留在內存中的變量對象是獨立的,互不影響的。

7.關於上面提到的兩點,有一個談到閉包就被拿出來的例子:

for(var i=1;i<6;i++){
    setTimeout(function(){
        console.log(i);
    },i*1000);
}

上述例子乍一看會以爲輸出的結果是:每隔1s分別打印出1,2,3,4,5;然而實際上的結果是:每隔1s分別打印出6,6,6,6,6

那麼是爲何會這樣呢?下面就來解析一下(ES6以前沒有let命令,不存在真正的塊級做用域):

變量i此處爲全局變量,咱們考慮全局變量,那麼傳遞給setTimeout(...)的這個匿名函數建立了閉包,由於它引用了變量i;雖然循環中的五個函數是在各次迭代中分別定義的,可是它們引用的是全局變量i,這個i只有一個,因此它們引用的是同一個變量(若是在此處將全局對象想象成一個僅調用了一次的函數的返回值,那麼這個現象即可以對應於 ———— 同一個調用函數建立的閉包共享引用的自由變量)

setTimeout()的回調會在循環結束時才執行,即便每一個迭代中執行的是setTimeout(.., 0),而循環結束時全局變量i的值已經變成6了,因此最後輸出的結果是每隔1s分別打印出6,6,6,6,6

要解決上面這個問題,最簡單的方式固然是ES6中喜人的let命令了,僅需將var改成let便可,for 循環頭部的let 聲明會有一個特殊的行爲。這個行爲指出變量在循環過程當中不止被聲明一次,每次迭代都會聲明。隨後的每一個迭代都會使用上一個迭代結束時的值來初始化這個變量。

拋開喜人的ES6,又該怎麼解決呢,既然上面的問題是因爲共享同一個變量而致使的,那麼我想辦法讓它不共享,而是每一個函數引用一個不一樣的變量不就行了。上面提到了 ———— 不一樣調用函數建立的閉包互不影響,咱們就要利用這個來解決這個問題:

for(var i=1;i<6;i++){
   waitShow(i);
}

function waitShow(j){
    setTimeout(function(){
        console.log(j);
    },j*1000);
}

咱們將循環內的代碼改爲了一個函數調用語句waitShow(i),而waitShow函數的內容就是以前循環體內的內容;waitShow內部傳遞給setTimeout(...)的這個匿名函數仍然建立了閉包,只不過此次引用的是waitShow的參數j

如今每迭代一次,便會調用waitShow一次,而咱們從上文中已經知道不一樣調用函數建立的閉包互不影響,因此就能夠解決問題了!固然,這還不是你常見的樣子,如今咱們稍稍改動一下,就變成很是常見的IIFE形式了:

for(var i=1;i<6;i++){
   (function(j){
        setTimeout(function(){
            console.log(j);
        },j*1000);
   })(i)
}

balabala說了這麼多,其實咱們日常寫代碼的時候常常無心識的就建立了閉包,可是建立了咱們不必定會去使用閉包,而閉包的「威力」須要經過使用才能看獲得。

閉包的應用

閉包到底有什麼用呢?我以爲總結成一句話就是:

「凍結」閉包的包含函數調用時的變量對象(使其以當前值留在內存中),並只有經過該閉包才能「解凍」(訪問/操做留在內存中的變量對象)

粗看可能不是很能理解,下面咱們結合具體的應用場景來理解:

  1. 恩。。。首先咱們來看一個老朋友,剛剛見過面的老朋友

    for(var i=1;i<6;i++){
       (function(j){
            setTimeout(function(){
                console.log(j);
            },j*1000);
       })(i)
    }

    在這個栗子中,每一個IIFE自調用時,其內部建立的閉包將其當時的變量對象「凍結」了,而且經過將這個閉包做爲setTimeout的參數傳遞到IIFE做用域之外;因此第一次循環「凍結」的j的值是1,第二次循環「凍結」的j的值是2......當循環結束後,延遲時間到了後,setTimeout的回調執行(即便用閉包),「解凍」了以前「凍結」的變量j,而後打印出來。

  2. 既然提到setTimeout,那再來看看另一個應用,咱們知道在標準的setTimeout是能夠向延遲函數傳遞額外的參數的,形式是這樣:setTimeout(function[, delay, param1, param2, ...]),,一旦定時器到期,它們會做爲參數傳遞給function。可是萬惡的IE搞事情,在IE9及其以前的版本中是不支持傳遞額外參數的。那有時候咱們確實有須要傳參數,怎麼辦呢。一般的解決方法有下面這些:

    function fullName( givenName ){
        let familyName = "Swift";
        console.log("The fullName is: " + givenName + " " + familyName);
    }
    setTimeout(fullName,1000,"Taylor Alison");
    • 使用一個匿名函數包裹
    setTimeout(function(){
        fullName("Taylor Alison");
    },1000);
    • 使用bindES5引入)
    setTimeout(fullName.bind(undefined,"Taylor Alison"),1000);
    • polyfill
    • 使用閉包

      function fullName( givenName ){
          let familyName = "Swift";
          return function(){
              console.log("The fullName is: " + givenName + " " + familyName);
          }
          
      }
      let showFullName = fullName("Taylor Alison");
      setTimeout(showFullName,1000);

      fullName內的匿名函數建立了閉包,並做爲返回值返回,調用fullName()後返回值賦給變量showFullName,此時fullName的變量對象被「凍結」,只能經過showFullName才能「解凍」,定時器到期後,showFullName被調用,經過以前被「凍結」的變量對象訪問到givenNamefamilyName

  3. 待續(有時間補上)
相關文章
相關標籤/搜索