先有蛋仍是先有雞?JavaScript 做用域與閉包探析

引子

先看一個問題,下面兩個代碼片斷會輸出什麼?express

// Snippet 1
a = 2;
var a;
console.log(a);

// Snippet 2
console.log(a);
var a = 2;

若是瞭解過 JavaScript 變量提高相關語法的話,答案是顯而易見的。本文做爲《你不知道的 JavaScript》第一部分的閱讀筆記,順便來總結一下對做用域與閉包的理解。閉包

1、先有蛋仍是先有雞

上面問題的答案是:異步

  1. -> 2函數

  2. -> undefined工具

咱們從編譯器的角度思考:this

  • 引擎會在解釋 JavaScript 代碼以前首先對其進行編譯(沒錯,JavaScript 也是要進行編譯的!),而編譯階段中的一部分工做就是找到全部聲明,並用合適的做用域將他們關聯起來,即 包括變量和函數在內的全部聲明都會在任何代碼被執行前首先被處理調試

  • 當你看到 var a = 2;時可能會認爲這是一個聲明,但 JavaScript 實際上會將其當作兩個聲明:var aa = 2,第一個定義聲明是在編譯階段進行的,第二個賦值聲明會被留在原地等待執行階段處理。code

  • 打個比方,這個過程就好像變量和函數聲明從它們的代碼中出現的位置被「移動」到了最上面,這個過程就叫作 提高對象

  • 因此,編譯以後上面兩個代碼片斷是這樣的:遞歸

// Snippet 1 編譯後
var a;
a = 2;
console.log(a);    // -> 2

// Snippet 2 編譯後
var a;
console.log(a);    // -> undefined
a = 2;

因此結論就是:先有蛋(聲明),後有雞(賦值)

2、編譯

實際上,JavaScript 也是一門編譯語言。與傳統編譯語言的過程同樣,程序中的一段源代碼在執行以前會通過是三個步驟,統稱爲「編譯」:

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

  • 解析/語法分析(Parsing)

  • 代碼生成

簡單來講,任何 JavaScript 代碼片斷在執行前都要進行編譯(一般就在執行前)。

3、做用域

爲了理解做用域,能夠想象出有如下三種角色:

  • 引擎:從頭至尾負責整個 JavaScript 程序的編譯及執行過程。

  • 編譯器:引擎的好朋友之一,負責語法分析及代碼生成等髒活累活。

  • 做用域:引擎的另外一位好朋友,負責收集並維護全部聲明的標識符(變量)組成的一系列查詢,並實施一套很是嚴格的規則,肯定當前執行的代碼對這些標識符的訪問權限。

var a = 2; 爲例,過程以下:

  • 首先遇到 var a,編譯器會詢問做用域是否已經有一個名爲 a 的變量存在於同一個做用域的集合中。若是是,編譯器會忽略該聲明,繼續進行編譯;不然就會要求做用域在當前做用域的集合中聲明一個新的變量,並命名爲 a.

  • 而後,編譯器會爲引擎生成運行時所需的代碼,這些代碼被用來處理 a=2 這個賦值操做。引擎運行時會首選詢問做用域,在當前的做用域集合中是否存在一個叫作 a 的變量。若是是,引擎就會使用這個變量;若是否,引擎就會繼續查找該變量(一層一層向上查找)。

  • 最後,若是引擎最終找到了a變量,就會將 2 賦值給它,不然引擎就會舉手示意並拋出一個異常(ReferenceError)!

當一個塊或函數嵌套在另外一個塊或函數中時,就發生了做用域的嵌套。遍歷嵌套做用域鏈的規則很簡單:引擎從當前的執行做用域開始查找變量,若是找不到,就向上一級查找。當抵達最外層的全局做用域時,不管找到仍是沒找到,查找過程都會中止

4、函數聲明式 & 函數表達式

JavaScript 中建立函數有兩種方式:

// 函數聲明式 
function funcDeclaration() { 
    return 'A function declaration'; 
} 

// 函數表達式 
var funcExpression = function () { 
    return 'A function expression'; 
}

聲明式與表達式的差別:

  • 相似於 var 聲明,函數聲明能夠 提高 到其它代碼以前,但函數表達式不能,不過容許保留在本地變量範圍內;

  • 函數表達式能夠匿名,而函數聲明不能夠。

怎麼判斷是函數聲明式仍是函數表達式?

  • 一個最簡單的方法是看 function 關鍵字出如今聲明的位置,若是是在第一個詞,那麼就是函數聲明式,不然就是函數表達式。

函數表達式比函數聲明式更加有用的地方:

  • 是一個閉包

  • 能夠做爲其餘函數的參數

  • 能夠做爲當即調用函數表達式(IIFE

  • 能夠做爲回調函數

5、匿名函數 & 當即調用函數

「在任意代碼片斷外部添加包裝函數,能夠將內部的變量和函數定義「隱藏起來」,外部做用域就沒法訪問包裝函數內部的任何內容。那麼,可否更完全一些?若是必須聲明一個有具體名字的函數,這個名字自己就會「污染」所在做用域;其次,必須顯式經過函數名調用這個函數才能運行其中的代碼。若是函數不須要函數名(或者至少函數名能夠不污染所在做用域),而且可以自動運行,這就完美了!」——論匿名函數和理解調用函數的誕生。

匿名函數表達式最熟悉的場景就是回調函數:

setTimeout(function(){
    console.log("I waited 1 second!");
}, 1000);

匿名函數表達式書寫起來簡單快捷,不少庫和工具也傾向鼓勵使用這種風格的代碼。可是,它也有幾個缺點須要考慮:

  1. 匿名函數在棧追蹤中不會顯示出有意義的函數名,使得調試很困難。

  2. 若是沒有函數名,當函數須要引用自身時只能使用已通過期的 arguments.callee 引用,好比在遞歸中。另外一個函數須要引用自身的例子,是在事件觸發後事件監聽器須要解綁自身。

  3. 匿名函數省略了對於代碼可讀性、可理解性很重要的函數名。一個描述性的名稱可讓代碼不言自明。

因此,始終給函數表達式命名是一個最佳實踐:

setTimeout(function timeoutHandler(){
    console.log("I waited 1 second!");
});

因爲函數被包含在一對()括號內部,所以成爲了一個表達式,經過在末尾加上另一個()括號就能夠當即執行這個函數,好比:

(function foo(){
    // ...
})()

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

它有個術語:IIFE,表示:當即執行函數表達式(Immediately Invoked Function Expression)

它有另一個改進形式:

(function foo(){
    // ...
}())

不一樣點就是把最後的括號挪進去了,實際上 這兩種形式在功能上是一致的,選擇哪一個全憑我的喜愛

至於 IIFE 的另外一個很是廣泛的進階用法是 把它們當作函數調用並傳遞參數進去

var a = 2;
(function foo(global){
    var a = 3;
    console.log(a);    // -> 3
    console.log(global.a);    // -> 2
})(window);    // 傳入window對象的引用
console.log(a);    // -> 2

6、再談提高

如今咱們再來談一談提高。

// Snippet 3
foo();    // -> TypeError
bar();    // -> ReferenceError
var foo = function bar(){
    console.log(1);
};

爲何會輸出上面這兩個異常?咱們能夠從編譯器的角度把代碼看出這樣子:

var foo;    // 聲明提高
foo();      // 聲明但未定義爲 undefined,而後這裏進行了函數調用,因此返回 TypeError
bar();      // 無聲明拋出引用異常,因此返回 ReferenceError
foo = function bar(){
    console.log(1);    
};

而後再變化一下,同名的函數聲明和變量聲明在提高階段會怎麼處理:

foo();    // 到底會輸出什麼?
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() 的聲明以前,但它是重複的聲明(所以被忽略了),由於函數聲明會被提高到普通變量以前。即:函數聲明和變量聲明都會被提高,但函數會首先被提高,而後纔是變量(這也從側面說明了在 JavaScript 中「函數是一等公民」)。

再來:

foo();    // -> 3
function foo(){
    console.log(1);
}
var foo = function(){
    console.log(2);
}
function foo(){
    console.log(3);
}

解釋:儘管重複的 var 聲明會被忽略掉,但出現後面的函數聲明仍是能夠覆蓋前面的。

7、閉包

閉包是基於詞法做用域書寫代碼時所產生的天然結果,你甚至不須要爲了利用它們而有意識地建立閉包。閉包的建立和使用在你的代碼中隨處可見。你缺乏的是根據你本身的意願來識別、擁抱和影響閉包的思惟環境。

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

function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz();    // -> 2,閉包的效果!

如下是解釋說明:

  • 函數 bar() 的詞法做用域可以訪問 foo() 的內部做用域,而後咱們將 bar() 函數自己當作一個值類型緊傳遞。在這個例子中,咱們將 bar() 所引用的函數對象自己當作返回值。

  • foo()執行後,其返回值(也就是內部的 bar() 函數)賦值給變量 baz 並調用 baz(),實際上只是經過不一樣的標識符引用調用了內部的函數 bar()

  • bar() 顯示是能夠被正常執行,可是在這個例子中,它在本身定義的詞法做用域之外的地方執行。

  • foo() 執行後,一般會期待 foo() 的整個內部做用域都被銷燬,由於咱們知道引擎有垃圾回收器用來釋放再也不使用的內存空間。因爲看上去 foo() 的內容不會再被使用,因此很天然地會考慮對其進行回收。

  • 而閉包的神奇之處正是能夠阻止事情的發生。事實上,內部做用域依然存在,所以沒有被回收。誰在使用這個內部做用域?原來是 bar() 自己在使用。

  • bar() 所聲明的位置所賜,它擁有涵蓋 foo() 內部做用域的閉包,使得該做用域可以一直存活,以供 bar() 在以後任什麼時候間進行引用。

  • bar() 依然持有對該做用域的引用,而 這個引用就叫閉包

本質上,不管什麼時候何地,若是將函數(訪問它們各自的詞法做用域)當作第一級的值類型並處處傳遞,你就會看到閉包在這些函數中的應用。在定時器、事件監聽器、Ajax 請求、跨窗口通訊、Web Workers 或者任何其餘的異步(或者同步)任務中,只要使用了回調函數,實際上就是在使用閉包

再補充一個示例:

function foo() {
    function bar() {
        console.log('1');
    }
    function baz() {
        console.log('2');
    }

    var yyy = {
        bar: bar,
        baz: baz
    }
    return yyy;
}

var kkk = foo();    // kkk經過foo得到了yyy的引用,也就能夠調用bar和baz
        
kkk.bar();    // -> 1
kkk.baz();    // -> 2

9、動態做用域

事實上,JavaScript 並不具備動態做用域,它只有 詞法做用域(雖然 this 機制某種程度上很像動態做用域)。詞法做用域和動態做用域的主要區別爲:

  • 詞法做用域是在寫代碼或者定義時肯定的,而動態做用域是在運行時肯定的;

  • 詞法做用域關注函數在何處聲明,而動態做用域關注函數從何處調用。

像下面的代碼片斷,若是是動態做用域輸出的就是3而不是2了:

function foo(){
    console.log(a);    // -> 2
}
function bar(){
    var a = 3;
    foo();
}
var a = 2;
bar();

10、參考

相關文章
相關標籤/搜索