【萬字總結】帶你一步步吃透做用域、詞法做用域、變量提高、閉包

本文是對《你不知道的JavaScript(上卷)》的濃縮總結,已經看過的童鞋能夠簡單再過一遍,沒看過的童鞋建議仔細閱讀 javascript

前言

每當咱們看完各類面試寶典,知識點總結,覺得掌握了某些概念以後,有沒有去想過,這種「掌握」是否真能學以至用。當你將經過面試做爲學習的目的之後,你會頻頻體會到知其然不知其因此然的滋味。看完《你不知道的JavaScript(上卷)》以後,讓我對不少知識點有了通透的理解,遂將本身的理解結合書本整理分享給各位可能有我上述所描述問題的童鞋,也歡迎大佬們補充指正。java

讀完本文,但願你能夠對以下幾個知識點,從編譯原理的基礎上,有一種通透的理解:面試

  • 做用域
  • 詞法做用域
  • 變量提高
  • 做用域閉包

何爲做用域

在js中(ES6版本後)通常會存在3種做用域性能優化

  • 全局做用域

做用於全部代碼執行的環境(整個 script 標籤內部),或者一個獨立的 js 文件閉包

  • 函數做用域

做用於函數內的代碼環境,僅在該函數內部能夠訪問app

  • 塊做用域

let和const建立的做用域異步

  • eval做用域

僅在嚴格模式下存在,下一章會討論函數

做用域的概念想必你們必定都能滾瓜爛熟,即:工具

做用域就是一套規則,用於肯定在何處以及如何查找變量(標識符)的規則性能

這裏咱們注意到有兩個關鍵詞在何處以及如何查找,下面咱們就把js的執行拆開成編譯時運行時兩個維度,來看看這兩個維度分別作了些什麼,做用域在其中又是如何將在何處以及如何查找實現的

編譯時

首先JavaScript究竟是解釋型語言仍是編譯型語言,這裏不作過多討論,由於它同時具備兩種類型的特性,本文按照《你不知道的JavaScript(上卷)》中的定義,將其解釋爲一種特殊的編譯形語言來理解便可,編譯時也能夠理解爲預編譯時

在傳統編譯語言的流程中,程序中的一段源代碼在執行以前會經歷三個步驟,統稱爲「編譯」:

  1. 分詞/詞法分析(Tokenizing/Lexing)

例如,考慮程序var a = 1;。這段程序一般會被分解成爲下面這些詞法單元:vara=1;

  1. 解析/語法分析(Parsing)

這個過程是將步驟1獲得的詞法單元流轉換成一個由元素逐級嵌套所組成的表明了程序語法結構的樹,就是你們所熟知的AST抽象語法樹

  1. 代碼生成

將AST轉換爲可執行代碼的過程,例如var a = 1;的AST轉化爲一組機器指令,用來建立一個叫做a的變量(包括分配內存等),並將一個值儲存在a中。

引用原文裏的總結就是:

任何JavaScript代碼片斷在執行前都要進行編譯(一般就在執行前)。所以,JavaScript編譯器首先會對var a = 1;這段程序進行編譯,而後作好執行它的準備,而且一般立刻就會執行它。

這裏咱們着重看一下var a這個聲明,當編譯器發現這類聲明時,會詢問做用域是否已經有一個該名稱的變量存在於同一個做用域的集合中。若是是,編譯器會忽略該聲明,繼續進行編譯;不然它會要求做用域在當前做用域的集合中聲明一個新的變量,並命名爲a

看到這裏是否你就能夠理解,爲何js在運行時,能夠提早調用以後聲明的變量(變量提高),由於變量聲明在編譯時就已經完成了,因此運行時能夠經過做用域找到該變量

看到這裏其實咱們能夠知道,對變量的建立(包括分配內存等)實際上是在編譯過程當中就完成了,而這個過程當中,咱們的做用域其實就已經參與了進來,他會記得這些變量在何處,那麼記得之後要如何查找呢,咱們接着往下看

運行時

一樣以var a = 1;爲例,引擎運行時會首先詢問做用域,在當前的做用域集合中(一段程序中可能會有多個做用域,例如代碼在函數內執行就是當前函數做用域)是否存在一個叫做a的變量。若是有則直接使用這個變量;若是沒有則會繼續查找該變量(聰明的你應該已經猜到了這就須要用到做用域鏈了,這塊咱們以後再分析)。若是引擎最終找到了a變量,就會將1賦值給它。不然引擎就會拋出一個ReferenceError異常(關於異常後面會專門講)。

那麼做用域是如何找到變量a的呢,這就聊到了我面要講的第二個關鍵詞如何查找

做用域會協助引擎經過LHSRHS進行變量查找

LHS查詢是找到變量的容器自己,RHS查詢是取得變量的源值

例如var a = b;,首先經過LHS找到變量a,再經過RHS找到變量b的值,最後將b的值賦值給變量a。

對什麼時候使用LHSRHS,一種比較好的理解方式就是:「賦值操做的目標是誰(LHS)」以及「誰是賦值操做的源頭(RHS)

這裏對於LHS或RHS只作一個概述,有興趣的能夠看看原文裏的解讀

做用域嵌套

當一個塊或函數嵌套在另外一個塊或函數中時,就發生了做用域的嵌套。

上一節提到了若是做用域當前做用域集合中沒有找到要查找的變量會繼續查找,其實就是在外層嵌套的做用域中繼續查找,直到找到該變量,或抵達最外層的做用域(也就是全局做用域)爲止。

例如:

function fn() {
    console.log(a)
}
var a = 1
複製代碼

變量a嵌套在了函數fn的做用域中,當js引擎經過RHS(回憶一下爲何是RHS)查找變量a進行輸出時,當前做用域發現沒有這個變量,就會去外層做用域查找,最後在全局做用域中找到了變量a

這種做用域一層層嵌套造成的鏈路,咱們就稱之爲做用域鏈

關於異常

咱們都知道,在非嚴格模式下,當咱們爲一個未定義的變量賦值時,會隱式的建立一個全局變量

a = 1
console.log(a) //1
複製代碼

而在嚴格模式時,會拋出ReferenceError異常

"use strict"
a = 1
console.log(a) //ReferenceError
複製代碼

回憶一下變量的查詢方式,不難知道這裏使用的是LHS查詢,在嚴格與非嚴格模式下表現是不一樣的

那若是咱們是直接使用一個未聲明的變量呢?

"use strict"
console.log(a) //ReferenceError
複製代碼
console.log(a) //ReferenceError
複製代碼

回憶一下變量的查詢方式,不難知道這裏使用的是RHS查詢,在嚴格與非嚴格模式下表現是相同的

若是RHS查詢找到了一個變量,可是你嘗試對這個變量的值進行不合理的操做,好比試圖對一個非函數類型的值進行函數調用,或者調用該變量不存在的方法等,就會拋出TypeError異常

var a = 1
a() //TypeError
a.sort() //TypeError
複製代碼

引用原文裏的總結就是:

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

總結

  1. 編譯器在編譯時對變量進行聲明,做用域此時會記得這些變量在何處

  2. js引擎在運行時會在做用域的協助下,經過LHSRHS查詢取得變量或其源值(所謂的如何查找)

  3. 查找時會經過做用域鏈嵌套做用域中一層層查找,直到找到全局做用域爲止

  4. 不成功的RHS引用會拋出ReferenceError異常。不成功的LHS引用會隱式地建立一個全局變量(非嚴格模式下),或者拋出ReferenceError異常(嚴格模式下)

詞法做用域

引用原文中的概念:

簡單地說,詞法做用域就是定義在詞法階段的做用域。換句話說,詞法做用域是由你在寫代碼時將變量和函數寫在哪裏來決定的,所以當詞法分析器處理代碼時會保持做用域不變(大部分狀況下是這樣的)。

若是說做用域就是一套規則,詞法做用域就是做用域的一種工做模型,一種在詞法階段的就定義的做用域

詞法階段

如圖所示的代碼片斷有3個逐級嵌套的做用域 ABC。因爲 詞法做用域在寫代碼時就定義好了,所以在執行 C內部 console時,會首先在當前 做用域內尋找 abc三個變量,若是找不到再經過 做用域鏈逐級向上查找。所以首先在 C內找到了變量 c,再向上在 B內找到了變量 ab

這裏咱們思考一個問題,若是我在C內從新定義了a會怎麼樣

function fn(a) {
    var b = a * 2
    function inner(c) {
        var a = 2
        console.log(a,b,c)
    }
    inner(b*2)
}
fn(1)
複製代碼

答案是會直接使用C內的變量a,緣由是做用域查找始終從運行時所處的最內部做用域開始,直到碰見第一個匹配的標識符爲止。這裏就產生了遮蔽效應(內部的標識符「遮蔽」了外部的標識符)。也就意味着在C內永遠沒法訪問到B內的變量a。除非被遮蔽的變量在全局做用域內,則能夠經過window.xxx來訪問。

欺騙詞法

前面講到詞法做用域是在詞法階段就肯定了的,那麼有沒有可能在運行時來「修改」(也能夠說欺騙)詞法做用域呢?答案是能夠,可是徹底不推薦!

eval

如下討論僅在非嚴格模式內

引用原文裏的描述:

eval(..)函數能夠接受一個字符串爲參數,並將其中的內容視爲好像在書寫時就存在於程序中這個位置的代碼。換句話說,能夠在你寫的代碼中用程序生成代碼並運行,就好像代碼是寫在那個位置的同樣。

咱們經過一段代碼來理解一下:

function fn(str,a) {
    console.log(a,b)
}
var b = 2
fn(eval("var b = 3"), 1) //1,3
複製代碼

因爲eval內從新聲明瞭變量b,經過遮蔽效應致使輸出的b變成了3

with

with一般被看成重複引用同一個對象中的多個屬性的快捷方式,能夠不須要重複引用對象自己。他有一個反作用,會將變量泄漏到全局做用域。這裏具體不作展開,由於with其實很是冷門且不推薦使用,並且在嚴格模式已經被徹底禁止了,有興趣瞭解的童鞋能夠看看原文中的解釋。

性能影響

evalwith爲何不建議使用,很大緣由就是對性能有影響,這裏原文解釋的比較清楚:

JavaScript引擎會在編譯階段進行數項的性能優化。其中有些優化依賴於可以根據代碼的詞法進行靜態分析,並預先肯定全部變量和函數的定義位置,才能在執行過程當中快速找到標識符。但若是引擎在代碼中發現了eval(..)with,它只能簡單地假設關於標識符位置的判斷都是無效的,由於沒法在詞法分析階段明確知道eval(..)會接收到什麼代碼,這些代碼會如何對做用域進行修改,也沒法知道傳遞給with用來建立新詞法做用域的對象的內容究竟是什麼。最悲觀的狀況是若是出現了eval(..)with,全部的優化可能都是無心義的,所以最簡單的作法就是徹底不作任何優化。若是代碼中大量使用eval(..)with,那麼運行起來必定會變得很是慢。不管引擎多聰明,試圖將這些悲觀狀況的反作用限制在最小範圍內,也沒法避免若是沒有這些優化,代碼會運行得更慢這個事實。

總結

  1. 詞法做用域是由函數及變量聲明的位置來決定的,在執行過程當中也會以此爲做用域基準進行LHSRHS

  2. 能夠經過eval(..)with詞法做用域進行「欺騙」(非嚴格模式)

  3. 詞法欺騙的反作用是致使js引擎性能優化失效,使程序運行變慢,所以不建議使用

函數做用域和塊做用域

函數做用域

借用上一節的圖片

咱們瞭解了 詞法做用域的概念以後,就知道函數fn在聲明時就確認了本身的 做用域B,該 做用域就是 函數做用域

函數做用域由函數在聲明時所處的位置決定,與其在哪裏被調用以及如何被調用無關

屬於這個函數的所有變量均可以在整個函數的範圍內使用及複用,而在函數以外是沒法被訪問的,這個特性有一個很是好的做用。這裏咱們再引伸出一個概念:最小特權原則

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

設想一個這樣的場景,咱們須要向小明借錢

var money = 100
function borrowMoney(money) {
    return money
}
function xiaoming() {
    return borrowMoney(money)
}
xiaoming() //100
money = 1000000
xiaoming() //1000000
borrowMoney(5000000) //5000000
複製代碼

不難發現咱們只要修改money的值即可以像小明借任意的錢,甚至咱們能夠不通過小明容許直接borrowMoney(money)拿錢,顯然這是不合理的。咱們但願的是外部沒有對moneyborrowMoney的訪問權限,他們應該是屬於小明的私有變量,這時就須要函數做用域出馬了,按照最小特權原則,將moneyborrowMoney「隱藏」起來

function xiaoming() {
    var money = 100
    function borrowMoney(money) {
        return money
    }
    return borrowMoney(money)
}
xiaoming() //100
money = 1000000 //嚴格模式會ReferenceError
xiaoming() //100
borrowMoney(5000000) //ReferenceError
複製代碼

當即執行函數表達式

假設咱們如今的場景是,咱們的程序運行過程當中只須要借一次錢,且不care究竟是問誰借,那麼原來的寫法其實有兩點沒有必要,一個是建立了一個具名函數xiaoming,該名稱自己會「污染」所在做用域(即在全局做用域中聲明瞭一個不須要往後再被調用的函數名xiaoming),另外還須要顯示的調用函數xiaoming

若是函數不須要函數名(至少不「污染」所在做用域),而且可以自動運行,將會更加理想,好在js提供了可以同時解決這兩個問題的方案:

(function xiaoming() {
    var money = 100
    function borrowMoney(money) {
        return money
    }
    return borrowMoney(money)
})() //100
複製代碼

上述代碼經過括號包裹住函數xiaoming建立了函數表達式,再經過()執行該表達式,且雖然咱們依舊聲明瞭函數名xiaoming(爲了體現不會污染所在做用域,實際能夠直接寫匿名函數),但該名稱並未「污染」全局做用域,而是綁定在函數表達式自身函數中,僅在function內部能夠被訪問,也就是說:

(function xiaoming() {
    ···
    xiaoming()  //能夠訪問
})() //100
xiaoming()  //ReferenceError
複製代碼

區分函數聲明函數表達式最簡單的方法是看function關鍵字出如今聲明中的位置(不只僅是一行代碼,而是整個聲明中的位置)。若是function是聲明中的第一個詞,那麼就是一個函數聲明,不然就是一個函數表達式

從概念咱們就能夠知道,爲何以下寫法,均可以建立函數表達式並當即執行(某些非主流寫法不建議使用):

~function xiaoming() {···}()
!function xiaoming() {···}()
+function xiaoming() {···}()
複製代碼

塊做用域

實際在ES6出現以前,JavaScript裏並無嚴格的塊做用域,可是有些語法也會建立塊做用域,後面會講到

下面這段代碼你們必定不陌生

for(var i=0;i < 10;i++) {
    console.log(i)  //0,1,2,3,4,5,6,7,8,9
}
console.log(i)  //10
複製代碼

此時變量i其實是聲明在了全局做用域中,但其實這是徹底沒有必要的,反而會對全局形成「污染」

因而在ES6推出了letconst關鍵字,完全解決這了一問題

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

for(let i=0;i < 10;i++) {
    console.log(i)  //0,1,2,3,4,5,6,7,8,9
}
console.log(i)  //ReferenceError
複製代碼

還記得一道常見的面試題嗎?

for(var i=0;i < 10;i++) {
    setTimeout(function() {
        console.log(i)  //輸出10個10
    })
}
複製代碼

如今是否就很容易理解,爲何是10個10了吧。當setTimeout宏任務執行的時候,for循環在主線程已經執行完畢(不理解的須要去補習下事件循環機制),所以在執行console.logi的值已是10了,而後在全局做用域中找到i以後進行輸出

若是使用let造成塊級做用域,就能獲得理想中的輸出結果

for(let i=0;i < 10;i++) {
    setTimeout(function() {
        console.log(i)  //輸出0,1,2,3,4,5,6,7,8,9
    })
}
複製代碼

這段代碼可能不方便理解,那麼換一種寫法你們就能看懂塊級做用域是如何運做的:

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

從代碼能夠看出,在每一次for循環內,其實都隱式的綁定了一次變量i,且該變量僅在塊做用域內部可訪問,在執行console.log時訪問的是塊做用域內部該次循環所綁定的i

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

除了let之外,ES6還引入了const,一樣能夠用來建立塊做用域變量,惟一的區別是const的值不容許被修改

總結

  1. 函數做用域是JavaScript中最多見的做用域單元,能夠經過最小特權原則將內部變量「隱藏」起來

  2. 能夠經過括號包裹等方式建立函數表達式當即執行,在特定場合下減小全局「污染」和代碼量

  3. ES6引入和letconst關鍵字,能夠在所在代碼塊{···}內部建立塊級做用域

提高

關於變量提高,也是一個老生常談的問題,想必你們都知道有這麼一種機制,可是至於爲何,不必定十分理解,不要緊,看完本章你會明白透徹

var提高

考慮如下代碼:

a = 1
var a
console.log(a)  //1
複製代碼

輸出的結果並不是語義上理解的undefined,而是1

console.log(a)  //undefined
var a = 2
複製代碼

輸出的結果並不是語義上理解的報錯,而是undefined

爲何會出現上面的結果呢,咱們就要從編譯階段來理解其中的本質緣由了

經過前面的學習咱們已經知道,任何JavaScript代碼片斷在執行前都要進行編譯(一般就在執行前)。編譯階段中的一部分工做就是找到全部的聲明,並用合適的做用域將它們關聯起來。引用原文的描述就是:

包括變量和函數在內的全部聲明都會在任何代碼被執行前首先被處理。

所以第一段代碼按照編譯執行的正確處理順序以下:

var a   //編譯階段先處理變量a的聲明
a = 1
console.log(a)  //1
複製代碼

代碼按照編譯執行的正確處理順序以下:

var a   //編譯階段先處理變量a的聲明
console.log(a)  //undefined
a = 2   //賦值操做不會被提高
複製代碼

函數提高

對於函數來講也一樣如此,且函數做用域內部的聲明的變量一樣會在該做用域內部被首先處理:

fn()    //正常執行
function fn() {
    console.log(a)  //undefined
    var a = 2
}
複製代碼

代碼按照編譯執行的正確處理順序以下:

function fn() {   //編譯階段先處理fn的聲明
    var a   //編譯階段先處理變量a的聲明
    console.log(a)  //undefined
    a = 2   //賦值操做不會被提高
}
fn()    //正常執行
複製代碼

變量和函數聲明從它們在代碼中出現的位置被「移動」到了最上面。這個過程就叫做提高

同名時的提高

這裏咱們引伸一下,若是同時聲明瞭同名的變量和函數會怎麼樣呢?好比以下代碼:

console.log(a)  //輸出函數a
var a = 2
function a() {···}
複製代碼

編譯階段,函數的優先級比普通變量要高,所以會被優先聲明,代碼按照編譯執行的正確處理順序以下:

function a(){···}   //編譯階段先處理a的聲明
var a   //回憶咱們第一章講到的,遇到已聲明過的變量a,忽略該聲明,繼續進行編譯
console.log(a)  //輸出函數a
a = 2   //賦值操做不會被提高
複製代碼

若是是重複聲明同名函數呢:

a()  //輸出2,後聲明的函數a會覆蓋以前的,儘管a已經被聲明過
function a() {
    console.log(1)
}
function a() {
    console.log(2)
}
複製代碼

函數表達式的提高

咱們再作另外一個引伸,有時候咱們會經過函數表達式的方式來定義一個function,那若是提早調用會怎樣呢:

fn()    //TypeError
a()     //ReferenceError
var fn = function a() {···}
複製代碼

形成這種結果是因爲函數函數表達式中並不會被提高,只是相似一個賦值語句,能夠類比成函數a就是一個值,這個值不須要提早聲明,所以a也沒法提早執行,而變量fn聲明瞭可是尚未賦值,因此也沒法經過()來執行,代碼按照編譯執行的正確處理順序以下:

var fn   //編譯階段先處理fn的聲明
fn()   //至關於undefined(),從以前對異常的介紹能夠知道,屬於非法操做所以會報TypeError
a()    //未找到a的聲明,所以會報ReferenceError
fn = function () {  //賦值操做不會被提高
    var a = ...self...
    ···
}
複製代碼

暫時死區

想必你們對於暫時死區都不陌生,實際上就是因爲letconst不會被提高致使的,所以就會有以下代碼的輸出:

console.log(a)  //ReferenceError
let a = 1
複製代碼

ES6標準中對let/const聲明中的解釋,第13章中有以下一段文字:

關於暫時死區的描述大體意思就是:

當程序的控制流程在新的做用域(module function 或 block 做用域)進行實例化時,在此做用域中用let/const聲明的變量會先在做用域中被建立出來,但所以時還未進行詞法綁定,因此是不能被訪問的,若是訪問就會拋出錯誤。所以,在運行流程進入做用域建立變量,到變量能夠被訪問之間的這一段時間,就稱之爲暫時死區。

從我我的的理解就是,let聲明的變量,在編譯階段是會被建立的,可是不會綁定在詞法做用域中,相似造成了一個隱形的塊做用域,沒法被外部訪問,只有當運行時代碼執行到了變量聲明的位置時,纔會放開訪問權限

上述代碼能夠描述成相似下面這樣一段代碼:

{
    let a   //提早建立變量a,但沒法被訪問
}
console.log(a)  //ReferenceError
a = 1   //放開對a的訪問權限,並賦值
複製代碼

看到這裏想必你們已經對變量提高有了透徹的理解,達到了所謂的通透。再隨便給你一段代碼,也能按照編譯執行的階段分析出正確的處理順序了吧

總結

  1. 一個簡單的賦值語句,被拆開成兩部分進行,第一部分在編譯階段完成對變量函數的聲明,就是所謂的提高,第二部分在執行階段完成對變量的賦值

  2. 聲明自己會被提高,而包括函數表達式的賦值在內的賦值操做並不會提高

  3. 要注意避免重複聲明!不然會引發不少意想不到的問題

做用域閉包

本文的重頭戲閉包終於隆重登場了,想必這個近乎神話的概念,曾經也讓很多同窗痛苦不堪。今天就讓咱們在對做用域有了透徹的理解以後,再來揭開閉包的神祕面紗。引用原文的一段話:

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

閉包

首先回顧一下閉包的定義:

當函數能夠記住並訪問所在的詞法做用域時,就產生了閉包,即便函數是在當前詞法做用域以外執行。 咱們用一段代碼來解釋一下:

function fn() {
    var a = 1
    function bar() {
        console.log(a)
    }
    bar()
}
複製代碼

嵌套做用域的知識咱們能夠了解到,基於詞法做用域的查找規則,函數bar能夠訪問外部做用域中的變量a。而這種基於詞法做用域的規則,就是閉包最核心的一部分。從學術的角度說,函數bar其實已經擁有了一個涵蓋fn做用域的閉包,也能夠理解成bar封閉在了fn的做用域中,可是經過這種方式定義的閉包並不能直接進行觀察,咱們換一種寫法:

function fn() {
    var a = 1
    function bar() {
        console.log(a)
    }
    return bar
}
var baz = fn()
baz()   //輸出2,這就是閉包的效果
複製代碼

fn執行後,由於咱們知道因爲看上去fn的內容不會再被使用,因此很天然地會期待引擎的垃圾回收器來釋放再也不使用的內存空間,從而銷燬fn的整個內部做用域,對其進行回收。而閉包的「神奇」之處正是能夠阻止這件事情的發生。事實上內部做用域依然存在,所以沒有被回收。誰在使用這個內部做用域呢,正是bar自己。在這個例子中,var baz = fn()建立了bazbar的引用,所以執行baz就至關於執行了內部的函數bar,也就至關於bar在自身詞法做用域以外執行了(本例在全局做用域進行了執行),並訪問到了自身所在詞法做用域中的變量a,這就徹底跟概念吻合上了。

再看另外兩個例子:

function fn() {
    var a = 1
    function bar() {
        console.log(a)
    }
    baz(bar)
}
function baz(fn) {
    fn()    //這裏一樣也是閉包
}
複製代碼

咱們能夠發現,不管使用何種方式對函數類型的值進行傳遞,當函數在別處被調用時均可以觀察到閉包

不管經過何種手段將內部函數傳遞到所在的詞法做用域之外,它都會持有對原始定義做用域的引用,不管在何處執行這個函數都會使用閉包

上述代碼可能有點刻意而爲之,但我保證看了下面的例子,你會發現閉包其實在你的編碼過程當中無處不在:

function wait(msg) {
    setTimeout(function timer() {
        console.log(msg)
    }, 1000)
}
wait('hello')  //1s後執行timer輸出hello
複製代碼

函數timer具備涵蓋wait做用域閉包,所以還保留有對msg的引用

//使用jQuery
function clickHandler(el, name) {
    $(el).click(function active() {
        console.log('clicked:' + name)
    })
}
clickHandler('#btn1', 'btn1')   //單擊btn1,至關於執行active,輸出‘clicked btn1’
clickHandler('#btn2', 'btn2')   //單擊btn2,至關於執行active,輸出‘clicked btn2’
複製代碼

函數active具備涵蓋clickHandler做用域閉包,所以還保留有對name的引用

引用原文的表述:

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

當即執行函數與閉包

關於當即執行函數

var msg = 'hello'
(function() {
    console.log(msg)    //hello
})()
複製代碼

嚴格來說它並非閉包。由於函數並非在它自己的詞法做用域之外執行,而是在全局做用域執行。msg是經過普通的詞法做用域查找而非閉包被發現的。

當即執行函數是最經常使用來建立能夠被封閉起來的閉包的工具,例如:

var sayHello = (function() {
    var msg = 'hello'
    return function() {
        console.log(msg)
    }
})()
sayHello()  //hello
複製代碼

循環與閉包

回憶以前講let時提到,能夠經過塊做用域解決循環內部輸出理想值的問題,其實也能夠經過閉包來解決:

for(var i=0;i < 10;i++) {
    (function(j) {
        setTimeout(function() {
            console.log(j)  //依次輸出0,1,2,3,4,5,6,7,8,9
        })
    })(i)
}
複製代碼

就如同let建立塊做用域同樣,每次循環都經過當即執行函數建立了封閉的做用域,而每個定時器中的回調函數都保持着對這個做用域中變量j的引用,這個變量j實際就是咱們傳入的值i,所以每次綁定的j值都是不同的,最終纔會依次輸出0到9

模塊

模塊也是一種利用閉包強大威力的編碼方式,一種最簡單的寫法就是:

function Module() {
    var food = 'apple'
    var water = 'water'
    function eat() {
        console.log('eat ' + food)
    }
    function drink() {
        console.log('drink ' + water)
    }
    return {
        eat: eat,
        drink: drink
    }
}
var fn = Module()
fn.eat()    //eat apple
fn.drink()  //drink water
複製代碼

eatdrink函數具備涵蓋模塊實例內部做用域閉包(經過調用Module實現)。當經過返回一個含有屬性引用的對象的方式來將函數傳遞到詞法做用域外部時,咱們已經創造了能夠觀察和實踐閉包的條件。

ES6中爲模塊增長了一級語法支持。在經過模塊系統進行加載時,ES6會將文件看成獨立的模塊來處理。每一個模塊均可以導入其餘模塊或特定的API成員,一樣也能夠導出本身的API成員。 關於ES6中的模塊本文不作展開。

總結

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

  2. 當即執行函數並不等於閉包,可是是最經常使用來建立能夠被封閉起來的閉包的工具

  3. 利用閉包的特性能夠在循環中實現塊做用域的效果

  4. 能夠利用閉包的特性來實現模塊

  5. 閉包無處不在

結語

下篇文章將會把《你不知道的JavaScript(上卷)》後半部分梳理完,涉及的知識點包括:

  • this
  • 對象
  • 原型
  • 委託

敬請期待···

相關文章
相關標籤/搜索