拒絕抄書,完全消化閉包

前言

以前寫過關於閉包的文章,原本覺得本身懂了,後來面試時被問到懷疑人生。才明白本身只是以爲本身明白了而已,若是說要將一個東西理解的不折不扣,就不能「抄書」(我以前就是抄書),而是死摳每個知識點,一點含糊都會讓整個系統崩塌。原文地址javascript

ok,如今開始死摳。什麼是閉包?java

閉包就是可以讀取其餘函數內部變量的函數。例如在javascript中,只有函數內部的子函數才能讀取局部變量,因此閉包能夠理解成「定義在一個函數內部的函數「。在本質上,閉包是將函數內部和函數外部鏈接起來的橋樑 ——來自於百度百科git

閉包是基於詞法做用域書寫代碼時所產生的天然結果。當函數能夠記住並訪問所在的詞法做用域時,就產生了閉包。 ————《你不知道的js(上)》github

看不太懂,那就拆開看,什麼是詞法做用域?面試

詞法做用域

如圖,每一個框框中都是一個做用域,引擎在執行console.log()時(黃色框中的語句),會從內向外逐個做用域查找變量。在baz中,咱們找到了變量c,沒有找到a,b,就會往上一層找,bar中有b,c,baz,找到了b,同名變量c被忽略,以此類推,直至全部執行語句都匹配了變量,不然引擎解析失敗拋出錯誤。編程

圖示

除了詞法做用域,還有啥?

其實做用域包括詞法做用域和動態做用域,JavaScript中的做用域是詞法做用域(大部分的編程語言也是基於詞法做用域)。在上面的圖中,咱們能清晰地看出來,每一個函數的所有變量均可以在整個函數的範圍中使用或複用(嵌套的函數可使用外部函數的變量),這就是函數做用域。那麼只有函數才能建立做用域「框框」嗎?segmentfault

咱們看下面這幾句代碼:瀏覽器

for(var b=0;b<3;b++){}

console.log('b',b) // 3
複製代碼

上面的代碼中,沒有聲明任何函數,因此經過var聲明的變量b被綁定到外部做用域上,也就是全局。(不瞭解變量提高的同窗,能夠看個人這篇文章=>《詳解ES6暫存死區TDZ》),因此上述代碼至關於:閉包

var b;
for(b=0;b<3;b++){}
console.log('b',b) // 3
複製代碼

。。。是否是很奇葩,原本只想讓變量b在for循環中使用,for循環以後銷燬,爲啥要讓他污染到整個詞法做用域嘞?幸運的是,因爲人類的探索精神,和幾個瀏覽器爹們對JavaScript這個不健全的兒子的扶持,ES6中有了let和const,做爲塊做用域的補充。(明明都9012了,我爲啥還在寫ES6的東西=.=)以下,b在for循環結束時就會被銷燬,又因爲詞法做用域中不存在同名變量,因此這裏會報錯。異步

for(let b=0;b<3;b++){}
console.log('b',b) // Uncaught ReferenceError: b is not defined
複製代碼

咱們在理解塊做用域的時候,能夠將一個{}中當作一個塊。

做用域和上下文究竟是不是一個東西?

答案確定是"NO!!"上文中咱們已經明白了,做用域是在函數定義時決定的。上下文其實就是函數中this的指向,即當前函數運行時所掛載的對象。

const a=1
function foo(){
    console.log(this.a)
}

const obj={a:2,foo}

foo() // undefined
obj.foo() // 2
複製代碼

這裏有個小tips,爲啥const聲明的a,沒有像var同樣掛載到window上呢?其實祕密在這裏,《Javascript閉包:從理論到實現,[[Scopes]]的每一根毛都看得清清楚楚》 (寫本章時我也沒仔細讀這篇文章),const 聲明的a實際上是在[[scopes]]上。

循環和閉包

一道經典面試題

如下代碼爲何與預想的輸出不符?

// 代碼塊1
for (var i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i) // 輸出5次5
    }, 0)
}
複製代碼

假設A:由於setTimeout這塊的任務直接進入了事件隊列中,因此i循環以後i先變成了5,再執行setTimeoutsetTimeout中的箭頭函數會保存對i的引用,因此會打印5個5.

變體一:

// 代碼塊2
for (let i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i) // 輸出 0,1,2,3,4
    }, 0)
}
複製代碼

假設結論A成立,那麼上式應該也是輸出5次5,可是很明顯不是,因此結論A並不徹底正確。

那咱們去掉循環,先寫成最簡單的異步代碼:

function test(a){
    setTimeout(function timer(){
        console.log(a)
    })
}

test('hello')
複製代碼

執行testsetTimeouttimer函數放入了事件隊列,timer保留着test函數的做用域(在函數定義時建立的),test執行完畢,主線程上沒有其餘任務了,timer從事件隊列中出隊,執行timer,執行console.log(a),因爲閉包的緣由,a依然會保留着以前的引用,輸出'hello'

那咱們在回到題目中,由於兩段代碼中的不一樣只有聲明語句,因此咱們提出假設B:由於在代碼塊1中,匿名函數保留着外部詞法做用域,i都是在全局做用域上,代碼塊2中因爲存在塊做用域,因此它保留着每次循環時i的引用。

變體二:

// 代碼塊3
for (var i = 0; i < 5; i++) {
    ((i) => {
        setTimeout(function timer() {
            console.log(i) // 輸出 0,1,2,3,4
        }, 0)
    })(i)
}
複製代碼

使用IIFE傳遞了變量i給匿名函數,IIFE產生了一個新做用域,timer中保留對匿名函數中的i的引用,因此會依次輸出。

變體三:

// 代碼塊4
for (var i = 0; i < 5; i++) {
    (() => {
        setTimeout(function timer() {
            console.log(i) // 輸出 5個5
        }, 0)
    })()
}
複製代碼

跟變體2的區別爲IIFE沒有給匿名函數傳遞i,timer保留的做用域鏈中對i的引用仍是在全局做用域上。

通過以上兩個變體的驗證,因此假設B成立,即:因爲做用域鏈的變化,閉包中保留的參數引用也發生了變化,輸出的參數也發生了變化。

但願看完的小夥伴能夠完全明白「閉包」和做用域的關係,若是有任何錯誤請在下方評論區留言,歡迎指正。

推薦文章

  1. 深刻理解閉包以前置知識---做用域與詞法做用域
相關文章
相關標籤/搜索