JavaScript 閉包詳解

閉包(Closure)是前端開發者常常會聽到的一個概念,也是咱們在求職面試中常常會遇到的題目之一。透過表象去理解閉包的本質,對前端開發者來講是進階的必經之路。javascript

閉包跟執行上下文中的變量對象和做用域鏈有着千絲萬縷的關係,深入理解變量對象以及做用域鏈對理解閉包的本質有很大的幫助。前端

MDN 中對閉包的定義是:閉包是函數和聲明該函數的詞法環境的組合。java

我認爲 MDN 中對閉包的定義比較抽象以至並不能讓咱們很清楚地知道閉包是什麼東西。在這裏我直接給閉包作一個我認爲比較本質且好理解(前提固然是你對執行上下文系列已經有相應的瞭解啦)的定義:閉包是指其所在執行上下文已經出棧,但仍訪問了其所在執行上下文變量對象的函數面試

咱們常常問,閉包是什麼?其實從上述的定義咱們能夠很簡單地知道,閉包是一個函數。但跟普通的函數不同,閉包是具備特殊能力的函數。算法

從上述的定義咱們能夠知道,閉包具備兩個特色,即bash

  • 其所在執行上下文已經出棧
  • 訪問了其所在執行上下文變量對象

這裏須要注意的是,「其所在執行上下文」指的是閉包所在函數對應的執行上下文,而不是指閉包自己所對應的執行上下文。閉包

一個簡單的可以產生閉包的例子是,在函數 A 中建立了函數 B 而且返回函數 B,當函數 B 執行時,若是訪問了 A 中的變量,則產生了閉包。模塊化

//一個簡單的產生閉包的例子
function A(){
    var a = 2;
    function B(){
        console.log(a)
    }

    return B;
}

A()();  // 2
複製代碼

在大部分書中,對閉包的概念解釋爲:可以讀取其它函數內部變量的函數。在上述的簡單例子中,即以函數 B 指代閉包,在咱們平時開發過程當中,也常常會以函數 B 來指代閉包。而在 Chrome 的開發者工具中,則會以函數 A 指代閉包。函數

那麼,閉包的本質又是什麼呢?工具

咱們都知道,JavaScript 擁有自動的垃圾回收機制,當一個值失去引用的時候,垃圾回收機制會根據特殊的算法找到它並將其回收。

函數的執行上下文在出棧後,其變量對象會失去引用等待被回收,而閉包的存在會阻止這一過程,由於閉包的做用域鏈包含了其所在執行上下文的變量對象。

舉個例子來講明其中的過程,假設有一個 JavaScript 文件中包含以下代碼

var a = 1;
function fn1(){
    var b = 2;
    function fn2(){
        return b;
    }
    
    return fn2;
}

var fn2=fn1();
fn2();
複製代碼

在代碼執行過程當中,執行上下文棧的行爲以下

/*僞代碼*/
// 代碼執行時最早進入全局環境,全局上下文被建立併入棧
ECStack.push(globalContext);
// fn1 被調用,fn1 函數上下文被建立併入棧
ECStack.push(<fn1> functionContext);
// fn1 執行完畢,fn1 函數上下文出棧
ECStack.pop();
// fn2 被調用,fn2 函數上下文被建立併入棧
ECStack.push(<fn2> functionContext);
// fn2 執行完畢,fn2 函數上下文出棧
ECStack.pop();
// 代碼執行完畢,全局上下文出棧
ECStack.pop();
複製代碼

從執行上下文棧的行爲咱們可知,在 fn2 函數執行的時候,其實 fn1 上下文已經出棧了,按照 javascript 的垃圾回收機制,fn1 上下文的變量對象失去引用後會被垃圾回收機制回收,但因爲在 fn2 上下文的做用域鏈包含了 fn1 上下文的變量對象,因此 fn1 上下文的變量對象不會被垃圾回收機制回收。

我在以前的文章說過,函數做用域是在函數被定義(聲明)的時候肯定的。每個函數都會包含一個 [[scope]] 內部屬性,在函數被定義的時候,該函數的 [[scope]] 屬性會保存其上層上下文的變量對象,造成包含上層上下文變量對象的層級鏈。

從代碼中可知,在 fn2 函數被定義的時候,其上層上下文包括了 fn1 上下文和全局上下文

fn2.[[scope]]=[fn1Context.VO,globalContext.VO]
複製代碼

當 fn2 被調用的時候,其執行上下文被建立併入棧,此時會生成變量對象並將該變量對象添加進做用域鏈的頂端,而且將 [[scope]] 添加進做用域鏈

fn2Context.Scope=[fn2Context.VO].concat([[scope]])
=>
fn2Context.Scope=[fn2Context.VO,fn1Context.VO,globalContext.VO]
即
fn2Context={
    scope:[fn2Context.VO,fn1Context.VO,globalContext.VO]
}
複製代碼

可見,fn2 上下文的做用域鏈中包含了 fn1 上下文的變量對象,而且因爲 fn2 訪問了 fn1 中的變量,因此阻止了 fn1 上下文的變量對象被垃圾回收機制回收。

讓咱們來看一個面試題常常會遇到的一個關於閉包的很經典的題目~

var arr = [];
for (var i = 0; i < 3; i++) {
    arr[i] = function () {
        console.log(i);
    };
}

arr[0]();
arr[1]();
arr[2]();
複製代碼

求出上面代碼三個函數的輸入結果。

若是對前面執行上下文系列文章已經理解透徹的童鞋,應該可以很容易得出,三個函數都是輸出 3 。讓咱們來分析一波~

在 arr[0] 函數執行以前,咱們能夠知道,全局上下文的變量對象以下所示

globalContext = {
    VO: {
        arr: [...],
        i: 3
    }
}
複製代碼

在 arr[0] 被調用執行時,其做用域鏈在函數上下文的建立階段被建立,其做用域鏈以下

arr[0]Context = {
    Scope: [arr[0]Context.VO, globalContext.VO]
}
複製代碼

根據做用域鏈,arr[0] 函數會如今自身的變量對象中尋找 i ,若是找不到,會到全局上下文變量對象中尋找 i,因此最終輸出 3 。

arr[1]、arr[2] 的分析與上述一致。

那麼怎麼使上述三個函數的輸出結果是 0 1 2 呢?

一個經典的解決方式是經過閉包來實現,代碼以下

var arr = [];
for (var i = 0; i < 3; i++) {
    arr[i] = (function (i) {
        return function(){
            console.log(i);
        }
    })(i);
}

arr[0]();  //  0
arr[1]();  //  1
arr[2]();  //  2
複製代碼

一樣地,在 arr[0] 函數執行前,全局上下文的變量對象跟以前同樣沒有任何變化。

而在 arr[0] 被調用執行時,其做用域鏈在函數上下文的建立階段被建立,其做用域鏈以下

arr[0]Context = {
    Scope: [arr[0]Context.VO, 匿名函數Context.VO, globalContext.VO]
}
複製代碼

能夠看到其做用域鏈發生了變化,arr[0] 的變量對象以後緊跟着匿名函數的變量對象

匿名函數Context = {
    VO: {
        Arguments:{
            0:0,
            length:1
        },
        i: 0
    }
}
複製代碼

在匿名函數的變量對象中,能找到訪問的變量 i,因此獲得 i 值爲 0.

arr[1]、arr[2] 的分析與上述一致,獲得的 i 值分別爲 一、2 。

經過閉包,咱們可以訪問其它函數上下文的變量對象

在實踐中,閉包的應用場景不少,但其都是依據閉包可以訪問其它函數上下文的變量對象的特性。

閉包可以被應用在模塊化中:

;(function(){
    var a=1;
    var addOne=function(x){
        return x+a;
    }
    window.addOne=addOne;
})()

console.log(addOne(2));  // 3
複製代碼

如上是經過函數自執行以及閉包實現模塊化的一個例子,經過將函數添加到全局對象的方式將方法(這裏是閉包)暴露出來,addOne 閉包訪問了自執行函數中的變量 a 。

閉包可以被應用在柯里化中:

柯里化是一種將使用多個參數的一個函數轉換成一系列使用一個參數的函數的技術。

柯里化的實現方式一樣用到了閉包,在這邊文章中不作多餘的贅述,感興趣的童鞋能夠查閱柯里化的資料。

閉包的應用場景多種多樣,咱們能夠經過閉包實現許多不一樣的功能。閉包的魅力,須要咱們去理解其本質,並在實踐中靈活地應用它。

閉包的征途,是星辰大海

以爲還不錯的小夥伴,能夠關注一波公衆號哦。

相關文章
相關標籤/搜索