精華提煉「你不知道的 JavaScript」之做用域和閉包

更新:謝謝你們的支持,最近折騰了一個博客官網出來,方便你們系統閱讀,後續會有更多內容和更多優化,猛戳這裏查看前端

------ 如下是正文 ------git

第1章 做用域是什麼

  • 問題1:變量儲存在哪裏?
  • 問題2:程序須要時如何找到它們?

1.1 編譯原理

JavaScript語言是「動態」或「解釋執行」語言,但事實上是一門編譯語言。但它不是提早編譯的,編譯結果也不能在分佈式系統中移植。github

傳統編譯語言流程中,程序在執行以前會經歷三個步驟,統稱爲「編譯」。面試

  • 分詞/詞法分析(Tokenizing/Lexing)編程

    將由字符組成的字符串分解成(對編程語言來講)有意義的代碼塊。數組

    var a = 2;
    複製代碼

    上面這段程序會被分解成如下詞法單元:var、a、=、二、;。安全

    空格是否會被當作詞法單元,取決於空格在這門語言中是否有意義。性能優化

  • 解析/語法分析(Parsing)前端工程師

    將詞法單元流(數組)轉換成一個由元素逐級嵌套所組成的表明了程序語法結構的數。這個數被稱做抽象語法樹(Abstract Syntax Tree, AST)。數據結構

    var a = 2;
    複製代碼

    以上代碼的抽象語法樹以下所示:

    • VariableDeclaration 頂級節點
      • Identifier 子節點,值爲a
      • AssignmentExpression 子節點
        • NumericLiteral 子節點,字爲2
  • 代碼生成

    AST轉換成可執行代碼的過程。過程與語言、目標平臺等相關。

    簡單來講就是能夠經過某種方法將var a = 2;的AST轉化爲一組機器指令。用來建立一個叫作a的變量(包括分配內存等),並將一個值存儲在a中。

1.2 理解做用域

1.2.1 演員表
  • 引擎:從頭至尾負責整個JavaScript程序的編譯和執行。
  • 編譯器:負責語法分析和代碼生成等
  • 做用域:負責收集並維護由全部聲明的標識符(變量、函數)組成的一系列查詢,並實施一套很是嚴格的規則,肯定當前執行的代碼對這些標識符的訪問權限。
1.2.2 對話

var a = 2;存在2個不一樣的聲明。

  • 一、編譯器在編譯時處理(var a):在當前做用域中聲明一個變量(若是以前沒有聲明過)。

    st=>start: Start
    e=>end: End
    op1=>operation: 分解成詞法單元
    op2=>operation: 解析成樹結構AST
    cond=>condition: 當前做用域存在變量a?
    op3=>operation: 忽略此聲明,繼續編譯
    op4=>operation: 在當前做用域集合中聲明新變量a
    op5=>operation: 生成代碼
    st->op1->op2->cond
    cond(yes)->op3->op5->e
    cond(no)->op4->op5->e
    複製代碼
  • 二、引擎在運行時處理(a = 2):在做用域中查找該變量,若是找到就對變量賦值。

st=>start: Start
e=>end: End
cond=>condition: 當前做用域存在變量a?
cond2=>condition: 全局做用域?
op1=>operation: 引擎使用這個變量a
op2=>operation: 引擎向上一級做用域查找變量a
op3=>operation: 引擎把2賦值給變量a
op4=>operation: 舉手示意,拋出異常
st->cond
cond(yes)->op1->op3->e
cond(no)->cond2(no)->op2(right)->cond
cond2(yes)->op4->e
複製代碼
1.2.3 LHS和RHS查詢

LR分別表明一個賦值操做的左側和右側,當變量出如今賦值操做的左側時進行LHS查詢,出如今賦值操做的**非左側**時進行RHS查詢。

  • LHS查詢(左側):找到變量的容器自己,而後對其賦值
  • RHS查詢(非左側):查找某個變量的值,能夠理解爲 retrieve his source value,即取到它的源值
function foo(a) {
    console.log( a ); // 2
}

foo(2);
複製代碼

上述代碼共有1處LHS查詢,3處RHS查詢。

  • LHS查詢有:

    • 隱式的a = 2中,在2被當作參數傳遞給foo(…)函數時,須要對參數a進行LHS查詢
  • RHS查詢有:

    • 最後一行foo(...)函數的調用須要對foo進行RHS查詢

    • console.log( a );中對a進行RHS查詢

    • console.log(...)自己對console對象進行RHS查詢

1.3 做用域嵌套

遍歷嵌套做用域鏈的規則:引擎從當前的執行做用域開始查找變量,若是找不到就向上一級繼續查找。當抵達最外層的全局做用域時,不管找到仍是沒有找到,查找過程都會中止。

1.4 異常

ReferenceError和做用域判別失敗相關,TypeError表示做用域判別成功了,可是對結果的操做是非法或不合理的。

  • RHS查詢在做用域鏈中搜索不到所需的變量,引擎會拋出ReferenceError異常。
  • 非嚴格模式下,LHS查詢在做用域鏈中搜索不到所需的變量,全局做用域中會建立一個具備該名稱的變量並返還給引擎。
  • 嚴格模式下(ES5開始,禁止自動或隱式地建立全局變量),LHS查詢失敗會拋出ReferenceError異常
  • 在RHS查詢成功狀況下,對變量進行不合理的操做,引擎會拋出TypeError異常。(好比對非函數類型的值進行函數調用,或者引用null或undefined類型的值中的屬性)

1.5 小結

var a = 2被分解成2個獨立的步驟。

  • 一、var a在其做用域中聲明新變量
  • 二、a = 2會LHS查詢a,而後對其進行賦值

第2章 詞法做用域

2.1 詞法階段

詞法做用域是定義在詞法階段的做用域,是由寫代碼時將變量和塊做用域寫在哪裏來決定的,因此在詞法分析器處理代碼時會保持做用域不變。(不考慮欺騙詞法做用域狀況下)

2.1.1 查找
  • 做用域查找會在找到第一個匹配的標識符時中止。

  • 遮蔽效應:在多層嵌套做用域中能夠定義同名的標識符,內部的標識符會「遮蔽」外部的標識符。

  • 全局變量會自動變成全局對象的屬性,能夠間接的經過對全局對象屬性的引用來訪問。經過這種技術能夠訪問那些被同名變量所遮蔽的全局變量,可是非全局的變量若是被遮蔽了,不管如何都沒法被訪問到。

    window.a
    複製代碼
  • 詞法做用域只由函數被聲明時所處的位置決定。

  • 詞法做用域查找只會查找一級標識符,好比a、b、c。對於foo.bar.baz,詞法做用域只會查找foo標識符,找到以後,對象屬性訪問規則會分別接管對barbaz屬性的訪問。

2.2 欺騙詞法

欺騙詞法做用域會致使性能降低。如下兩種方法不推薦使用

2.2.1 eval

eval(..)函數能夠接受一個字符串爲參數,並將其中的內容視爲好像在書寫時就存在於程序中這個位置的代碼。

function foo (str, a) {
    eval( str ); // 欺騙!
    console.log( a, b );
}

var b = 2;
foo( "var b = 3;", 1 ); // 1, 3
複製代碼

eval('var b = 3')會被當作原本就在那裏同樣來處理。

  • 非嚴格模式下,若是eval(..)中所執行的代碼包含一個或多個聲明,會在運行期修改書寫期的詞法做用域。上述代碼中在foo(..)內部建立了一個變量b,並遮蔽了外部做用域中的同名變量。
  • 嚴格模式下,eval(..)在運行時有本身的詞法做用域,其中的聲明沒法修改做用域。
function foo (str) {
 "use strict"; 
    eval( str ); 
    console.log( a ); // ReferenceError: a is not defined
}

foo( "var a = 2;" ); 
複製代碼
  • setTimeout(..)setInterval(..)的第一個參數能夠是字符串,會被解釋爲一段動態生成的函數代碼。已過期,不要使用
  • new Function(..)的最後一個參數能夠接受代碼字符串(前面的參數是新生成的函數的形參)。避免使用
2.2.2 with

with一般被當作重複引用同一個對象中的多個屬性的快捷方式,能夠不須要重複引用對象自己

var obj = {
    a: 1,
    b: 2,
    c: 3
};

// 單調乏味的重複「obj」
obj.a = 2;
obj.b = 3;
obj.c = 4;

// 簡單的快捷方式
with (obj) {
	a = 3;
    b = 4;
    c = 5;
}
複製代碼

with能夠將一個沒有或有多個屬性的對象處理爲一個徹底隔離的詞法做用域,這個對象的屬性會被處理爲定義在這個做用域中的詞法標識符。

這個塊內部正常的var聲明並不會被限制在這個塊的做用域中,而是被添加到with所處的函數做用域中。

function foo(obj) {
    with (obj) {
        a = 2;
    }
}

var o1 = {
    a: 3
};

var o2 = {
    b : 3
}

foo( o1 );
console.log( o1.a ); // 2

foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2 -- 很差,a被泄露到全局做用域上了!
複製代碼

上面例子中,建立了o1o2兩個對象。其中一個有a屬性,另外一個沒有。在with(obj){..}內部是一個LHS引用,並將2賦值給它。

  • o1傳遞進去後,with聲明的做用域是o1,a = 2賦值操做找到o1.a並將2賦值給它。
  • o2傳遞進去後,做用域o2中並無a屬性,所以進行正常的LHS標識符查找,o2的做用域、foo(..)的做用域和全局做用域都沒有找到標識符a,所以當a = 2執行時,自動建立了一個全局變量(非嚴格模式),因此o2.a保持undefined。
2.2.3 性能
  • JavaScript引擎會在編譯階段進行數項的性能優化,其中有些優化依賴於可以根據代碼的詞法進行靜態分析,並預先肯定全部變量和函數的定義位置,才能在執行過程當中快速找到標識符。
  • 引擎在代碼中發現eval(..)with,它只能簡單的假設關於標識符位置的判斷都是無效的。由於沒法在詞法分析階段明確知道eval(..)會接收到什麼代碼,這些代碼會如何對做用域進行修改,也沒法知道傳遞給with用來建立詞法做用域的對象的內容究竟是什麼。
  • 悲觀狀況下若是出現了eval(..)或with,全部的優化可能都是無心義的,最簡單的作法就是徹底不作任何優化。代碼運行起來必定會變得很是慢。

2.3 小結

詞法做用域意味着做用域是由書寫代碼時函數聲明的位置來決定的。

編譯的詞法分析階段基本可以知道所有標識符在哪裏以及是如何聲明的,從而可以預測在執行過程當中如何對它們進行查找。

有如下兩個機制能夠「欺騙」詞法做用域:

  • eval(..):對一段包含一個或多個聲明的」代碼「字符串進行演算,藉此來修改已經存在的詞法做用域(運行時)。
  • with:將一個對象的引用當作做用域來處理,將對象的屬性當作做用域中的標識符來處理,建立一個新的詞法做用域(運行時)。

反作用是引擎沒法在編譯時對做用域查找進行優化。由於引擎只能謹慎地認爲這樣的優化是無效的,使用任何一個都將致使代碼運行變慢。不要使用它們

第3章 函數做用域和塊做用域

3.1 函數中的做用域

屬於這個函數的所有變量均可以在整個函數的範圍內使用及複用(事實上在嵌套的做用域中也可使用)。

function foo(a) {
    var b = 2;
    
    // 一些代碼
    
    function bar() {
        // ...
    }
    
    // 更多的代碼
    
    var c = 3;
}
複製代碼

foo(..)做用域中包含了標識符(變量、函數)a、b、c和bar。不管標識符聲明出如今做用域中的何處,這個標識符所表明的變量或函數都將附屬於所處的做用域。

全局做用域只包含一個標識符:foo

3.2 隱藏內部實現

最小特權原則(最小受權或最小暴露原則):在軟件設計中,應該最小限度地暴露必要內容,而將其餘內容都」隱藏「起來,好比某個模塊或對象的API設計。

function doSomething(a) {
    function doSomethingElse(a) {
        return a - 1;
    }
    
    var b;
    
    b = a + doSomethingElse( a * 2 );
    
    console.log( b * 3 );
}

doSomething( 2 ); // 15
複製代碼

bdoSomethingElse(..)都沒法從外部被訪問,而只能被doSomething(..)所控制,設計上將具體內容私有化了。

3.2.1 規避衝突

」隱藏「做用域中的變量和函數帶來的另外一個好處是能夠避免同名標識符之間的衝突。

function foo() {
    function bar(a) {
        i = 3; // 修改for循環所屬做用域中的i
        console.log( a + i );
    }
    
    for (var i = 0; i < 10; i++) {
        bar( i * 2 ); // 糟糕,無限循環了!
    }
}
foo();
複製代碼

bar(..)內部的賦值表達式i = 3意外的覆蓋了聲明在foo(..)內部for循環中的i。

解決方案:

  • 聲明一個本地變量,任何名字均可以,例如var i = 3
  • 採用一個徹底不一樣的標識符名稱,例如var j = 3

規避變量衝突的典型例子:

  • 全局命名空間

    第三方庫會在全局做用域中聲明一個名字足夠獨特的變量,一般是一個對象,這個對象被用做庫的命名空間,全部須要暴露給外界的功能都會成爲這個對象(命名空間)的屬性,而不是將本身的標識符暴露在頂級的詞法做用域中。

  • 模塊管理

    任何庫無需將標識符加入到全局做用域中,而是經過依賴管理器的機制將庫的標識符顯示的導入到另一個特定的做用域中。

3.3 函數做用域

var a = 2;

function foo() { // <-- 添加這一行
    
    var a = 3;
    console.log( a ); // 3
    
} // <-- 以及這一行
foo(); // <-- 以及這一行

console.log( a ); // 2
複製代碼

上述函數做用域雖然能夠將內部的變量和函數定義」隱藏「起來,可是會致使如下2個額外問題。

  • 必須聲明一個具名函數foo(),意味着foo這個名稱自己」污染「了所在的做用域。
  • 必須顯示地經過函數名foo()調用這個函數才能運行其中的代碼。

解決方案:

var a = 2;

(function foo(){ // <-- 添加這一行
    
    var a = 3;
    console.log( a ); // 3
    
})(); // <-- 以及這一行

console.log( a ); // 2
複製代碼

上述代碼包裝函數的聲明以(function...開始,函數會被當作函數表達式而不是一個標準的函數聲明來處理。

  • 區分函數聲明函數表達式最簡單的方法是看function關鍵字出如今聲明中的位置(不只僅是一行代碼,而是整個聲明中的位置)。
    • 函數聲明:function是聲明中的第一個詞
    • 函數表達式:不是聲明中的第一個詞
  • 函數聲明函數表達式之間最重要的區別是它們的名稱標識符將會綁定在何處。
    • 第一個片斷中,foo被綁定在所在做用域中,能夠直接經過foo()來調用它。
    • 第二個片斷中,foo被綁定在函數表達式自身的函數中,而不是所在的做用域。(function foo(){ .. }foo只能在..所表明的位置中被訪問,外部做用域不行。foo變量名被隱藏在自身中意味着不會非必要地污染外部做用域。
3.3.1 匿名和具名
setTimeout( function() {
    console.log("I wait 1 second!");
}, 1000 );
複製代碼

上述是匿名函數表達式,由於function()..沒有名稱標識符。

函數表達式能夠匿名,但函數聲明不能夠省略函數名。

匿名函數表達式有如下缺點:

  • 在棧追蹤中不會顯示出有意義的函數名,會使得調試困難。
  • 沒有函數名,當函數須要引用自身時只能使用已經過時arguments.callee引用
    • 遞歸
    • 事件觸發後事件監聽器須要解綁自身
  • 匿名函數省略了對於代碼可讀性/可理解性很重要的函數名。

解決方案:

行內函數表達式能夠解決上述問題,始終給函數表達式命名是一個最佳實踐。

setTimeout( function timeoutHandler() { // <-- 快看,我有名字了!
    console.log( "I waited 1 second!" );
}, 1000 );

複製代碼
3.3.2 當即執行函數表達式

當即執行函數表達式(IIFE,Immediately Invoked Function Expression)

  • 匿名/具名函數表達式

    第一個( )將函數變成表達式,第二個( )執行了這個函數

    var a = 2;
    (function IIFE() {
        
        var a = 3;
        console.log( a ); // 3
        
    })();
    
    console.log( a ); // 2
    
    複製代碼
  • 改進型(function(){ .. }())

    用來調用的( )被移進了用來包裝的( )中。

  • 當作函數調用並傳遞參數進去

    var a = 2;
    (function IIFE( global ) {
        
        var a = 3;
        console.log( a ); // 3
        console.log( global.a ); // 2
        
    })( window );
    
    console.log( a ); // 2
    
    複製代碼
  • 解決undefined標識符的默認值被錯誤覆蓋致使的異常

    將一個參數命名爲undefined,可是在對應的位置不傳入任何值,這樣就能夠保證在代碼塊中undefined標識符的值真的是undefined

    undefined = true;
    
    (function IIFE( undefined ) {
        
        var a;
        if (a === undefined) {
            console.log("Undefined is safe here!");
        }
    })();
    
    複製代碼
  • 倒置代碼的運行順序,將須要運行的函數放在第二位,在IIFE執行以後當作參數傳遞進去

    函數表達式def定義在片斷的第二部分,而後當作參數(這個參數也叫作def)被傳遞進IIFE函數定義的第一部分中。最後,參數def(也就是傳遞進去的函數)被調用,並將window傳入當作global參數的值。

    var a = 2;
    
    (function IIFE( def ) {
        def( window );
    })(function def( global ) {
       
        var a = 3;
        console.log( a ); // 3
        console.log( global.a ); // 2
        
    });
    
    複製代碼

3.4 塊做用域

表面上看JavaScript並無塊做用域的相關功能,除非更加深刻了解(with、try/catch 、let、const)。

for (var i = 0; i < 10; i++) {
    console.log( i );
}

複製代碼

上述代碼中i會被綁定在外部做用域(函數或全局)中。

var foo = true;

if (foo) {
    var bar = foo * 2;
    bar = something( bar );
    console.log( bar );
}

複製代碼

上述代碼中,當使用var聲明變量時,它寫在哪裏都是同樣的,由於它們最終都會屬於外部做用域。

3.4.1 with

塊做用域的一種形式,用with從對象中建立出的做用域僅在**with聲明中**而非外部做用域中有效。

3.4.2 try/catch

ES3規範中規定try/catch的catch分句會建立一個塊做用域,其中聲明的變量僅在catch中有效。

try {
    undefined(); // 執行一個非法操做來強制製造一個異常
}
catch (err) {
    console.log( err ); // 可以正常執行!
}

console.log( err ); // ReferenceError: err not found

複製代碼

當同一個做用域中的兩個或多個catch分句用一樣的標識符名稱聲明錯誤變量時,不少靜態檢查工具仍是會發出警告,實際上這並非重複定義,由於全部變量都會安全地限制在塊做用域內部。

3.4.3 let

ES6引入了let關鍵字,能夠將變量綁定到所在的任意做用域中(一般是{ .. }內部),即let爲其聲明的變量隱式地劫持了所在的塊做用域。

var foo = true;

if (foo) {
    let bar = foo * 2;
    bar = something( bar );
    console.log( bar );
}

console.log( bar ); // ReferenceError

複製代碼

存在的問題

let將變量附加在一個已經存在的的塊做用域上的行爲是隱式的,若是習慣性的移動這些塊或者將其包含在其餘的塊中,可能會致使代碼混亂。

解決方案

爲塊做用域顯示地建立塊。顯式的代碼優於隱式或一些精巧但不清晰的代碼。

var foo = true;

if (foo) {
    { // <-- 顯式的塊
        let bar = foo * 2;
        bar = something( bar );
        console.log( bar );
    }
}

console.log( bar ); // ReferenceError

複製代碼

在if聲明內部顯式地建立了一個塊,若是須要對其進行重構,整個塊均可以被方便地移動而不會對外部if聲明的位置和語義產生任何影響。

  • 在let進行的聲明不會在塊做用域中進行提高

    console.log( bar ); // ReferenceError
    let bar = 2;
    
    複製代碼
  • 一、垃圾收集

    function process(data) {
        // 在這裏作點有趣的事情
    }
    
    var someReallyBigData = { .. };
    
    process( someReallyBigData );
    
    var btn = document.getElementById( "my_button" );
    
    btn.addEventListener( "click", function click(evt) {
        console.log("button clicked");
    }, /*capturingPhase*/false );
    
    複製代碼

    click函數的點擊回調並不須要someReallyBigData。理論上當process(..)執行後,在內存中佔用大量空間的數據結構就能夠被垃圾回收了。可是,因爲click函數造成了一個覆蓋整個做用域的閉包,JS引擎極有可能依然保存着這個結構(取決於具體實現)。

  • 二、let循環

    for (let i = 0; i < 10; i++) {
        console.log( i );
    }
    
    console.log( i ); // ReferenceError
    
    複製代碼

    for循環頭部的let不只將i綁定到了for循環的塊中,事實上它將其從新綁定到了循環的每個迭代中,確保使用上一個循環迭代結束時的值從新進行賦值。

    {
        let j;
        for (j = 0; j < 10; j++) {
            let i = j; // 每一個迭代從新綁定!
            console.log( i ); 
       	} 
    }
    
    複製代碼
3.4.4 const

ES6引用了const,能夠建立塊做用域變量,但其值是固定的(常量)

var foo = true;

if(foo) {
    var a = 2;
    const b = 3; // 包含在if中的塊做用域常量
    
    a = 3; // 正常!
    b = 4; // 錯誤!
}

console.log( a ); // 3
console.log( b ); // ReferenceError!

複製代碼

第4章 提高

  • 任何聲明在某個做用域內的變量,都將附屬於這個做用域。
  • 包括變量和函數在內的全部聲明都會在任何代碼被執行前首先被處理。
  • var a = 2;會被當作兩個聲明,var a;a = 2;,第一個聲明在編譯階段進行,第二個賦值聲明會被留在原地等待執行階段
  • 全部的聲明(變量和函數)都會被**「移動」到各自做用域的最頂端,這個過程叫作提高**
  • 只有聲明自己會被提高,而包括函數表達式在內的賦值或其餘運行邏輯並不會提高。
a = 2;

var a;

console.log( a ); // 2

---------------------------------------
// 實際按以下形式進行處理
var a; // 編譯階段

a = 2; // 執行階段

console.log( a ); // 2

複製代碼
console.log( a ); // undefinde

var a = 2;

---------------------------------------
// 實際按以下形式進行處理
var a; // 編譯

console.log( a ); // undefinde

a = 2; // 執行

複製代碼
  • 每一個做用域都會進行變量提高
function foo() {
    var a;
    
    console.log( a ); // undefinde
    
    a = 2;
}

foo();

複製代碼
  • 函數聲明會被提高,可是函數表達式不會被提高
foo(); // 不是ReferenceError,而是TypeError!

var foo = function bar() {
    // ...
};

複製代碼

上面這段程序中,變量標識符foo()被提高並分配給所在做用域,所以foo()不會致使ReferenceError。此時foo並無賦值(若是它是一個函數聲明而不是函數表達式,那麼就會賦值),foo()因爲對undefined值進行函數調用而致使非法操做,所以拋出TypeError異常。

  • 即便是具名的函數表達式,名稱標識符在賦值以前也沒法在所在做用域中使用。
foo(); // TypeError
bar(); // ReferenceError

var foo = function bar() {
    // ...
};

---------------------------------------
// 實際按以下形式進行處理
var foo;

foo(); // TypeError
bar(); // ReferenceError

foo = function() {
    var bar = ...self...
    // ...
};

複製代碼

4.1 函數優先

  • 函數聲明和變量聲明都會被提高,可是,函數首先被提高,而後纔是變量
foo(); // 1

var foo;

function foo() {
    console.log( 1 ); 
};

foo = function() {
    console.log( 2 ); 
};

---------------------------------------
// 實際按以下形式進行處理

function foo() { // 函數提高是總體提高,聲明 + 賦值
    console.log( 1 ); 
};

foo(); // 1

foo = function() {
    console.log( 2 ); 
};

複製代碼
  • var foo儘管出如今function foo()...的聲明以前,但它是重複的聲明,且函數聲明會被提高到普通變量以前,所以被忽略
  • 後面出現的函數聲明能夠覆蓋前面的。
foo(); // 3

function foo() {
    console.log( 1 ); 
};

var foo = function() {
    console.log( 2 ); 
};

function foo() {
    console.log( 3 ); 
};

複製代碼
  • 一個普通塊內部的函數聲明一般會被提高到所在做用域的頂部,不會被條件判斷所控制。儘可能避免在普通塊內部聲明函數
foo(); // "b"

var a = true;
if (a) {
    function foo() { console.log( "a" ); };
}
else {
    function foo() { console.log( "b" ); };
}

複製代碼

第5章 做用域閉包

5.1 閉包

  • 當函數能夠記住並訪問所在的詞法做用域,即便函數名是在當前詞法做用域以外執行,這時就產生了閉包。
function foo() {
    var a = 2;
    
    function bar() {
		console.log( a );
    }
    
    return bar;
}

var baz = foo();

baz(); // 2 ---- 這就是閉包的效果

複製代碼

bar()在本身定義的詞法做用域之外的地方執行。

bar()擁有覆蓋foo()內部做用域的閉包,使得該做用域可以一直存活,以供bar()在以後任什麼時候間進行引用,不會被垃圾回收器回收

  • bar()持有對foo()內部做用域的引用,這個引用就叫作閉包。
// 對函數類型的值進行傳遞
function foo() {
    var a = 2;
    
    function baz() {
		console.log( a ); // 2
    }
    
    bar( baz );
}

function bar(fn) {
    fn(); // 這就是閉包
}

foo();

複製代碼
  • 把內部函數baz傳遞給bar,當調用這個內部函數時(如今叫作fn),它覆蓋的foo()內部做用域的閉包就造成了,由於它可以訪問a。
// 間接的傳遞函數
var fn;

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

function bar() {
    fn(); // 這就是閉包
}

foo();
bar(); // 2

複製代碼
  • 將內部函數傳遞到所在的詞法做用域之外,它都會持有對原始定義做用域的引用,不管在何處執行這個函數都會使用閉包。
function wait(message) {
    
    setTimeout( function timer() {
        console.log( message );
    }, 1000 );
}

wait( "Hello, closure!" );

複製代碼
  • 在引擎內部,內置的工具函數setTimeout(..)持有對一個參數的引用,這裏參數叫作timer,引擎會調用這個函數,而詞法做用域在這個過程當中保持完整。這就是閉包
  • 定時器、事件監聽器、Ajax請求、跨窗口通訊、Web Workers或者任何其餘的異步(或者同步)任務中,只要使用了回調函數,實際上就是在使用閉包
// 典型的閉包例子:IIFE
var a = 2;

(function IIFE() {
    console.log( a );
})();

複製代碼

5.2 循環和閉包

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

//輸入五次6

複製代碼
  • 延遲函數的回調會在循環結束時才執行,輸出顯示的是循環結束時i的最終值。
  • 儘管循環中的五個函數是在各個迭代中分別定義的,可是它們都被封閉在一個共享的全局做用域中,所以實際上只有一個i

嘗試方案1:使用IIFE增長更多的閉包做用域

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

//失敗,由於IIFE做用域是空的,須要包含一點實質內容纔可使用

複製代碼

嘗試方案2:IIFE增長變量

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

// 正常工做

複製代碼

嘗試方案3:改進型,將i做爲參數傳遞給IIFE函數

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

// 正常工做

複製代碼
5.2.1 塊做用域和閉包
  • let能夠用來劫持塊做用域,而且在這個塊做用域中聲明一個變量。
  • 本質上這是將一個塊轉換成一個能夠被關閉的做用域
for (var i = 1; i <= 5; i++) {
    let j = i; // 閉包的塊做用域!
    setTimeout( function timer() {
        console.log( j );
    }, j * 1000 );
}

// 正常工做

複製代碼
  • for循環頭部的let聲明會有一個特殊的行爲。變量在循環過程當中不止被聲明一次,每次迭代都會聲明。隨後的每一個迭代都會使用上一個迭代結束時的值來初始化這個變量。

上面這句話參照3.4.3–---2.let循環,即如下

{
    let j;
    for (j = 0; j < 10; j++) {
        let i = j; // 每一個迭代從新綁定!
        console.log( i ); 
   	} 
}

複製代碼

循環改進:

for (let i = 1; i <= 5; i++) {
    setTimeout( function timer() {
        console.log( i );
    }, i * 1000 );
}

// 正常工做

複製代碼

5.3 模塊

模塊模式須要具有兩個必要條件:

  • 必須有外部的封閉函數,該函數必須至少被調用一次(每次調用都會建立一個新的模塊實例,能夠經過IIFE實現單例模式)
  • 封閉函數必須返回至少一個內部函數,這樣內部函數才能在私有做用域中造成閉包,而且能夠訪問或者修改私有的狀態。
function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    
    function doSomething() {
        console.log( something );
    }
    
    function doAnother() {
        console.log( another.join( " ! ") );
    }
    
    return {
        doSomething: doSomething,
        doAnother: doAnother
    }
}

var foo = CoolModule();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

// 一、必須經過調用CoolModule()來建立一個模塊實例
// 二、CoolModule()返回一個對象字面量語法{ key: value, ... }表示的對象,對象中含有對內部函數而不是內部數據變量的引用。內部數據變量保持隱藏且私有的狀態。

複製代碼
  • 使用IIFE實現單例模式

當即調用這個函數並將返回值直接賦予給單例的模塊標識符foo。

var foo = (function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    
    function doSomething() {
        console.log( something );
    }
    
    function doAnother() {
        console.log( another.join( " ! ") );
    }
    
    return {
        doSomething: doSomething,
        doAnother: doAnother
    }
})();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

複製代碼

5.5.1 現代的模塊機制

大多數模塊依賴加載器/管理器本質上是將這種模塊定義封裝進一個友好的API。

var MyModules = (function Manager() {
    var modules = {};
    
    function define(name, deps, impl) {
        for (var i = 0; i < deps.length; i++ ) {
			deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply( impl, deps ); // 核心,爲了模塊的定義引用了包裝函數(能夠傳入任何依賴),而且將返回值(模塊的API),儲存在一個根據名字來管理的模塊列表中。
    }
    
    function get(name) {
        return modules[name];
    }
    
    return {
        define: define,
        get: get
    };
    
})();

複製代碼

使用上面的函數來定義模塊:

MyModules.define( "bar", [], function() {
    function hello(who) {
        return "Let me introduct: " + who;
    }
    
    return {
        hello: hello
    };
} );

MyModules.define( "foo", ["bar"], function(bar) {
    var hungry = "hippo";
    
    function awesome() {
        console.log( bar.hello( hungry ).toUpperCase() );
    }
    
    return {
        awesome: awesome
    };
} );

var bar = MyModules.get( "bar" );
var foo = MyModules.get( "foo" );

console.log(
	bar.hello( "hippo" );
) // Let me introduct: hippo

foo.awesome(); // LET ME INTRODUCT: HIPPO

複製代碼

5.5.2 將來的模塊機制

在經過模塊系統進行加載時,ES6會將文件當作獨立的模塊來處理。每一個模塊均可以導入其餘模塊或特定的API成員,一樣能夠導出本身的API成員。

ES6模塊沒有「行內」格式,必須被定義在獨立的文件中(一個文件一個模塊)

  • 基於函數的模塊不能被靜態識別(編譯器沒法識別),只有在運行時纔會考慮API語義,所以能夠在運行時修改一個模塊的API。
  • ES6模塊API是靜態的(API模塊不會在運行時改變),會在編譯期檢查對導入模塊的API成員的引用是否真實存在。
// bar.js

function hello(who) {
    return "Let me introduct: " + who;
}

export hello;


// foo.js
// 僅從「bar」模塊導入hello()
import hello from "bar";

var hungry = "hippo";

function awesome() {
    console.log(
    	hello( hungry ).toUpperCase();
    );
}

export awesome;

// baz.js
// 導入完整的「foo」和」bar「模塊
module foo from "foo";
module bar from "bar";

console.log(
	bar.hello( "rhino")
); // Let me introduct: rhino

foo.awesome(); // LET ME INTRODUCT: HIPPO

複製代碼
  • import:將一個模塊中的一個或多個API導入到當前做用域中,並分別綁定在一個變量上
  • module:將整個模塊的API導入並綁定到一個變量上。
  • export:將當前模塊的一個標識符(變量、函數)導出爲公共API

附錄A 動態做用域

  • 詞法做用域是在寫代碼或者定義時肯定的,關注函數在何處聲明,做用域鏈基於代碼嵌套。
  • 動態做用域是在運行時肯定的(this也是),關注函數從何處調用,做用域鏈基於調用棧。
  • JavaScript並不具有動態做用域,它只有詞法做用域。可是this機制某種程度上很像動態做用域。
// 詞法做用域,關注函數在何處聲明,a經過RHS引用到了全局做用域中的a
function foo() {
    console.log( a ); // 2
}

function bar() {
    var a = 3;
    foo();
}

var a = 2;
bar();

-----------------------------
// 動態做用域,關注函數從何處調用,當foo()沒法找到a的變量引用時,會順着調用棧在調用foo()的地方查找a
function foo() {
    console.log( a ); // 3(不是2!)
}

function bar() {
    var a = 3;
    foo();
}

var a = 2;
bar();

複製代碼

附錄B 塊做用域的替代方案

ES3開始,JavaScript中就有了塊做用域,包括with和catch分句。

// ES6環境
{
    let a = 2;
    console.log( a ); // 2
}

console.log( a ); // ReferenceError

複製代碼

上述代碼在ES6環境中能夠正常工做,可是在ES6以前的環境中如何實現呢?

答案是使用catch分句,這是ES6中大部分功能遷移的首選方式。

try {
    throw 2;
} catch (a) {
    console.log( a ); // 2
}

console.log( a ); // ReferenceError

複製代碼

B.1 Traceur

// 代碼轉換成以下形式
{
    try {
        throw undefined;
    } catch (a) {
        a = 2;
        console.log( a ); // 2
    }
}

console.log( a ); // ReferenceError

複製代碼

B.2 隱式和顯式做用域

let聲明會建立一個顯式的做用域並與其進行綁定,而不是隱式地劫持一個已經存在的做用域(對比前面的let定義)。

let (a = 2) {
    console.log( a ); // 2
}

console.log( a ); // ReferenceError

複製代碼

存在的問題:

let聲明不包含在ES6中,Traceur編譯器也不接受這種代碼

  • 方案一:使用合法的ES6語法而且在代碼規範上作一些妥協
/*let*/ { let a = 2;
    console.log( a );
}

console.log( a ); // ReferenceError

複製代碼
  • 方案二:使用let-er工具,生成徹底標準的ES6代碼,不會生成經過try/catch進行hack的ES3替代方案
{
    let a = 2;
    console.log( a );
}

console.log( a ); // ReferenceError

複製代碼

B.3 性能

  • try/catch的性能的確很糟糕,但技術層面上沒有合理的理由來講明try/catch必須這麼慢,或者會一直慢下去。
  • IIFE和try/catch不是徹底等價的,由於若是把一段代碼中的任意一部分拿出來用函數進行包裹,會改變這段代碼的含義,其中的this、return、break和continue都會發生變化。IIFE並非一個普適的方案,只適合在某些狀況下進行手動操做。

交流

進階系列文章彙總以下,內有優質前端資料,以爲不錯點個star。

github.com/yygmind/blo…

我是木易楊,網易高級前端工程師,跟着我每週重點攻克一個前端面試重難點。接下來讓我帶你走進高級前端的世界,在進階的路上,共勉!

相關文章
相關標籤/搜索