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

木易楊說發表於1970-01-01

第1章 作用域是什麼

  • 問題1:變數儲存在哪裡?
  • 問題2:程式需要時如何找到它們?

1.1 編譯原理

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

傳統編譯語言流程中,程式在執行之前會經歷三個步驟,統稱為“編譯”。

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

    將由字元組成的字串分解成(對程式語言來說)有意義的程式碼塊。

    var a = 2;
    複製程式碼

    上面這段程式會被分解成以下詞法單元:var、a、=、2、;。

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

  • 解析/語法分析(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個不同的宣告。

  • 1、編譯器在編譯時處理(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
    複製程式碼
  • 2、引擎在執行時處理(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個獨立的步驟。

  • 1、var a在其作用域中宣告新變數
  • 2、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;
    
    複製程式碼
  • 1、垃圾收集

    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引擎極有可能依然儲存著這個結構(取決於具體實現)。

  • 2、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

// 1、必須通過呼叫CoolModule()來建立一個模組例項
// 2、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…

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

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

相關文章