前端小祕密系列之閉包

拾雪兒在海邊發表於2019-02-26

本篇文章,我們來說說老(bei)生(xie)常(lan)談(le)的閉包,很多文章包括一些權威書籍中對於閉包的解釋不盡相同,每個人的理解也都不一樣。並且在其他語言中,也有對閉包的不同實現,讓我們來看看 Javascript 中是如何實現閉包的以及有哪些特性。

直接進入主題,上一段簡短的程式碼:

function outer(count) {
    var temp = new Array(count)
    
    function log() {
        console.log(temp)
    }
    
    log()
    
    function inner() {
        console.log('done')
    }
    
    return inner
}

var o = {}

for(var i = 0; i < 1000000; i++) {
    o["f"+i] = outer(i)
}
複製程式碼

如果你不知道這段程式碼可能帶來的問題,那麼這篇文章就值得你讀一讀。

執行上下文 & 作用域鏈

我們先把上面的問題放一放,先讓我們來看一看下面這段簡單的程式碼:

function outer() {
    var b = 2
    
    function inner() {
        console.log(a, b)
    }
    
    return inner
}

var a = 1
var inner = outer()
inner()
複製程式碼

JS引擎 中,是通過執行上下文棧來管理和執行程式碼的。上述程式碼的偽執行過程如下(本節內容主要參考冴羽大大的系列文章):

0、程式開始

ECStack = []
複製程式碼

1、建立全域性上下文globalContext,並將其入棧

ECStack = [
   globalContext 
]
複製程式碼

2、在執行之前初始化這個全域性上下文

globalContext = {
    VO: {
        a: undefined,
        inner: undefined,
        outer: function outer() {...}
    },
    Scope: [globalContext]
}
複製程式碼

初始化作用域鏈屬性 Scope[globalContext],此時程式碼還沒有執行,由於變數提升的緣故, innera 變數為 undefined,需要注意的是,這個時候,outer 函式的作用域 [[scope]] 內部屬性已確定(靜態作用域):

outer.[[scope]] = [
    globalContext.VO
]
複製程式碼

3、執行 globalContext 全域性上下文

在執行過程中,不斷改變 VO,執行到 a = 1 語句,將 VO 中的 a 置為 1,執行到 inner = outer() 語句,執行 outer 函式,進入 outer 函式的執行上下文。

4、建立outerContext執行上下文,將其入棧

ECStack = [
    outerContext,
    globalContext
]
複製程式碼

5、初始化 outerContext 執行上下文

outerContext = {
    VO: {
        b: undefined,
        inner: function inner() {...}
    },
    Scope: [VO, globalContext.VO]
}
複製程式碼

初始化作用域鏈屬性 Scope[VO].concat(outer.[[scope]])[VO, globalContext.VO]。 並在此時,確定 inner 函式的 [[scope]] 屬性:

inner.[[scope]] = [
    outerContext.VO,
    globalContext.VO
]
複製程式碼

6、執行 outerContext 上下文

執行語句 b = 2,將 VO 中的 b 置為 2,最後返回 inner

7、outerContext 執行完畢,出棧,繼續回到 globalContext 執行餘下的程式碼

ECStack = [
    globalContext
]
複製程式碼

繼續執行 inner = outer() 語句的賦值操作,將 outer 函式的返回結果賦給 inner 變數。

執行 inner() 語句,進入 inner 函式的執行上下文。

8、建立 innerContext 執行上下文,將其入棧

ECStack = [
    innerContext,
    globalContext
]
複製程式碼

9、初始化 innerContext 執行上下文

innerContext = {
    VO: {},
    Scope: [VO, outerContext.VO, globalContext.VO]
}
複製程式碼

初始化作用域鏈屬性 Scope[VO].concat(inner.[[scope]])[VO, outerContext.VO, globalContext.VO]

10、執行 innerContext 上下文

執行語句 console.log(a, b)VO 中沒有變數 a,往上查詢到 outerContext.VO,找到變數 aVO 中沒有變數 b,依次往上查詢到 globalContext.VO,找到變數 b。執行 console.log 函式,這裡同樣涉及到 變數console 的作用域鏈查詢,console.log 函式的執行上下文切換,不再贅述。

11、globalContext 執行完畢,出棧,程式結束

ECStack = []
複製程式碼

在第7步中,outerContext 執行完畢後,雖然其已出棧並在隨後被垃圾回收機制回收,但是可以看到 innerContext.Scope 仍有對 outerContext.VO 的引用。當 outerContext被回收後,outerContext.VO 並不會被回收,如下圖:

前端小祕密系列之閉包

這就使得我們在執行 inner 函式時仍可以通過其作用域鏈訪問到已執行完畢的 outer 函式中的變數,這就是閉包。

通過執行上下文和作用域鏈相關知識,我們引出了閉包的概念,讓我們繼續。

在第5步中,我們說到,初始化 outerContext 的過程中,同時確定了 inner 函式的作用域屬性 [[scope]][outerContext.VO, globalContext.VO],這其實是不準確的。

我們稍微改動下加上兩句程式碼:

function outer() {
    var b = 2
    var c = new Array(100000).join('*')
    var d = 3
    
    function inner() {
        console.log(a, b)
    }
    
    return inner
}

var a = 1
var inner = outer()
inner()
複製程式碼

聰明的你會發現,變數 cd 在inner中並不會用到,如果按照如上所述,將 inner 函式的 [[scope]] 屬性置為 [outerContext.VO, globalContext.VO],那麼變數 c (準確的說應該是變數 c 指向的那塊記憶體,下同)只能一直等到 inner 函式執行完畢後才會被銷燬,如果 inner 函式一直不執行的話,new Array(100000).join('*') 所佔用的記憶體一直無法被釋放。

那麼,你可能會想,我們在確定 inner 函式 [[scope]] 屬性的時候,只引用 inner 函式體內用到的變數不就好了嗎?實際上,JS引擎 和你一樣聰明,就是這麼幹的,在 Chrome 除錯工具下:

前端小祕密系列之閉包

可以看到,並沒有對變數 c 的引用,我們可以認為 inner 函式 [[scope]] 屬性為:

inner.[[scope]] = [
    Closure(outerContext.VO),
    globalContext.VO
]
複製程式碼

這裡,我們用 Closure 這樣一個函式來表示得到內部函式體中(包括內部函式中的內部函式,一直下去...)引用外部函式變數的集合,即閉包。

共享閉包

讓我們繼續前進的腳步,把上面的程式碼再稍微改動下:

function outer() {
    var b = 2
    var c = new Array(100000).join('*')
    var d = 3
    
    function log() {
        console.log(c)
    }
    
    function inner() {
        console.log(a, b)
    }
    
    log()
    
    return inner
}

var a = 1
var inner = outer()
inner()
複製程式碼

這裡,我們只是加了一個 log 函式,並將變數 c 列印出來。對於 inner 函式來說,並沒有什麼改變,果真如此嗎?我們看下 Chrome 除錯工具下作用域和閉包相關資訊。

outer函式執行之前:

前端小祕密系列之閉包

outer函式執行完成:

前端小祕密系列之閉包

咦,我們可以看到 inner 函式中的閉包中竟然包含了變數 c!但是 inner 函式中並沒有用到 c啊,你可能隱隱發現了什麼,是的,我們在 log 函式中引用了變數 c,這竟然會影響到 inner 函式的閉包。

在前文中,我們說到確定 inner 函式 [[scope]] 屬性時,會通過 Closure 函式得到 inner 函式體內引用到的所有閉包變數集合,那有多個內部函式呢?

其實,JS引擎 會通過 Clousre 函式得到 outer 函式下所有內部函式體中用到的閉包變數集合 Closure(outerContext.VO) ,並且所有的內部函式的 [[scope]] 屬性都引用這個共同的閉包,所以:

inner.[[scope]] = [
    Closure(outerContext.VO),
    globalContext.VO
]

log.[[scope]] = [
    Closure(outerContext.VO),
    globalContext.VO
]

Closure(outerContext.VO) = { b, c }
複製程式碼

讓我們來看看 log 函式的閉包資訊,同樣也有變數 b

前端小祕密系列之閉包

這裡,你可能會有疑問,變數 a 哪裡去了,其實變數 aglobalContext 下。

讀到這裡,細心的你會發現,這和文章開頭給出的程式碼幾乎一毛一樣啊,那究竟會帶來什麼問題呢,我想你應該知道了:記憶體洩露!

讓我們回到文章開頭的那段程式碼,返回的 inner 函式中,一直引用著 temp 變數,在 inner 函式不執行的情況下,temp 變數一直無法被垃圾回收。

我們再稍微改下程式碼:

function outer(count) {
    var temp = new Array(count)
    
    function log() {
        console.log(temp)
    }
    
    log()
    
    function inner() {
        var message = 'done'
        
        return function innermost() {
            console.log(message)
        }
    }
    
    return inner()
}

var o = {}

for(var i = 0; i < 1000000; i++) {
    o["f"+i] = outer(i)
}
複製程式碼

這裡,我們在 inner 函式裡面又包了一層,那最終返回的 innermost 還有對 temp 變數的引用嗎?

按照前面關於執行上下文相關內容的邏輯分析下去,其實是有的。innermost[[scope]] 屬性如下:

innermost.[[scope]] = [
    Closure(innerContext.VO): { message },
    Closure(outerContext.VO): { temp },
    globalContext
]
複製程式碼

當然,你可能會說,只要 inner 函式執行完成後,這些記憶體就會被回收掉。OK,那我們再來看一個更經典的例子:

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);
複製程式碼

unused 函式引用了 originalThing ,由於共享閉包的特性,theThing.someMethod 函式的閉包中也包含了對 originalThing 的引用,而 originalThing 是上一個 theThing,也就是說下一個 theThing 引用者上一個 theThing,形成了一個鏈。並隨著 setInterval 的執行,這個鏈越來越長,最終導致記憶體洩露,如下:

前端小祕密系列之閉包

如果把間隔時間改小點,分分鐘 out of memory

前端小祕密系列之閉包

這個例子來源於這裡,建議大家都點進去讀一讀(我記得之前有小夥伴翻譯了這篇文章的,一時找不到了,有知道中文翻譯連結的小夥伴在評論裡貼一下哈)。

Real Local Variable vs Context Variable

Real Local Variable,直譯過來就是真正的區域性變數,在這裡變數 d 就是 Real Local Variable,在C++層面,它可以直接分配在棧上,隨著 inner 函式執行完畢的出棧操作而被立即回收掉,不需要後面垃圾回收機制的干預。

Context Variable,上下文環境變數或者稱之為閉包變數,在這裡變數 b 就是 Context Variable, 在C++層面,它一定分配在堆上,儘管這裡它是一個基本型別。

那變數 c 呢,你可以認為它是一個 Real Local Variable,只是在棧上存的是指向這個 new Array() 的記憶體地址,而 new Array() 的實際內容是存在堆上的。

記憶體分佈如下:

前端小祕密系列之閉包

通過上面的分析,我們在很多文章中經常看到的 基本型別分佈在棧上,引用型別分佈在堆上 這句話明顯是錯誤的,對於被閉包引用的變數,不管其是什麼型別,肯定是分配在堆上的。

eval 與閉包

前文中已經提到,JS引擎 會分析所有內部函式體中引用了哪些外部函式的變數,但是對於 eval 的直接呼叫是無法分析的。因為無法預料到 eval 中可能會訪問那些變數,所以會把外部函式中的所有變數都囊括進來。

function outer() {
    var b = 2
    var c = new Array(100000).join('*')
    var d = 3
    
    function inner() {
        eval("console.log(1)")
    }
    
    return inner
}

var a = 1
var inner = outer()
inner()
複製程式碼

JS引擎 內心OS是這樣的:eval 這傢伙什麼事情都乾的出來,你們(區域性變數)統統不準走!

前端小祕密系列之閉包

如果,你在層層巢狀的函式下面來一個 eval,那麼 eval 所在函式的所有父級函式中的變數都無法被釋放掉,想想就可怕...

那對於 eval 的間接呼叫呢?

function outer() {
    var b = 2
    var c = new Array(100000).join('*')
    var d = 3
    
    function inner() {
        (0, eval)("console.log(a)")     // 輸出1
    }
    
    return inner
}

var a = 1
var inner = outer()
inner()
複製程式碼

這時 JS引擎 內心OS又是這樣的:eval 是誰,不認識,你們(區域性變數)都回家收衣服吧...

前端小祕密系列之閉包

其實,對於 evalfunction 的組合還有各種姿勢,比如:

function outer() {
    var b = 2
    var c = new Array(100000).join('*')
    var d = 3
    
    return eval("(function() { console.log(a) })")
    
    // return (0,eval)("(function() { console.log(a) })")
    
    // return (function(){ return eval("(function(){ console.log(a) })") })()
    
    // ...
    
    // 更多姿勢留待各位自己去發掘和嘗試,逃...
}

var a = 1
var inner = outer()
inner()
複製程式碼

到這裡就寫完了,希望各位對閉包有一個新的認識和見解。

最後歡迎各路大佬們啪啪打臉...

相關文章