前端小祕密系列之閉包

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

直接進入主題,上一段簡短的代碼:java

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)
}
複製代碼

若是你不知道這段代碼可能帶來的問題,那麼這篇文章就值得你讀一讀。git

執行上下文 & 做用域鏈

咱們先把上面的問題放一放,先讓咱們來看一看下面這段簡單的代碼:github

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

var a = 1
var inner = outer()
inner()
複製代碼

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

0、程序開始閉包

ECStack = []
複製代碼

一、建立全局上下文globalContext,並將其入棧函數

ECStack = [
   globalContext 
]
複製代碼

二、在執行以前初始化這個全局上下文工具

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

初始化做用域鏈屬性 Scope[globalContext],此時代碼尚未執行,因爲變量提高的緣故, innera 變量爲 undefined,須要注意的是,這個時候,outer 函數的做用域 [[scope]] 內部屬性已肯定(靜態做用域):ui

outer.[[scope]] = [
    globalContext.VO
]
複製代碼

三、執行 globalContext 全局上下文spa

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

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

ECStack = [
    outerContext,
    globalContext
]
複製代碼

五、初始化 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
]
複製代碼

六、執行 outerContext 上下文

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

七、outerContext 執行完畢,出棧,繼續回到 globalContext 執行餘下的代碼

ECStack = [
    globalContext
]
複製代碼

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

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

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

ECStack = [
    innerContext,
    globalContext
]
複製代碼

九、初始化 innerContext 執行上下文

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

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

十、執行 innerContext 上下文

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

十一、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()
複製代碼

到這裏就寫完了,但願各位對閉包有一個新的認識和看法。

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

相關文章
相關標籤/搜索