首頁  >  文章  >  web前端  >  前端進階(四):詳細圖解作用域鏈與閉包

前端進階(四):詳細圖解作用域鏈與閉包

PHPz
PHPz原創
2017-04-04 17:39:471349瀏覽

前端進階(四):詳細圖解作用域鏈與閉包

攻克閉包難題


初學JavaScript的時候,我在學習閉包上,走了很多彎路。而這次重新回過頭來對基礎知識進​​行梳理,要講清楚閉包,也是一個非常大的挑戰。

閉包有多重要?如果你是初入前端的朋友,我沒有辦法直觀的告訴你閉包在實際開發中的無處不在,但是我可以告訴你,前端面試,必問閉包。面試官們常常用對閉包的了解程度來判定面試者的基礎水平,保守估計,10個前端面試者,至少5個都死在閉包上。

可是為什麼,閉包如此重要,還是有那麼多人沒有搞清楚呢?是因為大家不願意學習嗎?還真不是,而是我們透過搜尋找到的大部分講解閉包的中文文章,都沒有清晰明了的把閉包講解清楚。要嘛淺嚐輒止,要嘛高深莫測,要嘛乾脆就直接亂說一通。包括我自己曾經也寫過一篇關於閉包的總結,回頭一看,不忍直視[摀臉]。

因此本文的目的就在於,能夠清晰明了得把閉包說清楚,讓讀者老爺們看了之後,就把閉包給徹底學會了,而不是似懂非懂。

一、作用域與作用域鏈

在詳細講解作用域鏈之前,我預設你已經大概明白了JavaScript中的下面這些重要概念。這些概念將會非常有幫助。

如果暫時還沒明白,可以去看本系列的前三篇文章,本文文末有目錄連結。為了講解閉包,我已經為大家做好了基本的鋪墊。哈哈,真是好大一齣戲。

作用域

  • 在JavaScript中,我們可以將作用域定義為一套規則,這套規則用來管理引擎如何在在目前作用域以及嵌套的子作用域中根據識別符名稱進行變數查找。

    這裡的標識符,指的是

    變數名稱函數名稱

  • JavaScript中只有全域作用域與

    函數作用域(因為eval我們平時開發中幾乎不會用到它,這裡不討論)。

  • 作用域與執行上下文是完全不同的兩個概念。我知道很多人會混淆他們,但是一定要仔細區分。

    JavaScript程式碼的整個執行過程,分成兩個階段,程式碼編譯階段與程式碼執行階段。編譯階段由編譯器完成,將程式碼翻譯成可執行程式碼,這個階段作用域規則會決定。執行階段由引擎完成,主要任務是執行可執行程式碼,執行上下文在這個階段建立。

前端進階(四):詳細圖解作用域鏈與閉包

程式

#作用域鏈

回顧一下上一篇文章我們分析的執行上下文的

生命週期,如下圖。

前端進階(四):詳細圖解作用域鏈與閉包

執行上下文生命週期

我們發現,作用域鍊是在執行上下文的建立階段產生的。這個就奇怪了。上面我們剛剛說作用域在編譯階段確定規則,可是為什麼作用域鏈卻在執行階段確定呢?

之所以有這個疑問,是因為大家對作用域和作用域鏈有一個誤解。我們上面說了,作用域是一套規則,那麼作用域鍊是什麼呢?是這套規則的具體實作。所以這就是作用域與作用域鏈的關係,相信大家都應該明白了吧。

我們知道函數在呼叫啟動時,會開始建立對應的執行上下文,在執行上下文產生的過程中,變數對象,作用域鏈,以及this的值會分別被決定。之前一篇文章我們詳細說明了變數對象,而這裡,我們將詳細說明作用域鏈。

作用域鏈,是由當前環境與上層環境的一系列變數物件組成,它保證了目前執行環境對符合存取權限的變數和函數的有序存取。

為了幫助大家理解作用域鏈,我我們先結合一個例子,以及對應的圖示來說明。

var a = 20;

function test() {
    var b = a + 10;

    function innerTest() {
        var c = 10;
        return b + c;
    }

    return innerTest();
}

test();

在上面的範例中,全域,函數test,函數innerTest的執行上下文先後建立。我們設定他們的變數物件分別為VO(global),VO(test), VO(innerTest)。而innerTest的作用域鏈,則同時包含了這三個變數對象,所以innerTest的執行上下文可如下表示。

innerTestEC = {
    VO: {...},  // 变量对象
    scopeChain: [VO(innerTest), VO(test), VO(global)], // 作用域链
    this: {}
}

是的,你沒有看錯,我們可以直接用一個數組來表示作用域鏈,數組的第一項scopeChain[0]為作用域鏈的最前端,而陣列的最後一項,為作用域鏈的最末端,所有的最末端都是全域變數物件。

很多人會誤解為目前作用域與上層作用域為包含關係,但其實不是。以最前端為起點,最末端為終點的單方向通道我認為是更貼切的形容。如圖。

前端進階(四):詳細圖解作用域鏈與閉包

作用域鏈圖示

#注意,因為變數物件在執行上下文進入執行階段時,就變成了活動對象,這一點在上一篇文章中已經講過,因此圖中使用了AO來表示。 Active Object

是的,作用域鏈是由一系列變數物件組成,我們可以在這個單向通道中,查詢變數物件中的標識符,這樣就可以存取到上一層作用域中的變數了。

二、閉包

#對於那些有一點JavaScript 使用經驗但從未真正理解閉包概念的人來說,理解閉包可以看作是某種意義上的重生,突破閉包的瓶頸可以使你功力大增。

  • 閉包與作用域鏈息息相關;

  • #閉包是在函數執行過程中被確認。

先直截了當的拋出閉包的定義:當函數可以記住並存取所在的作用域(全域作用域除外)時,就產生了閉包,即使函數是在目前作用域之外執行。

簡單來說,假設函數A在函數B的內部進行定義了,並且當函數A在執行時,訪問了函數B內部的變數對象,那麼B就是一個閉包。

在基礎進階(一)中,我總結了JavaScript的垃圾回收機制。 JavaScript擁有自動的垃圾回收機制,關於垃圾回收機制,有一個重要的行為,那就是,當一個值,在記憶體中失去引用時,垃圾回收機制會根據特殊的演算法找到它,並將其回收,釋放記憶體。

而我們知道,函數的執行上下文,在執行完畢之後,生命週期結束,那麼該函數的執行上下文就會失去引用。其佔用的記憶體空間很快就會被垃圾回收器釋放。可是閉包的存在,會阻止這個過程。

先來一個簡單的例子。

var fn = null;
function foo() {
    var a = 2;
    function innnerFoo() { 
        console.log(a);
    }
    fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}

function bar() {
    fn(); // 此处的保留的innerFoo的引用
}

foo();
bar(); // 2

在上面的範例中,foo()執行完畢之後,按照常理,其執行環境生命週期會結束,所佔記憶體被垃圾收集器釋放。但是透過fn = innerFoo,函數innerFoo的引用被保留了下來,複製給了全域變數fn。這個行為,導致了foo的變數對象,也被保留了下來。於是,函數fn在函數bar內部執行時,依然可以存取這個被保留下來的變數物件。所以此刻仍然能夠存取到變數a的值。

這樣,我們就可以稱foo為閉包。

下圖展示了閉包foo的作用域鏈。

前端進階(四):詳細圖解作用域鏈與閉包

閉包foo的作用域鏈,圖中標題寫錯了,請無視

我們可以在chrome瀏覽器的開發者工具中查看這段程式碼運行時產生的函數呼叫棧與作用域鏈的生成情況。如下圖。

關於如何在chrome中觀察閉包,以及更多閉包的例子,請閱讀基礎系列(六)

前端進階(四):詳細圖解作用域鏈與閉包

#從圖中可以看出,chrome瀏覽器認為閉包是foo,而不是通常我們認為的innerFoo

在上面的图中,红色箭头所指的正是闭包。其中Call Stack为当前的函数调用栈,Scope为当前正在被执行的函数的作用域链,Local为当前的局部变量。

所以,通过闭包,我们可以在其他的执行上下文中,访问到函数的内部变量。比如在上面的例子中,我们在函数bar的执行环境中访问到了函数foo的a变量。个人认为,从应用层面,这是闭包最重要的特性。利用这个特性,我们可以实现很多有意思的东西。

不过读者老爷们需要注意的是,虽然例子中的闭包被保存在了全局变量中,但是闭包的作用域链并不会发生任何改变。在闭包中,能访问到的变量,仍然是作用域链上能够查询到的变量。

对上面的例子稍作修改,如果我们在函数bar中声明一个变量c,并在闭包fn中试图访问该变量,运行结果会抛出错误。

var fn = null;
function foo() {
    var a = 2;
    function innnerFoo() { 
        console.log(c); // 在这里,试图访问函数bar中的c变量,会抛出错误
        console.log(a);
    }
    fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}

function bar() {
    var c = 100;
    fn(); // 此处的保留的innerFoo的引用
}

foo();
bar();

关于这一点,很多同学把函数调用栈与作用域链没有分清楚,所以有的大神看了我关于介绍执行上下文的文章时就义正言辞的说我的例子有问题,而这些评论有很大的误导作用,为了帮助大家自己拥有能够辨别的能力,所以我写了基础(六),教大家如何在chrome中观察闭包,作用域链,this等。当然我也不敢100%保证我文中的例子就一定正确,所以教大家如何去辨认我认为才是最重要的。

闭包的应用场景

接下来,我们来总结下,闭包的常用场景。

我们知道setTimeout的第一个参数是一个函数,第二个参数则是延迟的时间。在下面例子中,

function fn() {
    console.log('this is test.')
}
var timer =  setTimeout(fn, 1000);
console.log(timer);

执行上面的代码,变量timer的值,会立即输出出来,表示setTimeout这个函数本身已经执行完毕了。但是一秒钟之后,fn才会被执行。这是为什么?

按道理来说,既然fn被作为参数传入了setTimeout中,那么fn将会被保存在setTimeout变量对象中,setTimeout执行完毕之后,它的变量对象也就不存在了。可是事实上并不是这样。至少在这一秒钟的事件里,它仍然是存在的。这正是因为闭包。

很显然,这是在函数的内部实现中,setTimeout通过特殊的方式,保留了fn的引用,让setTimeout的变量对象,并没有在其执行完毕后被垃圾收集器回收。因此setTimeout执行结束后一秒,我们任然能够执行fn函数。

  • 柯里化

在函数式编程中,利用闭包能够实现很多炫酷的功能,柯里化算是其中一种。关于柯里化,我会在以后详解函数式编程的时候仔细总结。

  • 模块

在我看来,模块是闭包最强大的一个应用场景。如果你是初学者,对于模块的了解可以暂时不用放在心上,因为理解模块需要更多的基础知识。但是如果你已经有了很多JavaScript的使用经验,在彻底了解了闭包之后,不妨借助本文介绍的作用域链与闭包的思路,重新理一理关于模块的知识。这对于我们理解各种各样的设计模式具有莫大的帮助。

(function () {
    var a = 10;
    var b = 20;

    function add(num1, num2) {
        var num1 = !!num1 ? num1 : a;
        var num2 = !!num2 ? num2 : b;

        return num1 + num2;
    }

    window.add = add;
})();

add(10, 20);

在上面的例子中,我使用函数自执行的方式,创建了一个模块。add是模块对外暴露的一个公共方法。而变量a,b被作为私有变量。在面向对象的开发中,我们常常需要考虑是将变量作为私有变量,还是放在构造函数中的this中,因此理解闭包,以及原型链是一个非常重要的事情。模块十分重要,因此我会在以后的文章专门介绍,这里就暂时不多说啦。

前端進階(四):詳細圖解作用域鏈與閉包

此图中可以观看到当代码执行到add方法时的调用栈与作用域链,此刻的闭包为外层的自执行函数

为了验证自己有没有搞懂作用域链与闭包,这里留下一个经典的思考题,常常也会在面试中被问到。

利用闭包,修改下面的代码,让循环输出的结果依次为1, 2, 3, 4, 5

for (var i=1; i<=5; i++) { 
    setTimeout( function timer() {
        console.log(i);
    }, i*1000 );
}

点此查看关于此题的详细解读

关于作用域链的与闭包我就总结完了,虽然我自认为我是说得非常清晰了,但是我知道理解闭包并不是一件简单的事情,所以如果你有什么问题,可以在评论中问我。你也可以带着从别的地方没有看懂的例子在评论中留言。大家一起学习进步。

以上是前端進階(四):詳細圖解作用域鏈與閉包的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn